import colors from 'ansi-colors' import stripAnsi from 'strip-ansi' import { open as openTemp } from 'temp' import { Injectable, NgZone } from '@angular/core' import { NgbModal } from '@ng-bootstrap/ng-bootstrap' import { Client } from 'ssh2' import { SSH2Stream } from 'ssh2-streams' import * as fs from 'mz/fs' import { execFile } from 'mz/child_process' import * as path from 'path' import * as sshpk from 'sshpk' import { ToastrService } from 'ngx-toastr' import { HostAppService, Platform, Logger, LogService, ElectronService, AppService, SelectorOption, ConfigService } from 'terminus-core' import { SettingsTabComponent } from 'terminus-settings' import { SSHConnection, SSHSession } from '../api' import { PromptModalComponent } from '../components/promptModal.component' import { PasswordStorageService } from './passwordStorage.service' import { SSHTabComponent } from '../components/sshTab.component' const WINDOWS_OPENSSH_AGENT_PIPE = '\\\\.\\pipe\\openssh-ssh-agent' try { var windowsProcessTreeNative = require('windows-process-tree/build/Release/windows_process_tree.node') // eslint-disable-line @typescript-eslint/no-var-requires, no-var } catch { } // eslint-disable-next-line @typescript-eslint/no-type-alias export type SSHLogCallback = (message: string) => void @Injectable({ providedIn: 'root' }) export class SSHService { private logger: Logger private constructor ( private log: LogService, private electron: ElectronService, private zone: NgZone, private ngbModal: NgbModal, private hostApp: HostAppService, private passwordStorage: PasswordStorageService, private toastr: ToastrService, private app: AppService, private config: ConfigService, ) { this.logger = log.create('ssh') } createSession (connection: SSHConnection): SSHSession { const session = new SSHSession(connection) session.logger = this.log.create(`ssh-${connection.host}-${connection.port}`) return session } async loadPrivateKeyForSession (session: SSHSession, logCallback?: SSHLogCallback): 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)) { logCallback?.('Using user\'s default private key') privateKeyPath = userKeyPath } } if (privateKeyPath) { logCallback?.('Loading private key from ' + colors.bgWhite.blackBright(' ' + privateKeyPath + ' ')) try { privateKey = (await fs.readFile(privateKeyPath)).toString() } catch (error) { logCallback?.(colors.bgRed.black(' X ') + 'Could not read the private key file') this.toastr.error('Could not read the private key file') } if (privateKey) { let parsedKey: any = null try { parsedKey = sshpk.parsePrivateKey(privateKey, 'auto') } catch (e) { if (e instanceof sshpk.KeyEncryptedError) { const modal = this.ngbModal.open(PromptModalComponent) logCallback?.(colors.bgYellow.yellow.black(' ! ') + ' Key requires passphrase') modal.componentInstance.prompt = 'Private key passphrase' modal.componentInstance.password = true let passphrase = '' try { const result = await modal.result passphrase = result?.value } catch { } parsedKey = sshpk.parsePrivateKey( privateKey, 'auto', { passphrase: passphrase } ) } else { throw e } } const sshFormatKey = parsedKey.toString('openssh') const temp = await openTemp() fs.close(temp.fd) await fs.writeFile(temp.path, sshFormatKey) let sshKeygenPath = 'ssh-keygen' if (this.hostApp.platform === Platform.Windows) { sshKeygenPath = path.join( path.dirname(this.electron.app.getPath('exe')), 'resources', 'extras', 'ssh-keygen', 'ssh-keygen.exe', ) await execFile('icacls', [temp.path, '/inheritance:r']) let sid = await execFile('whoami', ['/user', '/nh', '/fo', 'csv']) sid = sid[0].split(',')[0] sid = sid.substring(1, sid.length - 1) await execFile('icacls', [temp.path, '/grant:r', `${sid}:(R,W)`]) } await execFile(sshKeygenPath, [ '-p', '-P', '', '-N', '', '-m', 'PEM', '-f', temp.path, ]) privateKey = await fs.readFile(temp.path, { encoding: 'utf-8' }) fs.unlink(temp.path) } } return privateKey } async connectSession (session: SSHSession, logCallback?: SSHLogCallback): Promise { if (!logCallback) { logCallback = () => null } const log = (s: any) => { logCallback!(s) this.logger.info(stripAnsi(s)) } let privateKey: string|null = null const ssh = new Client() session.ssh = ssh let connected = false let savedPassword: string|null = null await new Promise(async (resolve, reject) => { ssh.on('ready', () => { connected = true if (savedPassword) { this.passwordStorage.savePassword(session.connection, savedPassword) } this.zone.run(resolve) }) ssh.on('error', error => { if (error.message === 'All configured authentication methods failed') { this.passwordStorage.deletePassword(session.connection) } this.zone.run(() => { if (connected) { // eslint-disable-next-line @typescript-eslint/no-base-to-string this.toastr.error(error.toString()) } else { reject(error) } }) }) ssh.on('close', () => { if (session.open) { session.destroy() } }) ssh.on('keyboard-interactive', (name, instructions, instructionsLang, prompts, finish) => this.zone.run(async () => { log(colors.bgBlackBright(' ') + ` Keyboard-interactive auth requested: ${name}`) this.logger.info('Keyboard-interactive auth:', name, instructions, instructionsLang) const results: string[] = [] for (const prompt of prompts) { const modal = this.ngbModal.open(PromptModalComponent) modal.componentInstance.prompt = prompt.prompt modal.componentInstance.password = !prompt.echo try { const result = await modal.result results.push(result ? result.value : '') } catch { results.push('') } } finish(results) })) ssh.on('greeting', greeting => { if (!session.connection.skipBanner) { log('Greeting: ' + greeting) } }) ssh.on('banner', banner => { if (!session.connection.skipBanner) { log(banner) } }) 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 { const pageantRunning = await new Promise(resolve => { windowsProcessTreeNative.getProcessList(list => { // eslint-disable-line block-scoped-var resolve(list.some(x => x.name === 'pageant.exe')) }, 0) }) if (pageantRunning) { agent = 'pageant' } } } else { agent = process.env.SSH_AUTH_SOCK! } const authMethodsLeft = ['none'] if (!session.connection.auth || session.connection.auth === 'publicKey') { privateKey = await this.loadPrivateKeyForSession(session, log) if (!privateKey) { log('\r\nPrivate key auth selected, but no key is loaded\r\n') } else { authMethodsLeft.push('publickey') } } if (!session.connection.auth || session.connection.auth === 'agent') { if (!agent) { log('\r\nAgent auth selected, but no running agent is detected\r\n') } else { authMethodsLeft.push('agent') } } if (!session.connection.auth || session.connection.auth === 'password') { authMethodsLeft.push('password') } if (!session.connection.auth || session.connection.auth === 'keyboardInteractive') { authMethodsLeft.push('keyboard-interactive') } authMethodsLeft.push('hostbased') try { ssh.connect({ host: session.connection.host, port: session.connection.port ?? 22, username: session.connection.user, password: session.connection.privateKey ? undefined : '', privateKey: privateKey ?? undefined, tryKeyboard: true, agent: agent ?? undefined, agentForward: session.connection.agentForward && !!agent, keepaliveInterval: session.connection.keepaliveInterval, keepaliveCountMax: session.connection.keepaliveCountMax, readyTimeout: session.connection.readyTimeout, hostVerifier: (digest: string) => { log(colors.bgWhite(' ') + ' Host key fingerprint:') log(colors.bgWhite(' ') + ' ' + colors.black.bgWhite(' SHA256 ') + colors.bgBlackBright(' ' + digest + ' ')) return true }, hostHash: 'sha256' as any, algorithms: session.connection.algorithms, sock: session.jumpStream, authHandler: methodsLeft => { while (true) { const method = authMethodsLeft.shift() if (!method) { return false } if (methodsLeft && !methodsLeft.includes(method) && method !== 'agent') { // Agent can still be used even if not in methodsLeft this.logger.info('Server does not support auth method', method) continue } return method } }, } as any) } catch (e) { this.toastr.error(e.message) return reject(e) } let keychainPasswordUsed = false ;(ssh as any).config.password = () => this.zone.run(async () => { if (session.connection.password) { log('Using preset password') return session.connection.password } if (!keychainPasswordUsed) { const password = await this.passwordStorage.loadPassword(session.connection) if (password) { log('Trying saved password') keychainPasswordUsed = true return password } } const modal = this.ngbModal.open(PromptModalComponent) modal.componentInstance.prompt = `Password for ${session.connection.user}@${session.connection.host}` modal.componentInstance.password = true modal.componentInstance.showRememberCheckbox = true try { const result = await modal.result if (result) { if (result.remember) { savedPassword = result.value } return result.value } return '' } catch (_) { return '' } }) }) } async showConnectionSelector (): Promise { const options: SelectorOption[] = [] const recentConnections = this.config.store.ssh.recentConnections for (const connection of recentConnections) { options.push({ name: connection.name, description: connection.host, icon: 'history', callback: () => this.connect(connection), }) } if (recentConnections.length) { options.push({ name: 'Clear recent connections', icon: 'eraser', callback: () => { this.config.store.ssh.recentConnections = [] this.config.save() }, }) } const groups: { name: string, connections: SSHConnection[] }[] = [] const connections = this.config.store.ssh.connections for (const connection of connections) { connection.group = connection.group || null let group = groups.find(x => x.name === connection.group) if (!group) { group = { name: connection.group!, connections: [], } groups.push(group) } group.connections.push(connection) } for (const group of groups) { for (const connection of group.connections) { options.push({ name: (group.name ? `${group.name} / ` : '') + connection.name, description: connection.host, icon: 'desktop', callback: () => this.connect(connection), }) } } options.push({ name: 'Manage connections', icon: 'cog', callback: () => this.app.openNewTab(SettingsTabComponent, { activeTab: 'ssh' }), }) options.push({ name: 'Quick connect', freeInputPattern: 'Connect to "%s"...', icon: 'arrow-right', callback: query => this.quickConnect(query), }) await this.app.showSelector('Open an SSH connection', options) } async connect (connection: SSHConnection): Promise { try { const tab = this.app.openNewTab( SSHTabComponent, { connection } ) as SSHTabComponent if (connection.color) { (this.app.getParentTab(tab) ?? tab).color = connection.color } setTimeout(() => this.app.activeTab?.emitFocused()) return tab } catch (error) { this.toastr.error(`Could not connect: ${error}`) throw error } } quickConnect (query: string): Promise { let user = 'root' let host = query let port = 22 if (host.includes('@')) { [user, host] = host.split('@') } if (host.includes(':')) { port = parseInt(host.split(':')[1]) host = host.split(':')[0] } const connection: SSHConnection = { name: query, group: null, host, user, port, } const recentConnections = this.config.store.ssh.recentConnections recentConnections.unshift(connection) if (recentConnections.length > 5) { recentConnections.pop() } this.config.store.ssh.recentConnections = recentConnections this.config.save() return this.connect(connection) } } /* eslint-disable */ const _authPassword = SSH2Stream.prototype.authPassword SSH2Stream.prototype.authPassword = async function (username, passwordFn: any) { _authPassword.bind(this)(username, await passwordFn()) } as any