From 767abcf7315b2534f7c693823bda96b734daea12 Mon Sep 17 00:00:00 2001 From: William Siqueira Date: Fri, 4 Apr 2025 23:32:40 -0300 Subject: [PATCH 1/3] Cloning from new host via ssh causes spurious error rather than prompting for confirmation and succeeding --- jupyterlab_git/handlers.py | 32 +++++++++++++++++++ jupyterlab_git/ssh.py | 48 ++++++++++++++++++++++++++++ src/cloneCommand.tsx | 22 +++++++++++++ src/model.ts | 64 ++++++++++++++++++++++++++++++++++---- src/tokens.ts | 17 ++++++++++ 5 files changed, 177 insertions(+), 6 deletions(-) create mode 100644 jupyterlab_git/ssh.py diff --git a/jupyterlab_git/handlers.py b/jupyterlab_git/handlers.py index 34d09c506..188168435 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. @@ -1087,6 +1096,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. @@ -1137,6 +1168,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 000000000..e9a69631e --- /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 given git clone URL contains a known host + """ + cmd = ["ssh-keygen", "-F", hostname.replace(" ", "")] + 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 12efa6ae1..7bd103770 100644 --- a/src/cloneCommand.tsx +++ b/src/cloneCommand.tsx @@ -63,6 +63,28 @@ 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 is unknown, would you like to add it to the list of known hosts?'), + 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 aa4204066..a525a3d5c 100644 --- a/src/model.ts +++ b/src/model.ts @@ -368,7 +368,7 @@ export class GitExtension implements IGitExtension { */ protected get _currentMarker(): BranchMarker { if (this.pathRepository === null) { - return new BranchMarker(() => {}); + return new BranchMarker(() => { }); } if (!this.__currentMarker) { @@ -419,8 +419,8 @@ export class GitExtension implements IGitExtension { } const fileStatus = this._status?.files ? this._status.files.find(status => { - return this.getRelativeFilePath(status.to) === path; - }) + return this.getRelativeFilePath(status.to) === path; + }) : null; if (!fileStatus) { @@ -2012,6 +2012,58 @@ 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. @@ -2281,7 +2333,7 @@ export class GitExtension implements IGitExtension { private _fetchPoll: Poll; private _isDisposed = false; private _markerCache = new Markers(() => this._markChanged.emit()); - private __currentMarker: BranchMarker = new BranchMarker(() => {}); + private __currentMarker: BranchMarker = new BranchMarker(() => { }); private _readyPromise: Promise = Promise.resolve(); private _pendingReadyPromise = 0; private _settings: ISettingRegistry.ISettings | null; @@ -2326,7 +2378,7 @@ export class GitExtension implements IGitExtension { } export class BranchMarker implements Git.IBranchMarker { - constructor(private _refresh: () => void) {} + constructor(private _refresh: () => void) { } add(fname: string, mark = true): void { if (!(fname in this._marks)) { @@ -2361,7 +2413,7 @@ export class BranchMarker implements Git.IBranchMarker { } export class Markers { - constructor(private _refresh: () => void) {} + constructor(private _refresh: () => void) { } get(path: string, branch: string): BranchMarker { const key = Markers.markerKey(path, branch); diff --git a/src/tokens.ts b/src/tokens.ts index e94c79700..11c3cddbe 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 From 4b24bf9420a0d92570678d1a610ad0f5048153aa Mon Sep 17 00:00:00 2001 From: William Siqueira Date: Wed, 4 Jun 2025 16:34:40 -0300 Subject: [PATCH 2/3] Using strip instead replace --- jupyterlab_git/ssh.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/jupyterlab_git/ssh.py b/jupyterlab_git/ssh.py index e9a69631e..c5ef66c3c 100644 --- a/jupyterlab_git/ssh.py +++ b/jupyterlab_git/ssh.py @@ -18,9 +18,9 @@ class SSH: def is_known_host(self, hostname): """ - Check if the given git clone URL contains a known host + Check if the provided hostname is a known one """ - cmd = ["ssh-keygen", "-F", hostname.replace(" ", "")] + cmd = ["ssh-keygen", "-F", hostname.strip()] try: code = subprocess.call( cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL From 785952161e8020b6a156c15644afc1256a0b9eb1 Mon Sep 17 00:00:00 2001 From: William Siqueira Date: Tue, 10 Jun 2025 15:31:38 -0300 Subject: [PATCH 3/3] Modifying the Add Hosts messages and running prettier --- src/cloneCommand.tsx | 6 ++++-- src/model.ts | 31 +++++++++++-------------------- 2 files changed, 15 insertions(+), 22 deletions(-) diff --git a/src/cloneCommand.tsx b/src/cloneCommand.tsx index 7bd103770..7b2dbf00a 100644 --- a/src/cloneCommand.tsx +++ b/src/cloneCommand.tsx @@ -72,7 +72,10 @@ export const gitCloneCommandPlugin: JupyterFrontEndPlugin = { if (!isKnownHost) { const result = await showDialog({ title: trans.__('Unknown Host'), - body: trans.__('The host is unknown, would you like to add it to the list of known hosts?'), + 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') }) @@ -84,7 +87,6 @@ export const gitCloneCommandPlugin: JupyterFrontEndPlugin = { } } - try { const details = await showGitOperationDialog( gitModel as GitExtension, diff --git a/src/model.ts b/src/model.ts index a525a3d5c..801f4b65b 100644 --- a/src/model.ts +++ b/src/model.ts @@ -368,7 +368,7 @@ export class GitExtension implements IGitExtension { */ protected get _currentMarker(): BranchMarker { if (this.pathRepository === null) { - return new BranchMarker(() => { }); + return new BranchMarker(() => {}); } if (!this.__currentMarker) { @@ -419,8 +419,8 @@ export class GitExtension implements IGitExtension { } const fileStatus = this._status?.files ? this._status.files.find(status => { - return this.getRelativeFilePath(status.to) === path; - }) + return this.getRelativeFilePath(status.to) === path; + }) : null; if (!fileStatus) { @@ -2031,7 +2031,6 @@ export class GitExtension implements IGitExtension { ); } ); - } catch (error) { console.error('Failed to check host'); // just ignore the host check @@ -2046,19 +2045,11 @@ export class GitExtension implements IGitExtension { */ async addHostToKnownList(hostname: string): Promise { try { - await this._taskHandler.execute( - 'git:addHost', - async () => { - return await requestAPI( - `known_hosts`, - 'POST', - { - hostname: hostname - } - ); - } - ); - + 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'); } @@ -2333,7 +2324,7 @@ export class GitExtension implements IGitExtension { private _fetchPoll: Poll; private _isDisposed = false; private _markerCache = new Markers(() => this._markChanged.emit()); - private __currentMarker: BranchMarker = new BranchMarker(() => { }); + private __currentMarker: BranchMarker = new BranchMarker(() => {}); private _readyPromise: Promise = Promise.resolve(); private _pendingReadyPromise = 0; private _settings: ISettingRegistry.ISettings | null; @@ -2378,7 +2369,7 @@ export class GitExtension implements IGitExtension { } export class BranchMarker implements Git.IBranchMarker { - constructor(private _refresh: () => void) { } + constructor(private _refresh: () => void) {} add(fname: string, mark = true): void { if (!(fname in this._marks)) { @@ -2413,7 +2404,7 @@ export class BranchMarker implements Git.IBranchMarker { } export class Markers { - constructor(private _refresh: () => void) { } + constructor(private _refresh: () => void) {} get(path: string, branch: string): BranchMarker { const key = Markers.markerKey(path, branch);