diff --git a/terminus-ssh/src/api.ts b/terminus-ssh/src/api.ts index 194ca9f8..db48dd49 100644 --- a/terminus-ssh/src/api.ts +++ b/terminus-ssh/src/api.ts @@ -1,17 +1,23 @@ +import * as fs from 'mz/fs' +import * as crypto from 'crypto' +import * as path from 'path' +import * as sshpk from 'sshpk' import colors from 'ansi-colors' import stripAnsi from 'strip-ansi' import socksv5 from 'socksv5' import { Injector } from '@angular/core' import { NgbModal } from '@ng-bootstrap/ng-bootstrap' +import { HostAppService, Logger, NotificationsService, Platform, PlatformService } from 'terminus-core' import { BaseSession } from 'terminus-terminal' import { Server, Socket, createServer, createConnection } from 'net' import { Client, ClientChannel } from 'ssh2' -import { Logger } from 'terminus-core' import { Subject, Observable } from 'rxjs' import { ProxyCommandStream } from './services/ssh.service' import { PasswordStorageService } from './services/passwordStorage.service' import { PromptModalComponent } from './components/promptModal.component' +const WINDOWS_OPENSSH_AGENT_PIPE = '\\\\.\\pipe\\openssh-ssh-agent' + export interface LoginScript { expect: string send: string @@ -133,13 +139,21 @@ export class SSHSession extends BaseSession { logger: Logger jumpStream: any proxyCommandStream: ProxyCommandStream|null = null - authMethodsLeft: string[] = [] - savedPassword: string|undefined + savedPassword?: string get serviceMessage$ (): Observable { return this.serviceMessage } + + agentPath?: string + privateKey?: string + + private authMethodsLeft: string[] = [] private serviceMessage = new Subject() private keychainPasswordUsed = false + private passwordStorage: PasswordStorageService private ngbModal: NgbModal + private hostApp: HostAppService + private platform: PlatformService + private notifications: NotificationsService constructor ( injector: Injector, @@ -148,6 +162,9 @@ export class SSHSession extends BaseSession { super() this.passwordStorage = injector.get(PasswordStorageService) this.ngbModal = injector.get(NgbModal) + this.hostApp = injector.get(HostAppService) + this.platform = injector.get(PlatformService) + this.notifications = injector.get(NotificationsService) this.scripts = connection.scripts ?? [] this.destroyed$.subscribe(() => { @@ -159,6 +176,48 @@ export class SSHSession extends BaseSession { }) } + async init (): Promise { + if (this.hostApp.platform === Platform.Windows) { + if (await fs.exists(WINDOWS_OPENSSH_AGENT_PIPE)) { + this.agentPath = WINDOWS_OPENSSH_AGENT_PIPE + } else { + if (await this.platform.isProcessRunning('pageant.exe')) { + this.agentPath = 'pageant' + } + } + } else { + this.agentPath = process.env.SSH_AUTH_SOCK! + } + + this.authMethodsLeft = ['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') + } + } + 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') + } + } + if (!this.connection.auth || this.connection.auth === 'password') { + this.authMethodsLeft.push('password') + } + if (!this.connection.auth || this.connection.auth === 'keyboardInteractive') { + this.authMethodsLeft.push('keyboard-interactive') + } + this.authMethodsLeft.push('hostbased') + } + async start (): Promise { this.open = true @@ -500,6 +559,64 @@ export class SSHSession extends BaseSession { } } } + + async loadPrivateKey (): Promise { + let privateKeyPath = this.connection.privateKey + + if (!privateKeyPath) { + const userKeyPath = path.join(process.env.HOME!, '.ssh', 'id_rsa') + if (await fs.exists(userKeyPath)) { + this.emitServiceMessage('Using user\'s default private key') + privateKeyPath = userKeyPath + } + } + + 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 + } + } + } + + async parsePrivateKey (privateKey: string): Promise { + const keyHash = crypto.createHash('sha512').update(privateKey).digest('hex') + let passphrase: string|null = await this.passwordStorage.loadPrivateKeyPassword(keyHash) + while (true) { + try { + return sshpk.parsePrivateKey(privateKey, 'auto', { passphrase }) + } catch (e) { + if (e instanceof sshpk.KeyEncryptedError || e instanceof sshpk.KeyParseError) { + await this.passwordStorage.deletePrivateKeyPassword(keyHash) + + const modal = this.ngbModal.open(PromptModalComponent) + modal.componentInstance.prompt = 'Private key passphrase' + modal.componentInstance.password = true + modal.componentInstance.showRememberCheckbox = true + + try { + const result = await modal.result + passphrase = result?.value + if (passphrase && result.remember) { + this.passwordStorage.savePrivateKeyPassword(keyHash, passphrase) + } + } catch { + throw e + } + } else { + this.notifications.error('Could not read the private key', e.toString()) + throw e + } + } + } + } } export const ALGORITHM_BLACKLIST = [ diff --git a/terminus-ssh/src/services/ssh.service.ts b/terminus-ssh/src/services/ssh.service.ts index 8c1ba72e..fa7f734e 100644 --- a/terminus-ssh/src/services/ssh.service.ts +++ b/terminus-ssh/src/services/ssh.service.ts @@ -1,15 +1,11 @@ import colors from 'ansi-colors' import { Duplex } from 'stream' -import * as crypto from 'crypto' import { Injectable, Injector, NgZone } from '@angular/core' import { NgbModal } from '@ng-bootstrap/ng-bootstrap' import { Client } from 'ssh2' -import * as fs from 'mz/fs' import { exec } from 'child_process' -import * as path from 'path' -import * as sshpk from 'sshpk' import { Subject, Observable } from 'rxjs' -import { HostAppService, Platform, Logger, LogService, AppService, SelectorOption, ConfigService, NotificationsService, PlatformService } from 'terminus-core' +import { Logger, LogService, AppService, SelectorOption, ConfigService, NotificationsService } from 'terminus-core' import { SettingsTabComponent } from 'terminus-settings' import { ALGORITHM_BLACKLIST, ForwardedPort, SSHConnection, SSHSession } from '../api' import { PromptModalComponent } from '../components/promptModal.component' @@ -17,8 +13,6 @@ import { PasswordStorageService } from './passwordStorage.service' import { SSHTabComponent } from '../components/sshTab.component' import { ChildProcess } from 'node:child_process' -const WINDOWS_OPENSSH_AGENT_PIPE = '\\\\.\\pipe\\openssh-ssh-agent' - @Injectable({ providedIn: 'root' }) export class SSHService { private logger: Logger @@ -28,12 +22,10 @@ export class SSHService { private log: LogService, private zone: NgZone, private ngbModal: NgbModal, - private hostApp: HostAppService, private passwordStorage: PasswordStorageService, private notifications: NotificationsService, private app: AppService, private config: ConfigService, - private platform: PlatformService, ) { this.logger = log.create('ssh') } @@ -44,75 +36,13 @@ export class SSHService { return session } - async loadPrivateKeyForSession (session: SSHSession): Promise { - let privateKey: string|null = null - let privateKeyPath = session.connection.privateKey - - if (!privateKeyPath) { - const userKeyPath = path.join(process.env.HOME!, '.ssh', 'id_rsa') - if (await fs.exists(userKeyPath)) { - session.emitServiceMessage('Using user\'s default private key') - privateKeyPath = userKeyPath - } - } - - if (privateKeyPath) { - session.emitServiceMessage('Loading private key from ' + colors.bgWhite.blackBright(' ' + privateKeyPath + ' ')) - try { - privateKey = (await fs.readFile(privateKeyPath)).toString() - } catch (error) { - session.emitServiceMessage(colors.bgRed.black(' X ') + ' Could not read the private key file') - session.emitServiceMessage(colors.bgRed.black(' X ') + ` ${error}`) - this.notifications.error('Could not read the private key file') - } - - if (privateKey) { - const parsedKey = await this.parsePrivateKey(privateKey) - privateKey = parsedKey.toString('openssh') - } - } - return privateKey - } - - async parsePrivateKey (privateKey: string): Promise { - const keyHash = crypto.createHash('sha512').update(privateKey).digest('hex') - let passphrase: string|null = await this.passwordStorage.loadPrivateKeyPassword(keyHash) - while (true) { - try { - return sshpk.parsePrivateKey(privateKey, 'auto', { passphrase }) - } catch (e) { - if (e instanceof sshpk.KeyEncryptedError || e instanceof sshpk.KeyParseError) { - await this.passwordStorage.deletePrivateKeyPassword(keyHash) - - const modal = this.ngbModal.open(PromptModalComponent) - modal.componentInstance.prompt = 'Private key passphrase' - modal.componentInstance.password = true - modal.componentInstance.showRememberCheckbox = true - - try { - const result = await modal.result - passphrase = result?.value - if (passphrase && result.remember) { - this.passwordStorage.savePrivateKeyPassword(keyHash, passphrase) - } - } catch { - throw e - } - } else { - this.notifications.error('Could not read the private key', e.toString()) - throw e - } - } - } - } - async connectSession (session: SSHSession): Promise { const log = (s: any) => session.emitServiceMessage(s) - let privateKey: string|null = null - const ssh = new Client() session.ssh = ssh + await session.init() + let connected = false const algorithms = {} for (const key of Object.keys(session.connection.algorithms ?? {})) { @@ -186,47 +116,6 @@ export class SSHService { }) }) - let agent: string|null = null - if (this.hostApp.platform === Platform.Windows) { - if (await fs.exists(WINDOWS_OPENSSH_AGENT_PIPE)) { - agent = WINDOWS_OPENSSH_AGENT_PIPE - } else { - if (await this.platform.isProcessRunning('pageant.exe')) { - agent = 'pageant' - } - } - } else { - agent = process.env.SSH_AUTH_SOCK! - } - - session.authMethodsLeft = ['none'] - if (!session.connection.auth || session.connection.auth === 'publicKey') { - try { - privateKey = await this.loadPrivateKeyForSession(session) - } catch (e) { - session.emitServiceMessage(colors.bgYellow.yellow.black(' ! ') + ` Failed to load private key: ${e}`) - } - if (!privateKey) { - session.emitServiceMessage(colors.bgYellow.yellow.black(' ! ') + ` Private key auth selected, but no key is loaded`) - } else { - session.authMethodsLeft.push('publickey') - } - } - if (!session.connection.auth || session.connection.auth === 'agent') { - if (!agent) { - session.emitServiceMessage(colors.bgYellow.yellow.black(' ! ') + ` Agent auth selected, but no running agent is detected`) - } else { - session.authMethodsLeft.push('agent') - } - } - if (!session.connection.auth || session.connection.auth === 'password') { - session.authMethodsLeft.push('password') - } - if (!session.connection.auth || session.connection.auth === 'keyboardInteractive') { - session.authMethodsLeft.push('keyboard-interactive') - } - session.authMethodsLeft.push('hostbased') - try { if (session.connection.proxyCommand) { session.emitServiceMessage(colors.bgBlue.black(' Proxy command ') + ` Using ${session.connection.proxyCommand}`) @@ -245,10 +134,10 @@ export class SSHService { sock: session.proxyCommandStream ?? session.jumpStream, username: session.connection.user, password: session.connection.privateKey ? undefined : '', - privateKey: privateKey ?? undefined, + privateKey: session.privateKey ?? undefined, tryKeyboard: true, - agent: agent ?? undefined, - agentForward: session.connection.agentForward && !!agent, + agent: session.agentPath, + agentForward: session.connection.agentForward && !!session.agentPath, keepaliveInterval: session.connection.keepaliveInterval ?? 15000, keepaliveCountMax: session.connection.keepaliveCountMax, readyTimeout: session.connection.readyTimeout,