From a9069a4a4998f80a90a020c697d3f4e6868ceabd Mon Sep 17 00:00:00 2001 From: Eugene Pankov Date: Sat, 5 Jun 2021 12:05:46 +0200 Subject: [PATCH] allow multiple private key paths - fixes #3921 --- terminus-core/src/configDefaults.yaml | 1 + terminus-core/src/services/config.service.ts | 18 +++- .../src/components/settingsTab.component.pug | 5 +- .../src/components/settingsTab.component.scss | 1 + .../src/components/settingsTab.component.ts | 1 + terminus-ssh/src/api.ts | 91 +++++++++++-------- .../editConnectionModal.component.pug | 72 +++++++-------- .../editConnectionModal.component.ts | 14 ++- terminus-ssh/src/services/ssh.service.ts | 2 - terminus-ssh/src/winSCPIntegration.ts | 12 +-- 10 files changed, 131 insertions(+), 86 deletions(-) diff --git a/terminus-core/src/configDefaults.yaml b/terminus-core/src/configDefaults.yaml index dd46a334..c4cc8d51 100644 --- a/terminus-core/src/configDefaults.yaml +++ b/terminus-core/src/configDefaults.yaml @@ -21,3 +21,4 @@ enableWelcomeTab: true electronFlags: - ['force_discrete_gpu', '0'] enableAutomaticUpdates: true +version: 1 diff --git a/terminus-core/src/services/config.service.ts b/terminus-core/src/services/config.service.ts index 8dc53452..c5654d9a 100644 --- a/terminus-core/src/services/config.service.ts +++ b/terminus-core/src/services/config.service.ts @@ -8,6 +8,8 @@ const deepmerge = require('deepmerge') const configMerge = (a, b) => deepmerge(a, b, { arrayMerge: (_d, s) => s }) // eslint-disable-line @typescript-eslint/no-var-requires +const LATEST_VERSION = 1 + function isStructuralMember (v) { return v instanceof Object && !(v instanceof Array) && Object.keys(v).length > 0 && !v.__nonStructural @@ -148,8 +150,9 @@ export class ConfigService { if (content) { this._store = yaml.load(content) } else { - this._store = {} + this._store = { version: LATEST_VERSION } } + this.migrate(this._store) this.store = new ConfigProxy(this._store, this.defaults) } @@ -225,4 +228,17 @@ export class ConfigService { private emitChange (): void { this.changed.next() } + + private migrate (config) { + config.version ??= 0 + if (config.version < 1) { + for (const connection of config.ssh?.connections) { + if (connection.privateKey) { + connection.privateKeys = [connection.privateKey] + delete connection.privateKey + } + } + config.version = 1 + } + } } diff --git a/terminus-settings/src/components/settingsTab.component.pug b/terminus-settings/src/components/settingsTab.component.pug index 4acfd705..48a9340a 100644 --- a/terminus-settings/src/components/settingsTab.component.pug +++ b/terminus-settings/src/components/settingsTab.component.pug @@ -88,7 +88,7 @@ button.btn.btn-outline-warning.btn-block(*ngIf='config.restartRequested', '(clic textarea.form-control.h-100( [(ngModel)]='configFile' ) - .w-100.d-flex.flex-column + .w-100.d-flex.flex-column(*ngIf='showConfigDefaults') h3 Defaults textarea.form-control.h-100( [(ngModel)]='configDefaults', @@ -102,6 +102,9 @@ button.btn.btn-outline-warning.btn-block(*ngIf='config.restartRequested', '(clic i.fas.fa-exclamation-triangle.mr-2 | Invalid syntax button.btn.btn-secondary.ml-auto( + (click)='showConfigDefaults = !showConfigDefaults' + ) Show defaults + button.btn.btn-secondary.ml-3( *ngIf='platform.getConfigPath()', (click)='showConfigFile()' ) diff --git a/terminus-settings/src/components/settingsTab.component.scss b/terminus-settings/src/components/settingsTab.component.scss index 3c2925b0..efecaac7 100644 --- a/terminus-settings/src/components/settingsTab.component.scss +++ b/terminus-settings/src/components/settingsTab.component.scss @@ -39,5 +39,6 @@ textarea { font-family: 'Source Code Pro', monospace; + font-size: 12px; min-height: 120px; } diff --git a/terminus-settings/src/components/settingsTab.component.ts b/terminus-settings/src/components/settingsTab.component.ts index 07371109..0ae9f17d 100644 --- a/terminus-settings/src/components/settingsTab.component.ts +++ b/terminus-settings/src/components/settingsTab.component.ts @@ -30,6 +30,7 @@ export class SettingsTabComponent extends BaseTabComponent { isShellIntegrationInstalled = false checkingForUpdate = false updateAvailable = false + showConfigDefaults = false @HostBinding('class.pad-window-controls') padWindowControls = false constructor ( diff --git a/terminus-ssh/src/api.ts b/terminus-ssh/src/api.ts index db48dd49..af9edf44 100644 --- a/terminus-ssh/src/api.ts +++ b/terminus-ssh/src/api.ts @@ -39,7 +39,7 @@ export interface SSHConnection { user: string auth?: null|'password'|'publicKey'|'agent'|'keyboardInteractive' password?: string - privateKey?: string + privateKeys?: string[] group: string | null scripts?: LoginScript[] keepaliveInterval?: number @@ -131,6 +131,11 @@ export class ForwardedPort implements ForwardedPortConfig { } } +interface AuthMethod { + type: 'none'|'publickey'|'agent'|'password'|'keyboard-interactive'|'hostbased' + path?: string +} + export class SSHSession extends BaseSession { scripts?: LoginScript[] shell?: ClientChannel @@ -143,9 +148,9 @@ export class SSHSession extends BaseSession { get serviceMessage$ (): Observable { return this.serviceMessage } agentPath?: string - privateKey?: string + activePrivateKey: string|null = null - private authMethodsLeft: string[] = [] + private remainingAuthMethods: AuthMethod[] = [] private serviceMessage = new Subject() private keychainPasswordUsed = false @@ -189,33 +194,31 @@ export class SSHSession extends BaseSession { this.agentPath = process.env.SSH_AUTH_SOCK! } - this.authMethodsLeft = ['none'] + this.remainingAuthMethods = [{ type: 'none' }] if (!this.connection.auth || this.connection.auth === 'publicKey') { - try { - await this.loadPrivateKey() - } catch (e) { - this.emitServiceMessage(colors.bgYellow.yellow.black(' ! ') + ` Failed to load private key: ${e}`) - } - if (!this.privateKey) { - this.emitServiceMessage(colors.bgYellow.yellow.black(' ! ') + ` Private key auth selected, but no key is loaded`) - } else { - this.authMethodsLeft.push('publickey') + for (const pk of this.connection.privateKeys ?? []) { + if (await fs.exists(pk)) { + this.remainingAuthMethods.push({ + type: 'publickey', + path: pk, + }) + } } } if (!this.connection.auth || this.connection.auth === 'agent') { if (!this.agentPath) { this.emitServiceMessage(colors.bgYellow.yellow.black(' ! ') + ` Agent auth selected, but no running agent is detected`) } else { - this.authMethodsLeft.push('agent') + this.remainingAuthMethods.push({ type: 'agent' }) } } if (!this.connection.auth || this.connection.auth === 'password') { - this.authMethodsLeft.push('password') + this.remainingAuthMethods.push({ type: 'password' }) } if (!this.connection.auth || this.connection.auth === 'keyboardInteractive') { - this.authMethodsLeft.push('keyboard-interactive') + this.remainingAuthMethods.push({ type: 'keyboard-interactive' }) } - this.authMethodsLeft.push('hostbased') + this.remainingAuthMethods.push({ type: 'hostbased' }) } async start (): Promise { @@ -370,17 +373,19 @@ export class SSHSession extends BaseSession { } async handleAuth (methodsLeft?: string[]): Promise { + this.activePrivateKey = null + while (true) { - const method = this.authMethodsLeft.shift() + const method = this.remainingAuthMethods.shift() if (!method) { return false } - if (methodsLeft && !methodsLeft.includes(method) && method !== 'agent') { + if (methodsLeft && !methodsLeft.includes(method.type) && method.type !== 'agent') { // Agent can still be used even if not in methodsLeft - this.logger.info('Server does not support auth method', method) + this.logger.info('Server does not support auth method', method.type) continue } - if (method === 'password') { + if (method.type === 'password') { if (this.connection.password) { this.emitServiceMessage('Using preset password') return { @@ -426,6 +431,19 @@ export class SSHSession extends BaseSession { continue } } + if (method.type === 'publickey') { + try { + const key = await this.loadPrivateKey(method.path!) + return { + type: 'publickey', + username: this.connection.user, + key, + } + } catch (e) { + this.emitServiceMessage(colors.bgYellow.yellow.black(' ! ') + ` Failed to load private key ${method.path}: ${e}`) + continue + } + } return method } } @@ -560,9 +578,7 @@ export class SSHSession extends BaseSession { } } - async loadPrivateKey (): Promise { - let privateKeyPath = this.connection.privateKey - + async loadPrivateKey (privateKeyPath: string): Promise { if (!privateKeyPath) { const userKeyPath = path.join(process.env.HOME!, '.ssh', 'id_rsa') if (await fs.exists(userKeyPath)) { @@ -571,18 +587,21 @@ export class SSHSession extends BaseSession { } } - if (privateKeyPath) { - this.emitServiceMessage('Loading private key from ' + colors.bgWhite.blackBright(' ' + privateKeyPath + ' ')) - try { - const privateKeyContents = (await fs.readFile(privateKeyPath)).toString() - const parsedKey = await this.parsePrivateKey(privateKeyContents) - this.privateKey = parsedKey.toString('openssh') - } catch (error) { - this.emitServiceMessage(colors.bgRed.black(' X ') + ' Could not read the private key file') - this.emitServiceMessage(colors.bgRed.black(' X ') + ` ${error}`) - this.notifications.error('Could not read the private key file') - return - } + if (!privateKeyPath) { + return null + } + + this.emitServiceMessage('Loading private key from ' + colors.bgWhite.blackBright(' ' + privateKeyPath + ' ')) + try { + const privateKeyContents = (await fs.readFile(privateKeyPath)).toString() + const parsedKey = await this.parsePrivateKey(privateKeyContents) + this.activePrivateKey = parsedKey.toString('openssh') + return this.activePrivateKey + } catch (error) { + this.emitServiceMessage(colors.bgRed.black(' X ') + ' Could not read the private key file') + this.emitServiceMessage(colors.bgRed.black(' X ') + ` ${error}`) + this.notifications.error('Could not read the private key file') + return null } } diff --git a/terminus-ssh/src/components/editConnectionModal.component.pug b/terminus-ssh/src/components/editConnectionModal.component.pug index 3e2da076..82ad14b7 100644 --- a/terminus-ssh/src/components/editConnectionModal.component.pug +++ b/terminus-ssh/src/components/editConnectionModal.component.pug @@ -49,30 +49,30 @@ .form-group label Authentication method - .btn-group.w-100( - [(ngModel)]='connection.auth', - ngbRadioGroup - ) - label.btn.btn-secondary(ngbButtonLabel) - input(type='radio', ngbButton, [value]='null') - i.far.fa-lightbulb - .m-0 Auto - label.btn.btn-secondary(ngbButtonLabel) - input(type='radio', ngbButton, [value]='"password"') - i.fas.fa-font - .m-0 Password - label.btn.btn-secondary(ngbButtonLabel) - input(type='radio', ngbButton, [value]='"publicKey"') - i.fas.fa-key - .m-0 Key - label.btn.btn-secondary(ngbButtonLabel) - input(type='radio', ngbButton, [value]='"agent"') - i.fas.fa-user-secret - .m-0 Agent - label.btn.btn-secondary(ngbButtonLabel) - input(type='radio', ngbButton, [value]='"keyboardInteractive"') - i.far.fa-keyboard - .m-0 Interactive + .btn-group.mt-1.w-100( + [(ngModel)]='connection.auth', + ngbRadioGroup + ) + label.btn.btn-secondary(ngbButtonLabel) + input(type='radio', ngbButton, [value]='null') + i.far.fa-lightbulb + .m-0 Auto + label.btn.btn-secondary(ngbButtonLabel) + input(type='radio', ngbButton, [value]='"password"') + i.fas.fa-font + .m-0 Password + label.btn.btn-secondary(ngbButtonLabel) + input(type='radio', ngbButton, [value]='"publicKey"') + i.fas.fa-key + .m-0 Key + label.btn.btn-secondary(ngbButtonLabel) + input(type='radio', ngbButton, [value]='"agent"') + i.fas.fa-user-secret + .m-0 Agent + label.btn.btn-secondary(ngbButtonLabel) + input(type='radio', ngbButton, [value]='"keyboardInteractive"') + i.far.fa-keyboard + .m-0 Interactive .form-line(*ngIf='!connection.auth || connection.auth === "password"') .header @@ -86,19 +86,17 @@ i.fas.fa-trash-alt span Forget - .form-line(*ngIf='!connection.auth || connection.auth === "publicKey"') - .header - .title Private key - .description Path to the private key file - .input-group - input.form-control( - type='text', - placeholder='Key file path', - [(ngModel)]='connection.privateKey' - ) - .input-group-append - button.btn.btn-secondary((click)='selectPrivateKey()') - i.fas.fa-folder-open + .form-group(*ngIf='!connection.auth || connection.auth === "publicKey"') + label Private keys + .list-group.mb-2 + .list-group-item.d-flex.align-items-center.p-1.pl-3(*ngFor='let path of connection.privateKeys') + i.fas.fa-key + .mr-auto {{path}} + button.btn.btn-link((click)='removePrivateKey(path)') + i.fas.fa-trash + button.btn.btn-secondary((click)='addPrivateKey()') + i.fas.fa-folder-open + span Add a private key li(ngbNavItem) a(ngbNavLink) Ports diff --git a/terminus-ssh/src/components/editConnectionModal.component.ts b/terminus-ssh/src/components/editConnectionModal.component.ts index d2c27bae..13783415 100644 --- a/terminus-ssh/src/components/editConnectionModal.component.ts +++ b/terminus-ssh/src/components/editConnectionModal.component.ts @@ -67,6 +67,7 @@ export class EditConnectionModalComponent { this.connection.algorithms = this.connection.algorithms ?? {} this.connection.scripts = this.connection.scripts ?? [] this.connection.auth = this.connection.auth ?? null + this.connection.privateKeys ??= [] this.useProxyCommand = !!this.connection.proxyCommand @@ -101,20 +102,27 @@ export class EditConnectionModalComponent { this.passwordStorage.deletePassword(this.connection) } - selectPrivateKey () { + addPrivateKey () { this.electron.dialog.showOpenDialog( this.hostApp.getWindow(), { - defaultPath: this.connection.privateKey, + defaultPath: this.connection.privateKeys![0], title: 'Select private key', } ).then(result => { if (!result.canceled) { - this.connection.privateKey = result.filePaths[0] + this.connection.privateKeys = [ + ...this.connection.privateKeys!, + ...result.filePaths, + ] } }) } + removePrivateKey (path: string) { + this.connection.privateKeys = this.connection.privateKeys?.filter(x => x !== path) + } + save () { for (const k of Object.values(SSHAlgorithmType)) { this.connection.algorithms![k] = Object.entries(this.algorithms[k]) diff --git a/terminus-ssh/src/services/ssh.service.ts b/terminus-ssh/src/services/ssh.service.ts index fa7f734e..76849c39 100644 --- a/terminus-ssh/src/services/ssh.service.ts +++ b/terminus-ssh/src/services/ssh.service.ts @@ -133,8 +133,6 @@ export class SSHService { port: session.connection.port ?? 22, sock: session.proxyCommandStream ?? session.jumpStream, username: session.connection.user, - password: session.connection.privateKey ? undefined : '', - privateKey: session.privateKey ?? undefined, tryKeyboard: true, agent: session.agentPath, agentForward: session.connection.agentForward && !!session.agentPath, diff --git a/terminus-ssh/src/winSCPIntegration.ts b/terminus-ssh/src/winSCPIntegration.ts index 2f57f27f..1fe86b9d 100644 --- a/terminus-ssh/src/winSCPIntegration.ts +++ b/terminus-ssh/src/winSCPIntegration.ts @@ -2,7 +2,7 @@ import { Injectable } from '@angular/core' import { ConfigService, BaseTabComponent, TabContextMenuItemProvider, TabHeaderComponent, HostAppService, Platform, PlatformService, MenuItemOptions } from 'terminus-core' import { SSHTabComponent } from './components/sshTab.component' import { PasswordStorageService } from './services/passwordStorage.service' -import { SSHConnection } from './api' +import { SSHConnection, SSHSession } from './api' /** @hidden */ @@ -40,7 +40,7 @@ export class WinSCPContextMenu extends TabContextMenuItemProvider { { label: 'Launch WinSCP', click: (): void => { - this.launchWinSCP(tab.connection!) + this.launchWinSCP(tab.session!) }, }, ] @@ -60,15 +60,15 @@ export class WinSCPContextMenu extends TabContextMenuItemProvider { return uri } - async launchWinSCP (connection: SSHConnection): Promise { + async launchWinSCP (session: SSHSession): Promise { const path = this.getPath() if (!path) { return } - const args = [await this.getURI(connection)] - if ((!connection.auth || connection.auth === 'publicKey') && connection.privateKey) { + const args = [await this.getURI(session.connection)] + if (session.activePrivateKey) { args.push('/privatekey') - args.push(connection.privateKey) + args.push(session.activePrivateKey) } this.platform.exec(path, args) }