Skip to content

Commit

Permalink
Support for auth with pub/priv keys in SSH sessions
Browse files Browse the repository at this point in the history
  • Loading branch information
sedwards2009 committed May 26, 2024
1 parent 3543ade commit 8cdd7af
Show file tree
Hide file tree
Showing 4 changed files with 140 additions and 8 deletions.
32 changes: 27 additions & 5 deletions extensions/SSHSessionBackend/src/SSHPty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export interface PtyOptions {
host: string;
port: number;
username: string;
privateKeyFilenames?: string[];
}

enum PtyState {
Expand All @@ -40,7 +41,7 @@ export class SSHPty implements Pty {
private _log: Logger;
#sshConnection: ssh2.Client = null;
#stream: ssh2.ClientChannel = null;
#possibleAuthTypes: ssh2.AuthenticationType[] = [];
#remainingPrivateKeyFilenames: string[] = [];
#passwordCallback: ssh2.NextAuthHandler = null;
#password: string = "";

Expand Down Expand Up @@ -69,6 +70,7 @@ export class SSHPty implements Pty {
constructor(log: Logger, options: PtyOptions) {
this._log = log;
this.#ptyOptions = options;
this.#remainingPrivateKeyFilenames = [...(options.privateKeyFilenames ?? [])];
this.onData = this.#onDataEventEmitter.event;
this.onExit = this.#onExitEventEmitter.event;
this.onAvailableWriteBufferSizeChange = this.#onAvailableWriteBufferSizeChangeEventEmitter.event;
Expand Down Expand Up @@ -354,11 +356,14 @@ export class SSHPty implements Pty {
partialSuccess: boolean,
callback: ssh2.NextAuthHandler): void {

if (this.#possibleAuthTypes.length === 0) {
this.#startPasswordInput(callback);
return;
while (this.#remainingPrivateKeyFilenames.length !== 0) {
const keyFilename = this.#remainingPrivateKeyFilenames.pop();
if (this.#handlePrivateKeyAuth(keyFilename, callback)) {
return;
}
}
// const authMethod = this.#possibleAuthTypes.shift();

this.#startPasswordInput(callback);
}

#startPasswordInput(callback: ssh2.NextAuthHandler): void {
Expand Down Expand Up @@ -388,6 +393,23 @@ export class SSHPty implements Pty {
}
}

#handlePrivateKeyAuth(keyFilename: string, callback: ssh2.NextAuthHandler): boolean {
try {
if (fs.existsSync(keyFilename)) {
const fileContents = fs.readFileSync(keyFilename);
callback({
type: "publickey",
username: this.#ptyOptions.username,
key: fileContents
});
return true;
}
} catch (error) {
this._log.warn(`Failed to read private key file '${keyFilename}'. ${error}`);
}
return false;
}

#closeSSHConnection(): void {
this.#onDataEventEmitter.fire(`\n\r\n[Connection closed. Press Enter to close this terminal.]`);
this.#state = PtyState.WAIT_EXIT_CONFIRM;
Expand Down
29 changes: 29 additions & 0 deletions extensions/SSHSessionBackend/src/SSHSessionBackendExtension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,27 @@
*/
import * as child_process from "node:child_process";
import * as os from "node:os";
import * as path from "node:path";

import { ExtensionContext, Logger, Pty, SessionConfiguration, SessionBackend,
CreateSessionOptions,
EnvironmentMap} from "@extraterm/extraterm-extension-api";
import { PtyOptions, SSHPty } from "./SSHPty";

// Note: This is duplicated in SSHSessionEditorExtension.ts.
enum AuthenticationMethod {
DEFAULT_KEYS_PASSWORD,
PASSWORD_ONLY,
KEY_FILE_ONLY
};

// Note: This is duplicated in SSHSessionEditorExtension.ts.
interface SSHSessionConfiguration extends SessionConfiguration {
host?: string;
port?: number;
username?: string;
authenicationMethod?: AuthenticationMethod;
keyFilePath?: string;
}

class SSHBackend implements SessionBackend {
Expand All @@ -41,6 +51,24 @@ class SSHBackend implements SessionBackend {

const preMessage = "";

const privateKeyFilenames: string[] = [];
switch (sessionConfig.authenicationMethod) {
case AuthenticationMethod.DEFAULT_KEYS_PASSWORD:
const homeDir = os.homedir();
privateKeyFilenames.push(path.join(homeDir, ".ssh", "id_rsa"));
privateKeyFilenames.push(path.join(homeDir, ".ssh", "id_dsa"));
privateKeyFilenames.push(path.join(homeDir, ".ssh", "id_ecdsa"));
privateKeyFilenames.push(path.join(homeDir, ".ssh", "id_ed25519"));
break;

case AuthenticationMethod.PASSWORD_ONLY:
break;

case AuthenticationMethod.KEY_FILE_ONLY:
privateKeyFilenames.push(sessionConfig.keyFilePath);
break;
}

const options: PtyOptions = {
env: this.#createEnv(sessionOptions),
cols: sessionOptions.cols,
Expand All @@ -49,6 +77,7 @@ class SSHBackend implements SessionBackend {
host: sessionConfig.host,
port: sessionConfig.port,
username: username,
privateKeyFilenames,
};

return new SSHPty(this._log, options);
Expand Down
80 changes: 78 additions & 2 deletions extensions/SSHSessionEditor/src/SSHSessionEditorExtension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,28 @@
* This source code is licensed under the MIT license which is detailed in the LICENSE.txt file.
*/
import {ExtensionContext, Logger, SessionConfiguration, SessionEditorBase} from "@extraterm/extraterm-extension-api";
import { Direction, QComboBox, QLineEdit, QRadioButton, QWidget } from "@nodegui/nodegui";
import { BoxLayout, ComboBox, GridLayout, LineEdit, SpinBox, setCssClasses, Widget } from "qt-construct";
import { Direction, FileMode, QFileDialog, QLabel, QPushButton, QWidget } from "@nodegui/nodegui";
import { BoxLayout, ComboBox, GridLayout, LineEdit, SpinBox, Widget, Label, PushButton } from "qt-construct";


let log: Logger = null;

// Note: This is duplicated in SSHSessionBackendExtension.ts.
enum AuthenticationMethod {
DEFAULT_KEYS_PASSWORD,
PASSWORD_ONLY,
KEY_FILE_ONLY
};

const AUTHENTICATION_METHOD_LABELS = ["Default OpenSSH keys, Password", "Password only", "Key file only"];

// Note: This is duplicated in SSHSessionBackendExtension.ts.
interface SSHSessionConfiguration extends SessionConfiguration {
host?: string;
port?: number;
username?: string;
authenicationMethod?: AuthenticationMethod;
keyFilePath?: string;
}

export function activate(context: ExtensionContext): any {
Expand All @@ -28,9 +40,16 @@ function SessionEditorFactory(sessionEditorBase: SessionEditorBase): QWidget {
class EditorUi {

#widget: QWidget = null;
#sessionEditorBase: SessionEditorBase = null;

#config: SSHSessionConfiguration = null;
#selectedKeyFileLabel: QLabel = null;
#selectKeyFileButton: QPushButton = null;

#fileDialog: QFileDialog = null;

constructor(sessionEditorBase: SessionEditorBase) {
this.#sessionEditorBase = sessionEditorBase;
this.#config = <SSHSessionConfiguration> sessionEditorBase.sessionConfiguration;

this.#config.port = this.#config.port ?? 22;
Expand Down Expand Up @@ -79,9 +98,66 @@ class EditorUi {
sessionEditorBase.setSessionConfiguration(this.#config);
}
}),

"Authentication:",
ComboBox({
currentIndex: this.#config.authenicationMethod ?? AuthenticationMethod.DEFAULT_KEYS_PASSWORD,
items: AUTHENTICATION_METHOD_LABELS,
onCurrentIndexChanged: (index: number): void => {
this.#config.authenicationMethod = index;
sessionEditorBase.setSessionConfiguration(this.#config);
this.#updateKeyFileLabel();
}
}),

"",
Widget({
layout: BoxLayout({
direction: Direction.LeftToRight,
contentsMargins: [0, 0, 0, 0],
children: [
{
widget: this.#selectedKeyFileLabel = Label({text: ""}),
stretch: 1,
},
{
widget: this.#selectKeyFileButton = PushButton({
text: "Select Key File",
cssClass: ["small"],
onClicked: (): void => {
this.#handleSelectKeyFile();
},
enabled: this.#config.authenicationMethod === AuthenticationMethod.KEY_FILE_ONLY,
}),
stretch: 0,
}
]
})
})
]
})
});
this.#updateKeyFileLabel();
}

#updateKeyFileLabel(): void {
if (this.#config.authenicationMethod === AuthenticationMethod.KEY_FILE_ONLY) {
this.#selectedKeyFileLabel.setText(this.#config.keyFilePath ?? "");
} else {
this.#selectedKeyFileLabel.setText("");
}
this.#selectKeyFileButton.setEnabled(this.#config.authenicationMethod === AuthenticationMethod.KEY_FILE_ONLY);
}

#handleSelectKeyFile(): void {
this.#fileDialog = new QFileDialog();
this.#fileDialog.setFileMode(FileMode.AnyFile);
this.#fileDialog.exec();

const selectedFiles = this.#fileDialog.selectedFiles();
this.#config.keyFilePath = selectedFiles.length === 0 ? null : selectedFiles[0];
this.#sessionEditorBase.setSessionConfiguration(this.#config);
this.#updateKeyFileLabel();
}

getWidget(): QWidget {
Expand Down
7 changes: 6 additions & 1 deletion packages/qt-construct/src/ComboBox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,14 @@ export interface ComboBoxOptions extends WidgetOptions {
items: (ComboBoxItem | string)[];
onActivated?: (index: number) => void;
onCurrentTextChanged?: (newText: string) => void;
onCurrentIndexChanged?: (index: number) => void;
}

export function ComboBox(options: ComboBoxOptions): QComboBox {
const comboBox = new QComboBox();
ApplyWidgetOptions(comboBox, options);
const { id, items, currentIndex, currentText, editable, onActivated, onCurrentTextChanged } = options;
const { id, items, currentIndex, currentText, editable, onActivated, onCurrentIndexChanged,
onCurrentTextChanged } = options;
if (id !== undefined) {
comboBox.setObjectName(id);
}
Expand Down Expand Up @@ -56,5 +58,8 @@ export function ComboBox(options: ComboBoxOptions): QComboBox {
if (onCurrentTextChanged !== undefined) {
comboBox.addEventListener("currentTextChanged", onCurrentTextChanged);
}
if (onCurrentIndexChanged !== undefined) {
comboBox.addEventListener("currentIndexChanged", onCurrentIndexChanged);
}
return comboBox;
}

0 comments on commit 8cdd7af

Please sign in to comment.