Skip to content

Commit 861374f

Browse files
authored
Fix cloning from new host via ssh by prompting for confirmation (#1408)
* Cloning from new host via ssh causes spurious error rather than prompting for confirmation and succeeding * Using strip instead replace * Modifying the Add Hosts messages and running prettier * Running lint+prettier and adding autorized decorator to the SSH API Handler * changing from subprocess.call to subprocess.check_call for better flow control
1 parent c5585fd commit 861374f

File tree

5 files changed

+176
-0
lines changed

5 files changed

+176
-0
lines changed

jupyterlab_git/handlers.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from jupyter_server.services.contents.manager import ContentsManager
1515
from jupyter_server.utils import ensure_async, url2path, url_path_join
1616
from packaging.version import parse
17+
from jupyter_server.auth.decorator import authorized
1718

1819
try:
1920
import hybridcontents
@@ -24,10 +25,26 @@
2425
from .git import DEFAULT_REMOTE_NAME, Git, RebaseAction
2526
from .log import get_logger
2627

28+
from .ssh import SSH
29+
2730
# Git configuration options exposed through the REST API
2831
ALLOWED_OPTIONS = ["user.name", "user.email"]
2932
# REST API namespace
3033
NAMESPACE = "/git"
34+
# SSH Auth Resource to be authorized
35+
SSH_AUTH_RESOURCE = "ssh"
36+
37+
38+
class SSHHandler(APIHandler):
39+
"""
40+
Top-level parent class for SSH actions
41+
"""
42+
43+
auth_resource = SSH_AUTH_RESOURCE
44+
45+
@property
46+
def ssh(self) -> SSH:
47+
return SSH()
3148

3249

3350
class GitHandler(APIHandler):
@@ -1096,6 +1113,30 @@ async def get(self, path: str = ""):
10961113
self.finish(json.dumps(result))
10971114

10981115

1116+
class SshHostHandler(SSHHandler):
1117+
"""
1118+
Handler for checking if a host is known by SSH
1119+
"""
1120+
1121+
@authorized
1122+
@tornado.web.authenticated
1123+
async def get(self):
1124+
"""
1125+
GET request handler, check if the host is known by SSH
1126+
"""
1127+
hostname = self.get_query_argument("hostname")
1128+
is_known_host = self.ssh.is_known_host(hostname)
1129+
self.set_status(200)
1130+
self.finish(json.dumps(is_known_host))
1131+
1132+
@authorized
1133+
@tornado.web.authenticated
1134+
async def post(self):
1135+
data = self.get_json_body()
1136+
hostname = data["hostname"]
1137+
self.ssh.add_host(hostname)
1138+
1139+
10991140
def setup_handlers(web_app):
11001141
"""
11011142
Setups all of the git command handlers.
@@ -1146,6 +1187,7 @@ def setup_handlers(web_app):
11461187
handlers = [
11471188
("/diffnotebook", GitDiffNotebookHandler),
11481189
("/settings", GitSettingsHandler),
1190+
("/known_hosts", SshHostHandler),
11491191
]
11501192

11511193
# add the baseurl to our paths

jupyterlab_git/ssh.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
"""
2+
Module for executing SSH commands
3+
"""
4+
5+
import re
6+
import subprocess
7+
import shutil
8+
from .log import get_logger
9+
from pathlib import Path
10+
11+
GIT_SSH_HOST = re.compile(r"git@(.+):.+")
12+
13+
14+
class SSH:
15+
"""
16+
A class to perform ssh actions
17+
"""
18+
19+
def is_known_host(self, hostname):
20+
"""
21+
Check if the provided hostname is a known one
22+
"""
23+
cmd = ["ssh-keygen", "-F", hostname.strip()]
24+
try:
25+
subprocess.check_call(
26+
cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
27+
)
28+
except Exception as e:
29+
get_logger().debug("Error verifying host using ssh-keygen command")
30+
return False
31+
else:
32+
return True
33+
34+
def add_host(self, hostname):
35+
"""
36+
Add the host to the known_hosts file
37+
"""
38+
get_logger().debug(f"adding host to the known hosts file {hostname}")
39+
try:
40+
result = subprocess.run(
41+
["ssh-keyscan", hostname], capture_output=True, text=True, check=True
42+
)
43+
known_hosts_file = f"{Path.home()}/.ssh/known_hosts"
44+
with open(known_hosts_file, "a") as f:
45+
f.write(result.stdout)
46+
get_logger().debug(f"Added {hostname} to known hosts.")
47+
except Exception as e:
48+
get_logger().error(f"Failed to add host: {e}.")
49+
raise e

src/cloneCommand.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,30 @@ export const gitCloneCommandPlugin: JupyterFrontEndPlugin<void> = {
6363
const id = Notification.emit(trans.__('Cloning…'), 'in-progress', {
6464
autoClose: false
6565
});
66+
const url = decodeURIComponent(result.value.url);
67+
const hostnameMatch = url.match(/git@(.+):.+/);
68+
69+
if (hostnameMatch && hostnameMatch.length > 1) {
70+
const hostname = hostnameMatch[1];
71+
const isKnownHost = await gitModel.checkKnownHost(hostname);
72+
if (!isKnownHost) {
73+
const result = await showDialog({
74+
title: trans.__('Unknown Host'),
75+
body: trans.__(
76+
'The host %1 is not known. Would you like to add it to the known_hosts file?',
77+
hostname
78+
),
79+
buttons: [
80+
Dialog.cancelButton({ label: trans.__('Cancel') }),
81+
Dialog.okButton({ label: trans.__('OK') })
82+
]
83+
});
84+
if (result.button.accept) {
85+
await gitModel.addHostToKnownList(hostname);
86+
}
87+
}
88+
}
89+
6690
try {
6791
const details = await showGitOperationDialog<IGitCloneArgs>(
6892
gitModel as GitExtension,

src/model.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2012,6 +2012,50 @@ export class GitExtension implements IGitExtension {
20122012
}
20132013
}
20142014

2015+
/**
2016+
* Checks if the hostname is a known host
2017+
*
2018+
* @param hostname - the host name to be checked
2019+
* @returns A boolean indicating that the host is a known one
2020+
*
2021+
* @throws {ServerConnection.NetworkError} If the request cannot be made
2022+
*/
2023+
async checkKnownHost(hostname: string): Promise<boolean> {
2024+
try {
2025+
return await this._taskHandler.execute<boolean>(
2026+
'git:checkHost',
2027+
async () => {
2028+
return await requestAPI<boolean>(
2029+
`known_hosts?hostname=${hostname}`,
2030+
'GET'
2031+
);
2032+
}
2033+
);
2034+
} catch (error) {
2035+
console.error('Failed to check host: ' + error);
2036+
// just ignore the host check
2037+
return true;
2038+
}
2039+
}
2040+
2041+
/**
2042+
* Adds a hostname to the list of known host files
2043+
* @param hostname - the hostname to be added
2044+
* @throws {ServerConnection.NetworkError} If the request cannot be made
2045+
*/
2046+
async addHostToKnownList(hostname: string): Promise<void> {
2047+
try {
2048+
await this._taskHandler.execute<boolean>('git:addHost', async () => {
2049+
return await requestAPI<boolean>('known_hosts', 'POST', {
2050+
hostname: hostname
2051+
});
2052+
});
2053+
} catch (error) {
2054+
console.error('Failed to add hostname to the list of known hosts');
2055+
console.debug(error);
2056+
}
2057+
}
2058+
20152059
/**
20162060
* Make request for a list of all git branches in the repository
20172061
* Retrieve a list of repository branches.

src/tokens.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -628,6 +628,23 @@ export interface IGitExtension extends IDisposable {
628628
*/
629629
revertCommit(message: string, hash: string): Promise<void>;
630630

631+
/**
632+
* Checks if the hostname is a known host
633+
*
634+
* @param hostname - the host name to be checked
635+
* @returns A boolean indicating that the host is a known one
636+
*
637+
* @throws {ServerConnection.NetworkError} If the request cannot be made
638+
*/
639+
checkKnownHost(hostname: string): Promise<boolean>;
640+
641+
/**
642+
* Adds a hostname to the list of known host files
643+
* @param hostname - the hostname to be added
644+
* @throws {ServerConnection.NetworkError} If the request cannot be made
645+
*/
646+
addHostToKnownList(hostname: string): Promise<void>;
647+
631648
/**
632649
* Get the prefix path of a directory 'path',
633650
* with respect to the root directory of repository

0 commit comments

Comments
 (0)