Skip to content
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

feat: username and password support for SSH connection type #1126

Open
wants to merge 30 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
482f2f2
feat: auth handler skeleton
smorrisj Aug 1, 2024
bda97f1
fix: type on authsLeft
smorrisj Aug 1, 2024
86840c7
chore: progress on auth handler
smorrisj Aug 1, 2024
7404025
fix: brain working faster than fingers
smorrisj Aug 1, 2024
54d8d66
Merge branch 'main' into feat/ssh-userpass
smorrisj Aug 28, 2024
36a778c
Merge branch 'main' into feat/ssh-userpass
smorrisj Sep 4, 2024
69b6664
feat: progress on userpass
smorrisj Sep 5, 2024
cc667a4
refactor: auth state cleanup
smorrisj Sep 5, 2024
38a575a
refactor: move auth details to separate module
smorrisj Sep 5, 2024
f0016cb
feat: keepalive and max unanswered settings
smorrisj Sep 5, 2024
21284e4
use const for keepalive
smorrisj Sep 5, 2024
8aa0c23
refactor: remove redundant timeout test
smorrisj Sep 5, 2024
91a6d0f
chore: copyright header
smorrisj Sep 5, 2024
0607d64
refactor for testability, auth tests
smorrisj Sep 10, 2024
6ac98e8
feat: set working path to work dir
smorrisj Sep 12, 2024
a092524
metadata for private key, various fixes found during testing
smorrisj Sep 13, 2024
978fdb4
Merge branch 'main' into feat/ssh-userpass
smorrisj Sep 13, 2024
debafaf
fix: minor cleanup
smorrisj Sep 17, 2024
e0e26dd
fix: bug fixes found during mfa testing
smorrisj Sep 18, 2024
51ce78a
doc: ssh auth refactor
smorrisj Sep 18, 2024
9879746
Merge branch 'main' into feat/ssh-userpass
smorrisj Sep 18, 2024
ea17b75
rename Connection Profile to Private Key File Path
smorrisj Sep 19, 2024
3e1f267
refactor: remove unused types
smorrisj Sep 19, 2024
76a271f
refactor: use one LineParser
smorrisj Sep 19, 2024
7a2c0b4
refactor: shorter prompting for private key file path
smorrisj Sep 19, 2024
430f80b
fix: remove console logging for debug msgs
smorrisj Sep 20, 2024
04444a7
fix: use a more reasonable unanswered threshhold
smorrisj Sep 23, 2024
5f9ec57
doc: update prompt wording
smorrisj Sep 23, 2024
d3c9cd2
fix: only write private key file key if there is an input value
smorrisj Sep 26, 2024
6d28a43
fix: connection close lifecycle bug
smorrisj Sep 30, 2024
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
40 changes: 40 additions & 0 deletions client/src/connection/LineParser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Copyright © 2024, SAS Institute Inc., Cary, NC, USA. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

export class LineParser {
Copy link
Member

Choose a reason for hiding this comment

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

do we want to remove the itc/LineParser.ts in favor of this?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks for catching. I had originally promoted LineParser up higher. A merge must have errantly added it back. Fixed in 76a271f.

protected processedLines: string[] = [];
protected capturingLine: boolean = false;

public constructor(
protected startTag: string,
protected endTag: string,
protected returnNonProcessedLines: boolean,
) {}

public processLine(line: string): string | undefined {
if (line.includes(this.startTag) || this.capturingLine) {
this.processedLines.push(line);
this.capturingLine = true;
if (line.includes(this.endTag)) {
return this.processedLine();
}
return;
}

return this.returnNonProcessedLines ? line : undefined;
}

protected processedLine(): string {
this.capturingLine = false;
const fullError = this.processedLines
.join("")
.replace(this.startTag, "")
.replace(this.endTag, "");
this.processedLines = [];
return fullError;
}

public isCapturingLine(): boolean {
return this.capturingLine;
}
}
171 changes: 171 additions & 0 deletions client/src/connection/ssh/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { CancellationTokenSource, l10n, window } from "vscode";

import { readFileSync } from "fs";
import { NextAuthHandler, utils } from "ssh2";

let _cancellationSource: CancellationTokenSource | undefined;

/**
* Authenticate to the server using the password method.
* @param cb ssh2 NextHandler callback instance. This is used to pass the authentication information to the ssh server.
* @param resolve a function that resolves the promise that is waiting for the password
* @param username the user name to use for the connection
*/
export const passwordAuth = (
cb: NextAuthHandler,
resolve: ((value?) => void) | undefined,
username: string,
) => {
promptForPassword(resolve).then((pw) => {
cb({
type: "password",
password: pw,
username: username,
});
});
};

/**
* Authenticate to the server using the keyboard-interactive method.
* @param cb ssh2 NextHandler callback instance. This is used to pass the authentication information to the ssh server.
* @param resolve a function that resolves the promise that is waiting for authentication
* @param username the user name to use for the connection
*/
export const keyboardInteractiveAuth = (
cb: NextAuthHandler,
resolve: ((value?) => void) | undefined,
username: string,
) => {
cb({
type: "keyboard-interactive",
username: username,
prompt: (_name, _instructions, _instructionsLang, prompts, cb) => {
if (prompts.length === 1 && prompts[0].prompt === "Password:") {
promptForPassword(resolve).then((pw) => {
cb([pw]);
});
} else {
cb([]);
}
},
});
};

/**
* Authenticate to the server using the ssh-agent. See the extension README for more information on how to set up the ssh-agent.
* @param cb ssh2 NextHandler callback instance. This is used to pass the authentication information to the ssh server.
* @param username the user name to use for the connection
*/
export const sshAgentAuth = (cb: NextAuthHandler, username: string) => {
//attempt to auth using ssh-agent
cb({
type: "agent",
agent: process.env.SSH_AUTH_SOCK,
username: username,
});
};

/**
* Authenticate to the server using a private key file.
* If a private key file is defined in the connection profile, this function will read the file and use it to authenticate to the server.
* If the key is encrypted, the user will be prompted for the passphrase.
* @param cb ssh2 NextHandler callback instance. This is used to pass the authentication information to the ssh server.
* @param resolve a function that resolves the promise that is waiting for authentication
* @param privateKeyFilePath the path to the private key file defined in the connection profile
* @param username the user name to use for the connection
*/
export const privateKeyAuth = (
cb: NextAuthHandler,
resolve: ((value?) => void) | undefined,
privateKeyFilePath: string,
username: string,
) => {
let keyContents: Buffer;
try {
keyContents = readFileSync(privateKeyFilePath);
} catch (e) {
l10n.t("Error reading private key file: {filePath}, error: {message}", {
filePath: privateKeyFilePath,
message: e.message,
});
}
//check for passphrase, prompt if necessary
//and then attempt to auth
const parsedKeyResult = utils.parseKey(keyContents);
const hasParseError = parsedKeyResult instanceof Error;
const passphraseRequired =
hasParseError &&
parsedKeyResult.message ===
"Encrypted OpenSSH private key detected, but no passphrase given";
// key is encrypted, prompt for passphrase
if (passphraseRequired) {
promptForPassphrase(resolve).then((passphrase) => {
//parse the keyfile using the passphrase
const reparsedKeyContentsResult = utils.parseKey(keyContents, passphrase);

if (!(reparsedKeyContentsResult instanceof Error)) {
cb({
type: "publickey",
key: reparsedKeyContentsResult,
passphrase: passphrase,
username: username,
});
}
});
} else {
if (!hasParseError) {
cb({
type: "publickey",
key: parsedKeyResult,
username: username,
});
}
}
};

/**
* Prompt the user for a passphrase.
* @param resolve a function that resolves the promise that is waiting for authentication
* @returns the passphrase entered by the user
*/
const promptForPassphrase = async (resolve): Promise<string> => {
const passphrase = await window.showInputBox({
prompt: l10n.t("Enter the passphrase for the private key."),
password: true,
});

// user cancelled password dialog
if (!passphrase) {
resolve?.({});
}

return passphrase;
};

/**
* Prompt the user for a password.
* @param resolve a function that resolves the promise that is waiting for authentication
* @returns the password entered by the user
*/
const promptForPassword = async (
resolve: ((value?) => void) | undefined,
): Promise<string> => {
const source = new CancellationTokenSource();
_cancellationSource = source;
const pw = await window.showInputBox(
{
ignoreFocusOut: true,
password: true,
prompt: l10n.t("Enter your password for this connection."),
title: l10n.t("Password Required"),
},
_cancellationSource.token,
);

// user cancelled password dialog
if (!pw) {
resolve?.({});
}

return pw;
};
15 changes: 15 additions & 0 deletions client/src/connection/ssh/const.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Copyright © 2024, SAS Institute Inc., Cary, NC, USA. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

const SECOND = 1000;
export const KEEPALIVE_INTERVAL = 60 * SECOND; //How often (in milliseconds) to send SSH-level keepalive packets to the server. Set to 0 to disable.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@chunky any thoughts on going with these values for keepalive and max unanswered?

Copy link

Choose a reason for hiding this comment

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

@smorrisj 60 seconds is a reasonable figure - I think I usually set it to 30, but I have no strong feelings as long as it's well inside 90.
The unanswered_threshold, I confess to be unsure about appropriate behaviour. If the comment is right, it's measured in "pings" not in "time". So if you're aiming for 12 hours, it should be (12 * HOUR / KEEPALIVE_INTERVAL). Either way, because this is happening at the SSH/networking level, then 12 hours is probably way too long. Maybe 15 minutes?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks @chunky. Must have forgot my morning coffee when making the first pass on that value =) See 04444a7.

// 720 * 60 seconds = 43200 seconds = 12 hours
export const KEEPALIVE_UNANSWERED_THRESHOLD = 720; //How many consecutive, unanswered SSH-level keepalive packets that can be sent to the server before disconnection.
export const WORK_DIR_START_TAG = "<WorkDirectory>";
export const WORK_DIR_END_TAG = "</WorkDirectory>";
export const SAS_LAUNCH_TIMEOUT = 600000;
export const SUPPORTED_AUTH_METHODS = [
"publickey",
"password",
"keyboard-interactive",
];
Loading
Loading