diff --git a/jupyterlab_git/handlers.py b/jupyterlab_git/handlers.py index 83a118ad..702d5f01 100644 --- a/jupyterlab_git/handlers.py +++ b/jupyterlab_git/handlers.py @@ -24,12 +24,21 @@ from .git import DEFAULT_REMOTE_NAME, Git, RebaseAction from .log import get_logger +from .ssh import SSH + # Git configuration options exposed through the REST API ALLOWED_OPTIONS = ["user.name", "user.email"] # REST API namespace NAMESPACE = "/git" +class SSHHandler(APIHandler): + + @property + def ssh(self) -> SSH: + return SSH() + + class GitHandler(APIHandler): """ Top-level parent class. @@ -1096,6 +1105,28 @@ async def get(self, path: str = ""): self.finish(json.dumps(result)) +class SshHostHandler(SSHHandler): + """ + Handler for checking if a host is known by SSH + """ + + @tornado.web.authenticated + async def get(self): + """ + GET request handler, check if the host is known by SSH + """ + hostname = self.get_query_argument("hostname") + is_known_host = self.ssh.is_known_host(hostname) + self.set_status(200) + self.finish(json.dumps(is_known_host)) + + @tornado.web.authenticated + async def post(self): + data = self.get_json_body() + hostname = data["hostname"] + self.ssh.add_host(hostname) + + def setup_handlers(web_app): """ Setups all of the git command handlers. @@ -1146,6 +1177,7 @@ def setup_handlers(web_app): handlers = [ ("/diffnotebook", GitDiffNotebookHandler), ("/settings", GitSettingsHandler), + ("/known_hosts", SshHostHandler), ] # add the baseurl to our paths diff --git a/jupyterlab_git/ssh.py b/jupyterlab_git/ssh.py new file mode 100644 index 00000000..c5ef66c3 --- /dev/null +++ b/jupyterlab_git/ssh.py @@ -0,0 +1,48 @@ +""" +Module for executing SSH commands +""" + +import re +import subprocess +import shutil +from .log import get_logger +from pathlib import Path + +GIT_SSH_HOST = re.compile(r"git@(.+):.+") + + +class SSH: + """ + A class to perform ssh actions + """ + + def is_known_host(self, hostname): + """ + Check if the provided hostname is a known one + """ + cmd = ["ssh-keygen", "-F", hostname.strip()] + try: + code = subprocess.call( + cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL + ) + return code == 0 + except subprocess.CalledProcessError as e: + get_logger().debug("Error verifying host using keygen") + raise e + + def add_host(self, hostname): + """ + Add the host to the known_hosts file + """ + get_logger().debug(f"adding host to the known hosts file {hostname}") + try: + result = subprocess.run( + ["ssh-keyscan", hostname], capture_output=True, text=True, check=True + ) + known_hosts_file = f"{Path.home()}/.ssh/known_hosts" + with open(known_hosts_file, "a") as f: + f.write(result.stdout) + get_logger().debug(f"Added {hostname} to known hosts.") + except Exception as e: + get_logger().error(f"Failed to add host: {e}.") + raise e diff --git a/src/cloneCommand.tsx b/src/cloneCommand.tsx index 12efa6ae..7b2dbf00 100644 --- a/src/cloneCommand.tsx +++ b/src/cloneCommand.tsx @@ -63,6 +63,30 @@ export const gitCloneCommandPlugin: JupyterFrontEndPlugin = { const id = Notification.emit(trans.__('Cloning…'), 'in-progress', { autoClose: false }); + const url = decodeURIComponent(result.value.url); + const hostnameMatch = url.match(/git@(.+):.+/); + + if (hostnameMatch && hostnameMatch.length > 1) { + const hostname = hostnameMatch[1]; + const isKnownHost = await gitModel.checkKnownHost(hostname); + if (!isKnownHost) { + const result = await showDialog({ + title: trans.__('Unknown Host'), + body: trans.__( + 'The host %1 is not known. Would you like to add it to the known_hosts file?', + hostname + ), + buttons: [ + Dialog.cancelButton({ label: trans.__('Cancel') }), + Dialog.okButton({ label: trans.__('OK') }) + ] + }); + if (result.button.accept) { + await gitModel.addHostToKnownList(hostname); + } + } + } + try { const details = await showGitOperationDialog( gitModel as GitExtension, diff --git a/src/model.ts b/src/model.ts index aa420406..801f4b65 100644 --- a/src/model.ts +++ b/src/model.ts @@ -2012,6 +2012,49 @@ export class GitExtension implements IGitExtension { } } + /** + * Checks if the hostname is a known host + * + * @param hostname - the host name to be checked + * @returns A boolean indicating that the host is a known one + * + * @throws {ServerConnection.NetworkError} If the request cannot be made + */ + async checkKnownHost(hostname: string): Promise { + try { + return await this._taskHandler.execute( + 'git:checkHost', + async () => { + return await requestAPI( + `known_hosts?hostname=${hostname}`, + 'GET' + ); + } + ); + } catch (error) { + console.error('Failed to check host'); + // just ignore the host check + return true; + } + } + + /** + * Adds a hostname to the list of known host files + * @param hostname - the hostname to be added + * @throws {ServerConnection.NetworkError} If the request cannot be made + */ + async addHostToKnownList(hostname: string): Promise { + try { + await this._taskHandler.execute('git:addHost', async () => { + return await requestAPI(`known_hosts`, 'POST', { + hostname: hostname + }); + }); + } catch (error) { + console.error('Failed to add hostname to the list of known hosts'); + } + } + /** * Make request for a list of all git branches in the repository * Retrieve a list of repository branches. diff --git a/src/tokens.ts b/src/tokens.ts index e94c7970..11c3cddb 100644 --- a/src/tokens.ts +++ b/src/tokens.ts @@ -628,6 +628,23 @@ export interface IGitExtension extends IDisposable { */ revertCommit(message: string, hash: string): Promise; + /** + * Checks if the hostname is a known host + * + * @param hostname - the host name to be checked + * @returns A boolean indicating that the host is a known one + * + * @throws {ServerConnection.NetworkError} If the request cannot be made + */ + checkKnownHost(hostname: string): Promise; + + /** + * Adds a hostname to the list of known host files + * @param hostname - the hostname to be added + * @throws {ServerConnection.NetworkError} If the request cannot be made + */ + addHostToKnownList(hostname: string): Promise; + /** * Get the prefix path of a directory 'path', * with respect to the root directory of repository