diff --git a/extensions/SSHSessionBackend/src/SSHPty.ts b/extensions/SSHSessionBackend/src/SSHPty.ts index 457087cb..63beaca1 100644 --- a/extensions/SSHSessionBackend/src/SSHPty.ts +++ b/extensions/SSHSessionBackend/src/SSHPty.ts @@ -24,6 +24,7 @@ export interface PtyOptions { host: string; port: number; username: string; + privateKeyFilenames?: string[]; } enum PtyState { @@ -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 = ""; @@ -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; @@ -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 { @@ -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; diff --git a/extensions/SSHSessionBackend/src/SSHSessionBackendExtension.ts b/extensions/SSHSessionBackend/src/SSHSessionBackendExtension.ts index 58ad8b74..7721f6b1 100644 --- a/extensions/SSHSessionBackend/src/SSHSessionBackendExtension.ts +++ b/extensions/SSHSessionBackend/src/SSHSessionBackendExtension.ts @@ -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 { @@ -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, @@ -49,6 +77,7 @@ class SSHBackend implements SessionBackend { host: sessionConfig.host, port: sessionConfig.port, username: username, + privateKeyFilenames, }; return new SSHPty(this._log, options); diff --git a/extensions/SSHSessionEditor/src/SSHSessionEditorExtension.ts b/extensions/SSHSessionEditor/src/SSHSessionEditorExtension.ts index f2005394..1dbaec67 100644 --- a/extensions/SSHSessionEditor/src/SSHSessionEditorExtension.ts +++ b/extensions/SSHSessionEditor/src/SSHSessionEditorExtension.ts @@ -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 { @@ -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 = sessionEditorBase.sessionConfiguration; this.#config.port = this.#config.port ?? 22; @@ -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 { diff --git a/packages/qt-construct/src/ComboBox.ts b/packages/qt-construct/src/ComboBox.ts index ca9770de..1c85796f 100644 --- a/packages/qt-construct/src/ComboBox.ts +++ b/packages/qt-construct/src/ComboBox.ts @@ -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); } @@ -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; }