Skip to content

Cloning from new host via ssh causes spurious error rather than prompting for confirmation and succeeding #1408

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions jupyterlab_git/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -1137,6 +1168,7 @@ def setup_handlers(web_app):
handlers = [
("/diffnotebook", GitDiffNotebookHandler),
("/settings", GitSettingsHandler),
("/known_hosts", SshHostHandler),
]

# add the baseurl to our paths
Expand Down
48 changes: 48 additions & 0 deletions jupyterlab_git/ssh.py
Original file line number Diff line number Diff line change
@@ -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):

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this be implemented in a "ask for forgiveness, not for permission" manner? That is, run clone, and if it fails on host not known, prompt user to add host?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That could be done as well, the issue for me doing this at first is to know if the error message remains consistent in all systems so I can check it on client then prompt user, hence I did added the check before attempting to clone. Should I go ahead and do the approach you suggested anyway?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't know. It makes sense to me, don't know if it would make sense to the maintainers here.

Is it possible to somehow hook into the git/ssh/credential-helper stuff more tightly so that you aren't reduced to parsing user-facing messages on stdout to determine what's happening? If you'd have to do that, then current solution is way better.

"""
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
22 changes: 22 additions & 0 deletions src/cloneCommand.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,28 @@ export const gitCloneCommandPlugin: JupyterFrontEndPlugin<void> = {
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<IGitCloneArgs>(
gitModel as GitExtension,
Expand Down
64 changes: 58 additions & 6 deletions src/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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<Boolean> {
try {
return await this._taskHandler.execute<Boolean>(
'git:checkHost',
async () => {
return await requestAPI<Boolean>(
`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<void> {
try {
await this._taskHandler.execute<Boolean>(
'git:addHost',
async () => {
return await requestAPI<Boolean>(
`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.
Expand Down Expand Up @@ -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<void> = Promise.resolve();
private _pendingReadyPromise = 0;
private _settings: ISettingRegistry.ISettings | null;
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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);
Expand Down
17 changes: 17 additions & 0 deletions src/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -628,6 +628,23 @@ export interface IGitExtension extends IDisposable {
*/
revertCommit(message: string, hash: string): Promise<void>;

/**
* 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<Boolean>;

/**
* 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<void>;

/**
* Get the prefix path of a directory 'path',
* with respect to the root directory of repository
Expand Down