diff --git a/package.json b/package.json index 3f2f049b..1cbb181d 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,6 @@ "source-code-pro": "^2.38.0", "source-map-loader": "^4.0.1", "source-sans-pro": "3.6.0", - "ssh2": "^1.14.0", "style-loader": "^3.3.1", "svg-inline-loader": "^0.8.2", "thenby": "^1.3.4", diff --git a/patches/ssh2+1.11.0.patch b/patches/ssh2+1.11.0.patch deleted file mode 100644 index eb049c0b..00000000 --- a/patches/ssh2+1.11.0.patch +++ /dev/null @@ -1,39 +0,0 @@ -diff --git a/node_modules/ssh2/lib/protocol/keyParser.js b/node_modules/ssh2/lib/protocol/keyParser.js -index 9860e3f..ee82e51 100644 ---- a/node_modules/ssh2/lib/protocol/keyParser.js -+++ b/node_modules/ssh2/lib/protocol/keyParser.js -@@ -15,6 +15,7 @@ const { - sign: sign_, - verify: verify_, - } = require('crypto'); -+const { createVerify: createVerifyDSS } = require('browserify-sign') - const supportedOpenSSLCiphers = getCiphers(); - - const { Ber } = require('asn1'); -@@ -404,6 +405,17 @@ const BaseKey = { - return new Error('No public key available'); - if (!algo || typeof algo !== 'string') - algo = this[SYM_HASH_ALGO]; -+ -+ if (algo === 'dss1') { -+ const verifier = createVerifyDSS('DSA-SHA1'); -+ verifier.update(data); -+ try { -+ return verifier.verify(pem, signature); -+ } catch (ex) { -+ return ex; -+ } -+ } -+ - try { - return verify_(algo, data, pem, signature); - } catch (ex) { -@@ -1343,7 +1355,7 @@ function parseDER(data, baseType, comment, fullType) { - return new Error('Malformed OpenSSH public key'); - pubPEM = genOpenSSLDSAPub(p, q, g, y); - pubSSH = genOpenSSHDSAPub(p, q, g, y); -- algo = 'sha1'; -+ algo = 'dss1'; - break; - } - case 'ssh-ed25519': { diff --git a/tabby-ssh/package.json b/tabby-ssh/package.json index e825b476..eb1f7108 100644 --- a/tabby-ssh/package.json +++ b/tabby-ssh/package.json @@ -23,7 +23,6 @@ "license": "MIT", "devDependencies": { "@types/node": "20.3.1", - "@types/ssh2": "^0.5.46", "ansi-colors": "^4.1.1", "diffie-hellman": "^5.0.3", "sshpk": "Eugeny/node-sshpk#c2b71d1243714d2daf0988f84c3323d180817136", diff --git a/tabby-ssh/src/algorithms.ts b/tabby-ssh/src/algorithms.ts index f7fa2a12..78c25261 100644 --- a/tabby-ssh/src/algorithms.ts +++ b/tabby-ssh/src/algorithms.ts @@ -1,20 +1,44 @@ -import * as ALGORITHMS from 'ssh2/lib/protocol/constants' -import { ALGORITHM_BLACKLIST, SSHAlgorithmType } from './api' +import * as russh from 'russh' +import { SSHAlgorithmType } from './api' -// Counteracts https://github.com/mscdex/ssh2/commit/f1b5ac3c81734c194740016eab79a699efae83d8 -ALGORITHMS.DEFAULT_CIPHER.push('aes128-gcm') -ALGORITHMS.DEFAULT_CIPHER.push('aes256-gcm') -ALGORITHMS.SUPPORTED_CIPHER.push('aes128-gcm') -ALGORITHMS.SUPPORTED_CIPHER.push('aes256-gcm') - -export const supportedAlgorithms: Record = {} - -for (const k of Object.values(SSHAlgorithmType)) { - const supportedAlg = { - [SSHAlgorithmType.KEX]: 'SUPPORTED_KEX', - [SSHAlgorithmType.HOSTKEY]: 'SUPPORTED_SERVER_HOST_KEY', - [SSHAlgorithmType.CIPHER]: 'SUPPORTED_CIPHER', - [SSHAlgorithmType.HMAC]: 'SUPPORTED_MAC', - }[k] - supportedAlgorithms[k] = ALGORITHMS[supportedAlg].filter(x => !ALGORITHM_BLACKLIST.includes(x)).sort() +export const supportedAlgorithms = { + [SSHAlgorithmType.KEX]: russh.getSupportedKexAlgorithms().filter(x => x !== 'none'), + [SSHAlgorithmType.HOSTKEY]: russh.getSupportedKeyTypes().filter(x => x !== 'none'), + [SSHAlgorithmType.CIPHER]: russh.getSupportedCiphers().filter(x => x !== 'clear'), + [SSHAlgorithmType.HMAC]: russh.getSupportedMACs().filter(x => x !== 'none'), +} + +export const defaultAlgorithms = { + [SSHAlgorithmType.KEX]: [ + 'curve25519-sha256', + 'curve25519-sha256@libssh.org', + 'diffie-hellman-group16-sha512', + 'diffie-hellman-group14-sha256', + 'ext-info-c', + 'ext-info-s', + 'kex-strict-c-v00@openssh.com', + 'kex-strict-s-v00@openssh.com', + ], + [SSHAlgorithmType.HOSTKEY]: [ + 'ssh-ed25519', + 'ecdsa-sha2-nistp256', + 'ecdsa-sha2-nistp521', + 'rsa-sha2-256', + 'rsa-sha2-512', + ], + [SSHAlgorithmType.CIPHER]: [ + 'chacha20-poly1305@openssh.com', + 'aes256-gcm@openssh.com', + 'aes256-ctr', + 'aes192-ctr', + 'aes128-ctr', + ], + [SSHAlgorithmType.HMAC]: [ + 'hmac-sha2-512-etm@openssh.com', + 'hmac-sha2-256-etm@openssh.com', + 'hmac-sha2-512', + 'hmac-sha2-256', + 'hmac-sha1-etm@openssh.com', + 'hmac-sha1', + ], } diff --git a/tabby-ssh/src/api/interfaces.ts b/tabby-ssh/src/api/interfaces.ts index 07a77a5c..56cbaf56 100644 --- a/tabby-ssh/src/api/interfaces.ts +++ b/tabby-ssh/src/api/interfaces.ts @@ -51,13 +51,3 @@ export interface ForwardedPortConfig { targetPort: number description: string } - -export let ALGORITHM_BLACKLIST = [ - // cause native crashes in node crypto, use EC instead - 'diffie-hellman-group-exchange-sha256', - 'diffie-hellman-group-exchange-sha1', -] - -if (!process.env.TABBY_ENABLE_SSH_ALG_BLACKLIST) { - ALGORITHM_BLACKLIST = [] -} diff --git a/tabby-ssh/src/components/sshTab.component.ts b/tabby-ssh/src/components/sshTab.component.ts index 738862dd..facd1ea5 100644 --- a/tabby-ssh/src/components/sshTab.component.ts +++ b/tabby-ssh/src/components/sshTab.component.ts @@ -94,17 +94,17 @@ export class SSHTabComponent extends ConnectableTerminalTabComponent } }) - session.jumpStream = await new Promise((resolve, reject) => jumpSession.ssh.forwardOut( - '127.0.0.1', 0, profile.options.host, profile.options.port ?? 22, - (err, stream) => { - if (err) { - jumpSession.emitServiceMessage(colors.bgRed.black(' X ') + ` Could not set up port forward on ${jumpConnection.name}`) - reject(err) - return - } - resolve(stream) - }, - )) + // session.jumpStream = await new Promise((resolve, reject) => jumpSession.ssh.forwardOut( + // '127.0.0.1', 0, profile.options.host, profile.options.port ?? 22, + // (err, stream) => { + // if (err) { + // jumpSession.emitServiceMessage(colors.bgRed.black(' X ') + ` Could not set up port forward on ${jumpConnection.name}`) + // reject(err) + // return + // } + // resolve(stream) + // }, + // )) } } diff --git a/tabby-ssh/src/profiles.ts b/tabby-ssh/src/profiles.ts index 36de9ce2..cf75105b 100644 --- a/tabby-ssh/src/profiles.ts +++ b/tabby-ssh/src/profiles.ts @@ -1,11 +1,11 @@ import { Injectable, InjectFlags, Injector } from '@angular/core' import { NewTabParameters, PartialProfile, TranslateService, QuickConnectProfileProvider } from 'tabby-core' -import * as ALGORITHMS from 'ssh2/lib/protocol/constants' import { SSHProfileSettingsComponent } from './components/sshProfileSettings.component' import { SSHTabComponent } from './components/sshTab.component' import { PasswordStorageService } from './services/passwordStorage.service' -import { ALGORITHM_BLACKLIST, SSHAlgorithmType, SSHProfile } from './api' +import { SSHAlgorithmType, SSHProfile } from './api' import { SSHProfileImporter } from './api/importer' +import { defaultAlgorithms } from './algorithms' @Injectable({ providedIn: 'root' }) export class SSHProfilesService extends QuickConnectProfileProvider { @@ -29,10 +29,10 @@ export class SSHProfilesService extends QuickConnectProfileProvider agentForward: false, warnOnClose: null, algorithms: { - hmac: [], - kex: [], - cipher: [], - serverHostKey: [], + hmac: [] as string[], + kex: [] as string[], + cipher: [] as string[], + serverHostKey: [] as string[], }, proxyCommand: null, forwardedPorts: [], @@ -54,13 +54,7 @@ export class SSHProfilesService extends QuickConnectProfileProvider ) { super() for (const k of Object.values(SSHAlgorithmType)) { - const defaultAlg = { - [SSHAlgorithmType.KEX]: 'DEFAULT_KEX', - [SSHAlgorithmType.HOSTKEY]: 'DEFAULT_SERVER_HOST_KEY', - [SSHAlgorithmType.CIPHER]: 'DEFAULT_CIPHER', - [SSHAlgorithmType.HMAC]: 'DEFAULT_MAC', - }[k] - this.configDefaults.options.algorithms[k] = ALGORITHMS[defaultAlg].filter(x => !ALGORITHM_BLACKLIST.includes(x)) + this.configDefaults.options.algorithms[k] = [...defaultAlgorithms[k]] this.configDefaults.options.algorithms[k].sort() } } diff --git a/tabby-ssh/src/services/ssh.service.ts b/tabby-ssh/src/services/ssh.service.ts index f8a3b0fc..3407b2de 100644 --- a/tabby-ssh/src/services/ssh.service.ts +++ b/tabby-ssh/src/services/ssh.service.ts @@ -1,6 +1,6 @@ import * as shellQuote from 'shell-quote' import * as net from 'net' -import * as fs from 'fs/promises' +// import * as fs from 'fs/promises' import * as tmp from 'tmp-promise' import socksv5 from '@luminati-io/socksv5' import { Duplex } from 'stream' @@ -55,7 +55,7 @@ export class SSHService { let tmpFile: tmp.FileResult|null = null if (session.activePrivateKey) { tmpFile = await tmp.file() - await fs.writeFile(tmpFile.path, session.activePrivateKey) + // await fs.writeFile(tmpFile.path, session.activePrivateKey) const winSCPcom = path.slice(0, -3) + 'com' await this.platform.exec(winSCPcom, ['/keygen', tmpFile.path, `/output=${tmpFile.path}`]) args.push(`/privatekey=${tmpFile.path}`) diff --git a/tabby-ssh/src/session/shell.ts b/tabby-ssh/src/session/shell.ts index 5b942cad..dd93d1df 100644 --- a/tabby-ssh/src/session/shell.ts +++ b/tabby-ssh/src/session/shell.ts @@ -1,15 +1,15 @@ import { Observable, Subject } from 'rxjs' import stripAnsi from 'strip-ansi' -import { ClientChannel } from 'ssh2' import { Injector } from '@angular/core' import { LogService } from 'tabby-core' import { BaseSession, UTF8SplitterMiddleware, InputProcessor } from 'tabby-terminal' import { SSHSession } from './ssh' import { SSHProfile } from '../api' +import * as russh from 'russh' export class SSHShellSession extends BaseSession { - shell?: ClientChannel + shell?: russh.Channel get serviceMessage$ (): Observable { return this.serviceMessage } private serviceMessage = new Subject() private ssh: SSHSession|null @@ -53,19 +53,19 @@ export class SSHShellSession extends BaseSession { this.loginScriptProcessor?.executeUnconditionalScripts() - this.shell.on('greeting', greeting => { - this.emitServiceMessage(`Shell greeting: ${greeting}`) + // this.shell.on('greeting', greeting => { + // this.emitServiceMessage(`Shell greeting: ${greeting}`) + // }) + + // this.shell.on('banner', banner => { + // this.emitServiceMessage(`Shell banner: ${banner}`) + // }) + + this.shell.data$.subscribe(data => { + this.emitOutput(Buffer.from(data)) }) - this.shell.on('banner', banner => { - this.emitServiceMessage(`Shell banner: ${banner}`) - }) - - this.shell.on('data', data => { - this.emitOutput(data) - }) - - this.shell.on('end', () => { + this.shell.eof$.subscribe(() => { this.logger.info('Shell session ended') if (this.open) { this.destroy() @@ -79,19 +79,22 @@ export class SSHShellSession extends BaseSession { } resize (columns: number, rows: number): void { - if (this.shell) { - this.shell.setWindow(rows, columns, rows, columns) - } + this.shell?.resizePTY({ + columns, + rows, + pixHeight: 0, + pixWidth: 0, + }) } write (data: Buffer): void { if (this.shell) { - this.shell.write(data) + this.shell.write(new Uint8Array(data)) } } - kill (signal?: string): void { - this.shell?.signal(signal ?? 'TERM') + kill (_signal?: string): void { + // this.shell?.signal(signal ?? 'TERM') } async destroy (): Promise { diff --git a/tabby-ssh/src/session/ssh.ts b/tabby-ssh/src/session/ssh.ts index 6bc354ef..40723f67 100644 --- a/tabby-ssh/src/session/ssh.ts +++ b/tabby-ssh/src/session/ssh.ts @@ -3,22 +3,22 @@ import * as crypto from 'crypto' import * as sshpk from 'sshpk' import colors from 'ansi-colors' import stripAnsi from 'strip-ansi' -import { Injector, NgZone } from '@angular/core' +import { Injector } from '@angular/core' import { NgbModal } from '@ng-bootstrap/ng-bootstrap' -import { ConfigService, FileProvidersService, HostAppService, NotificationsService, Platform, PlatformService, wrapPromise, PromptModalComponent, LogService, Logger, TranslateService } from 'tabby-core' -import { Socket } from 'net' -import { Client, ClientChannel, SFTPWrapper } from 'ssh2' +import { ConfigService, FileProvidersService, HostAppService, NotificationsService, Platform, PlatformService, PromptModalComponent, LogService, Logger, TranslateService } from 'tabby-core' +// import { Socket } from 'net' +// import { Client, ClientChannel, SFTPWrapper } from 'ssh2' import { Subject, Observable } from 'rxjs' import { HostKeyPromptModalComponent } from '../components/hostKeyPromptModal.component' -import { HTTPProxyStream, ProxyCommandStream, SocksProxyStream } from '../services/ssh.service' +// import { HTTPProxyStream, ProxyCommandStream, SocksProxyStream } from '../services/ssh.service' import { PasswordStorageService } from '../services/passwordStorage.service' import { SSHKnownHostsService } from '../services/sshKnownHosts.service' -import { promisify } from 'util' import { SFTPSession } from './sftp' -import { SSHAlgorithmType, PortForwardType, SSHProfile, SSHProxyStream, AutoPrivateKeyLocator } from '../api' +import { SSHAlgorithmType, SSHProfile, SSHProxyStream, AutoPrivateKeyLocator } from '../api' import { ForwardedPort } from './forwards' -import { X11Socket } from './x11' +// import { X11Socket } from './x11' import { supportedAlgorithms } from '../algorithms' +import * as russh from 'russh' const WINDOWS_OPENSSH_AGENT_PIPE = '\\\\.\\pipe\\openssh-ssh-agent' @@ -33,32 +33,42 @@ interface AuthMethod { contents?: Buffer } -interface Handshake { - kex: string - serverHostKey: string -} +// interface Handshake { +// kex: string +// serverHostKey: string +// } export class KeyboardInteractivePrompt { - responses: string[] = [] + readonly responses: string[] = [] + + private _resolve: (value: string[]) => void + private _reject: (reason: any) => void + readonly promise = new Promise((resolve, reject) => { + this._resolve = resolve + this._reject = reject + }) constructor ( public name: string, public instruction: string, public prompts: Prompt[], - private callback: (_: string[]) => void, ) { this.responses = new Array(this.prompts.length).fill('') } respond (): void { - this.callback(this.responses) + this._resolve(this.responses) + } + + reject (): void { + this._reject(new Error('Keyboard-interactive auth rejected')) } } export class SSHSession { - shell?: ClientChannel - ssh: Client - sftp?: SFTPWrapper + shell?: russh.Channel + ssh: russh.SSHClient|russh.AuthenticatedSSHClient + // sftp?: SFTPWrapper forwardedPorts: ForwardedPort[] = [] jumpStream: any proxyCommandStream: SSHProxyStream|null = null @@ -68,7 +78,7 @@ export class SSHSession { get willDestroy$ (): Observable { return this.willDestroy } agentPath?: string - activePrivateKey: string|null = null + activePrivateKey: russh.KeyPair|null = null authUsername: string|null = null open = false @@ -80,14 +90,13 @@ export class SSHSession { private keyboardInteractivePrompt = new Subject() private willDestroy = new Subject() private keychainPasswordUsed = false - private hostKeyDigest = '' private passwordStorage: PasswordStorageService private ngbModal: NgbModal private hostApp: HostAppService private platform: PlatformService private notifications: NotificationsService - private zone: NgZone + // private zone: NgZone private fileProviders: FileProvidersService private config: ConfigService private translate: TranslateService @@ -95,7 +104,7 @@ export class SSHSession { private privateKeyImporters: AutoPrivateKeyLocator[] constructor ( - private injector: Injector, + injector: Injector, public profile: SSHProfile, ) { this.logger = injector.get(LogService).create(`ssh-${profile.options.host}-${profile.options.port}`) @@ -105,7 +114,7 @@ export class SSHSession { this.hostApp = injector.get(HostAppService) this.platform = injector.get(PlatformService) this.notifications = injector.get(NotificationsService) - this.zone = injector.get(NgZone) + // this.zone = injector.get(NgZone) this.fileProviders = injector.get(FileProvidersService) this.config = injector.get(ConfigService) this.translate = injector.get(TranslateService) @@ -184,248 +193,245 @@ export class SSHSession { } async openSFTP (): Promise { - if (!this.sftp) { - this.sftp = await wrapPromise(this.zone, promisify(f => this.ssh.sftp(f))()) - } - return new SFTPSession(this.sftp, this.injector) + throw new Error('Not implemented') + // if (!this.sftp) { + // this.sftp = await wrapPromise(this.zone, promisify(f => this.ssh.sftp(f))()) + // } + // return new SFTPSession(this.sftp, this.injector) } async start (): Promise { - const log = (s: any) => this.emitServiceMessage(s) + // const log = (s: any) => this.emitServiceMessage(s) - const ssh = new Client() - this.ssh = ssh await this.init() - let connected = false const algorithms = {} for (const key of Object.values(SSHAlgorithmType)) { algorithms[key] = this.profile.options.algorithms![key].filter(x => supportedAlgorithms[key].includes(x)) } - const hostVerifiedPromise: Promise = new Promise((resolve, reject) => { - ssh.on('handshake', async handshake => { - if (!await this.verifyHostKey(handshake)) { - this.ssh.end() - reject(new Error('Host key verification failed')) + // todo migrate connection opts + this.ssh = await russh.SSHClient.connect( + `${this.profile.options.host.trim()}:${this.profile.options.port ?? 22}`, + async key => { + if (!await this.verifyHostKey(key)) { + return false } - this.logger.info('Handshake complete:', handshake) - resolve() - }) + this.logger.info('Host key verified') + return true + }, + { + preferred: { + ciphers: this.profile.options.algorithms?.[SSHAlgorithmType.CIPHER]?.filter(x => supportedAlgorithms[SSHAlgorithmType.CIPHER].includes(x)), + kex: this.profile.options.algorithms?.[SSHAlgorithmType.KEX]?.filter(x => supportedAlgorithms[SSHAlgorithmType.KEX].includes(x)), + mac: this.profile.options.algorithms?.[SSHAlgorithmType.HMAC]?.filter(x => supportedAlgorithms[SSHAlgorithmType.HMAC].includes(x)), + key: this.profile.options.algorithms?.[SSHAlgorithmType.HOSTKEY]?.filter(x => supportedAlgorithms[SSHAlgorithmType.HOSTKEY].includes(x)), + }, + }, + ) + + this.ssh.disconnect$.subscribe(() => { + if (this.open) { + this.destroy() + } }) - const resultPromise: Promise = new Promise(async (resolve, reject) => { - ssh.on('ready', () => { - connected = true - if (this.savedPassword) { - this.passwordStorage.savePassword(this.profile, this.savedPassword) - } + // auth - this.zone.run(resolve) - }) - ssh.on('error', error => { - if (error.message === 'All configured authentication methods failed') { - this.passwordStorage.deletePassword(this.profile) - } - this.zone.run(() => { - if (connected) { - // eslint-disable-next-line @typescript-eslint/no-base-to-string - this.notifications.error(error.toString()) - } else { - reject(error) - } - }) - }) - ssh.on('close', () => { - if (this.open) { - this.destroy() - } - }) + this.authUsername ??= this.profile.options.user + if (!this.authUsername) { + const modal = this.ngbModal.open(PromptModalComponent) + modal.componentInstance.prompt = `Username for ${this.profile.options.host}` + try { + const result = await modal.result.catch(() => null) + this.authUsername = result?.value ?? null + } catch { + this.authUsername = 'root' + } + } - ssh.on('keyboard-interactive', (name, instructions, instructionsLang, prompts, finish) => this.zone.run(async () => { - this.emitKeyboardInteractivePrompt(new KeyboardInteractivePrompt( - name, - instructions, - prompts, - finish, - )) - })) + if (this.authUsername?.startsWith('$')) { + try { + const result = process.env[this.authUsername.slice(1)] + this.authUsername = result ?? this.authUsername + } catch { + this.authUsername = 'root' + } + } - ssh.on('greeting', greeting => { - if (!this.profile.options.skipBanner) { - log('Greeting: ' + greeting) - } - }) + const authenticatedClient = await this.handleAuth() + if (authenticatedClient) { + this.ssh = authenticatedClient + } else { + this.ssh.disconnect() + this.passwordStorage.deletePassword(this.profile) + // eslint-disable-next-line @typescript-eslint/no-base-to-string + throw new Error('Authentication rejected') + } - ssh.on('banner', banner => { - if (!this.profile.options.skipBanner) { - log(banner) - } - }) - }) + // auth success + + if (this.savedPassword) { + this.passwordStorage.savePassword(this.profile, this.savedPassword) + } + + //zone ? + + // const resultPromise: Promise = new Promise(async (resolve, reject) => { + + + // ssh.on('greeting', greeting => { + // if (!this.profile.options.skipBanner) { + // log('Greeting: ' + greeting) + // } + // }) + + // ssh.on('banner', banner => { + // if (!this.profile.options.skipBanner) { + // log(banner) + // } + // }) + // }) try { - if (this.profile.options.socksProxyHost) { - this.emitServiceMessage(colors.bgBlue.black(' Proxy ') + ` Using ${this.profile.options.socksProxyHost}:${this.profile.options.socksProxyPort}`) - this.proxyCommandStream = new SocksProxyStream(this.profile) - } - if (this.profile.options.httpProxyHost) { - this.emitServiceMessage(colors.bgBlue.black(' Proxy ') + ` Using ${this.profile.options.httpProxyHost}:${this.profile.options.httpProxyPort}`) - this.proxyCommandStream = new HTTPProxyStream(this.profile) - } - if (this.profile.options.proxyCommand) { - this.emitServiceMessage(colors.bgBlue.black(' Proxy command ') + ` Using ${this.profile.options.proxyCommand}`) - this.proxyCommandStream = new ProxyCommandStream(this.profile.options.proxyCommand) - } - if (this.proxyCommandStream) { - this.proxyCommandStream.destroyed$.subscribe(err => { - if (err) { - this.emitServiceMessage(colors.bgRed.black(' X ') + ` ${err.message}`) - this.destroy() - } - }) + // if (this.profile.options.socksProxyHost) { + // this.emitServiceMessage(colors.bgBlue.black(' Proxy ') + ` Using ${this.profile.options.socksProxyHost}:${this.profile.options.socksProxyPort}`) + // this.proxyCommandStream = new SocksProxyStream(this.profile) + // } + // if (this.profile.options.httpProxyHost) { + // this.emitServiceMessage(colors.bgBlue.black(' Proxy ') + ` Using ${this.profile.options.httpProxyHost}:${this.profile.options.httpProxyPort}`) + // this.proxyCommandStream = new HTTPProxyStream(this.profile) + // } + // if (this.profile.options.proxyCommand) { + // this.emitServiceMessage(colors.bgBlue.black(' Proxy command ') + ` Using ${this.profile.options.proxyCommand}`) + // this.proxyCommandStream = new ProxyCommandStream(this.profile.options.proxyCommand) + // } + // if (this.proxyCommandStream) { + // this.proxyCommandStream.destroyed$.subscribe(err => { + // if (err) { + // this.emitServiceMessage(colors.bgRed.black(' X ') + ` ${err.message}`) + // this.destroy() + // } + // }) - this.proxyCommandStream.message$.subscribe(message => { - this.emitServiceMessage(colors.bgBlue.black(' Proxy ') + ' ' + message.trim()) - }) + // this.proxyCommandStream.message$.subscribe(message => { + // this.emitServiceMessage(colors.bgBlue.black(' Proxy ') + ' ' + message.trim()) + // }) - await this.proxyCommandStream.start() - } + // await this.proxyCommandStream.start() + // } - this.authUsername ??= this.profile.options.user - if (!this.authUsername) { - const modal = this.ngbModal.open(PromptModalComponent) - modal.componentInstance.prompt = `Username for ${this.profile.options.host}` - try { - const result = await modal.result.catch(() => null) - this.authUsername = result?.value ?? null - } catch { - this.authUsername = 'root' - } - } - if (this.authUsername?.startsWith('$')) { - try { - const result = process.env[this.authUsername.slice(1)] - this.authUsername = result ?? this.authUsername - } catch { - this.authUsername = 'root' - } - } - ssh.connect({ - host: this.profile.options.host.trim(), - port: this.profile.options.port ?? 22, - sock: this.proxyCommandStream?.socket ?? this.jumpStream, - username: this.authUsername ?? undefined, - tryKeyboard: true, - agent: this.agentPath, - agentForward: this.profile.options.agentForward && !!this.agentPath, - keepaliveInterval: this.profile.options.keepaliveInterval ?? 15000, - keepaliveCountMax: this.profile.options.keepaliveCountMax, - readyTimeout: this.profile.options.readyTimeout, - hostVerifier: (key: any) => { - this.hostKeyDigest = crypto.createHash('sha256').update(key).digest('base64') - return true - }, - algorithms, - authHandler: (methodsLeft, partialSuccess, callback) => { - this.zone.run(async () => { - callback(await this.handleAuth(methodsLeft)) - }) - }, - }) + // ssh.connect({ + // host: this.profile.options.host.trim(), + // port: this.profile.options.port ?? 22, + // sock: this.proxyCommandStream?.socket ?? this.jumpStream, + // username: this.authUsername ?? undefined, + // tryKeyboard: true, + // agent: this.agentPath, + // agentForward: this.profile.options.agentForward && !!this.agentPath, + // keepaliveInterval: this.profile.options.keepaliveInterval ?? 15000, + // keepaliveCountMax: this.profile.options.keepaliveCountMax, + // readyTimeout: this.profile.options.readyTimeout, + // algorithms, + // authHandler: (methodsLeft, partialSuccess, callback) => { + // this.zone.run(async () => { + // callback(await this.handleAuth(methodsLeft)) + // }) + // }, + // }) } catch (e) { this.notifications.error(e.message) throw e } - await resultPromise - await hostVerifiedPromise - - for (const fw of this.profile.options.forwardedPorts ?? []) { - this.addPortForward(Object.assign(new ForwardedPort(), fw)) - } + // for (const fw of this.profile.options.forwardedPorts ?? []) { + // this.addPortForward(Object.assign(new ForwardedPort(), fw)) + // } this.open = true - this.ssh.on('tcp connection', (details, accept, reject) => { - this.logger.info(`Incoming forwarded connection: (remote) ${details.srcIP}:${details.srcPort} -> (local) ${details.destIP}:${details.destPort}`) - const forward = this.forwardedPorts.find(x => x.port === details.destPort) - if (!forward) { - this.emitServiceMessage(colors.bgRed.black(' X ') + ` Rejected incoming forwarded connection for unrecognized port ${details.destPort}`) - reject() - return - } - const socket = new Socket() - socket.connect(forward.targetPort, forward.targetAddress) - socket.on('error', e => { - // eslint-disable-next-line @typescript-eslint/no-base-to-string - this.emitServiceMessage(colors.bgRed.black(' X ') + ` Could not forward the remote connection to ${forward.targetAddress}:${forward.targetPort}: ${e}`) - reject() - }) - socket.on('connect', () => { - this.logger.info('Connection forwarded') - const stream = accept() - stream.pipe(socket) - socket.pipe(stream) - stream.on('close', () => { - socket.destroy() - }) - socket.on('close', () => { - stream.close() - }) - }) - }) + // this.ssh.on('tcp connection', (details, accept, reject) => { + // this.logger.info(`Incoming forwarded connection: (remote) ${details.srcIP}:${details.srcPort} -> (local) ${details.destIP}:${details.destPort}`) + // const forward = this.forwardedPorts.find(x => x.port === details.destPort) + // if (!forward) { + // this.emitServiceMessage(colors.bgRed.black(' X ') + ` Rejected incoming forwarded connection for unrecognized port ${details.destPort}`) + // reject() + // return + // } + // const socket = new Socket() + // socket.connect(forward.targetPort, forward.targetAddress) + // socket.on('error', e => { + // // eslint-disable-next-line @typescript-eslint/no-base-to-string + // this.emitServiceMessage(colors.bgRed.black(' X ') + ` Could not forward the remote connection to ${forward.targetAddress}:${forward.targetPort}: ${e}`) + // reject() + // }) + // socket.on('connect', () => { + // this.logger.info('Connection forwarded') + // const stream = accept() + // stream.pipe(socket) + // socket.pipe(stream) + // stream.on('close', () => { + // socket.destroy() + // }) + // socket.on('close', () => { + // stream.close() + // }) + // }) + // }) - this.ssh.on('x11', async (details, accept, reject) => { - this.logger.info(`Incoming X11 connection from ${details.srcIP}:${details.srcPort}`) - const displaySpec = (this.config.store.ssh.x11Display || process.env.DISPLAY) ?? 'localhost:0' - this.logger.debug(`Trying display ${displaySpec}`) + // this.ssh.on('x11', async (details, accept, reject) => { + // this.logger.info(`Incoming X11 connection from ${details.srcIP}:${details.srcPort}`) + // const displaySpec = (this.config.store.ssh.x11Display || process.env.DISPLAY) ?? 'localhost:0' + // this.logger.debug(`Trying display ${displaySpec}`) - const socket = new X11Socket() - try { - const x11Stream = await socket.connect(displaySpec) - this.logger.info('Connection forwarded') - const stream = accept() - stream.pipe(x11Stream) - x11Stream.pipe(stream) - stream.on('close', () => { - socket.destroy() - }) - x11Stream.on('close', () => { - stream.close() - }) - } catch (e) { - // eslint-disable-next-line @typescript-eslint/no-base-to-string - this.emitServiceMessage(colors.bgRed.black(' X ') + ` Could not connect to the X server: ${e}`) - this.emitServiceMessage(` Tabby tried to connect to ${JSON.stringify(X11Socket.resolveDisplaySpec(displaySpec))} based on the DISPLAY environment var (${displaySpec})`) - if (process.platform === 'win32') { - this.emitServiceMessage(' To use X forwarding, you need a local X server, e.g.:') - this.emitServiceMessage(' * VcXsrv: https://sourceforge.net/projects/vcxsrv/') - this.emitServiceMessage(' * Xming: https://sourceforge.net/projects/xming/') - } - reject() - } - }) + // const socket = new X11Socket() + // try { + // const x11Stream = await socket.connect(displaySpec) + // this.logger.info('Connection forwarded') + // const stream = accept() + // stream.pipe(x11Stream) + // x11Stream.pipe(stream) + // stream.on('close', () => { + // socket.destroy() + // }) + // x11Stream.on('close', () => { + // stream.close() + // }) + // } catch (e) { + // // eslint-disable-next-line @typescript-eslint/no-base-to-string + // this.emitServiceMessage(colors.bgRed.black(' X ') + ` Could not connect to the X server: ${e}`) + // this.emitServiceMessage(` Tabby tried to connect to ${JSON.stringify(X11Socket.resolveDisplaySpec(displaySpec))} based on the DISPLAY environment var (${displaySpec})`) + // if (process.platform === 'win32') { + // this.emitServiceMessage(' To use X forwarding, you need a local X server, e.g.:') + // this.emitServiceMessage(' * VcXsrv: https://sourceforge.net/projects/vcxsrv/') + // this.emitServiceMessage(' * Xming: https://sourceforge.net/projects/xming/') + // } + // reject() + // } + // }) } - private async verifyHostKey (handshake: Handshake): Promise { + private async verifyHostKey (key: russh.SshPublicKey): Promise { this.emitServiceMessage('Host key fingerprint:') - this.emitServiceMessage(colors.white.bgBlack(` ${handshake.serverHostKey} `) + colors.bgBlackBright(' ' + this.hostKeyDigest + ' ')) + this.emitServiceMessage(colors.white.bgBlack(` ${key.algorithm()} `) + colors.bgBlackBright(' ' + key.fingerprint() + ' ')) if (!this.config.store.ssh.verifyHostKeys) { return true } const selector = { host: this.profile.options.host, port: this.profile.options.port ?? 22, - type: handshake.serverHostKey, + type: key.algorithm(), } + + const keyDigest = crypto.createHash('sha256').update(key.bytes()).digest('base64') + const knownHost = this.knownHosts.getFor(selector) - if (!knownHost || knownHost.digest !== this.hostKeyDigest) { + if (!knownHost || knownHost.digest !== keyDigest) { const modal = this.ngbModal.open(HostKeyPromptModalComponent) modal.componentInstance.selector = selector - modal.componentInstance.digest = this.hostKeyDigest + modal.componentInstance.digest = keyDigest return modal.result.catch(() => false) } return true @@ -447,13 +453,21 @@ export class SSHSession { this.keyboardInteractivePrompt.next(prompt) } - async handleAuth (methodsLeft?: string[] | null): Promise { + async handleAuth (methodsLeft?: string[] | null): Promise { this.activePrivateKey = null + if (!(this.ssh instanceof russh.SSHClient)) { + throw new Error('Wrong state for auth handling') + } + + if (!this.authUsername) { + throw new Error('No username') + } + while (true) { const method = this.remainingAuthMethods.shift() if (!method) { - return false + return null } if (methodsLeft && !methodsLeft.includes(method.type) && method.type !== 'agent') { // Agent can still be used even if not in methodsLeft @@ -463,10 +477,9 @@ export class SSHSession { if (method.type === 'password') { if (this.profile.options.password) { this.emitServiceMessage(this.translate.instant('Using preset password')) - return { - type: 'password', - username: this.authUsername, - password: this.profile.options.password, + const result = await this.ssh.authenticateWithPassword(this.authUsername, this.profile.options.password) + if (result) { + return result } } @@ -475,10 +488,9 @@ export class SSHSession { if (password) { this.emitServiceMessage(this.translate.instant('Trying saved password')) this.keychainPasswordUsed = true - return { - type: 'password', - username: this.authUsername, - password, + const result = await this.ssh.authenticateWithPassword(this.authUsername, password) + if (result) { + return result } } } @@ -489,15 +501,14 @@ export class SSHSession { modal.componentInstance.showRememberCheckbox = true try { - const result = await modal.result.catch(() => null) - if (result) { - if (result.remember) { - this.savedPassword = result.value + const promptResult = await modal.result.catch(() => null) + if (promptResult) { + if (promptResult.remember) { + this.savedPassword = promptResult.value } - return { - type: 'password', - username: this.authUsername, - password: result.value, + const result = await this.ssh.authenticateWithPassword(this.authUsername, promptResult.value) + if (result) { + return result } } else { continue @@ -509,81 +520,116 @@ export class SSHSession { if (method.type === 'publickey' && method.contents) { try { const key = await this.loadPrivateKey(method.name!, method.contents) - return { - type: 'publickey', - username: this.authUsername, - key, + const result = await this.ssh.authenticateWithKeyPair(this.authUsername, key) + if (result) { + return result } } catch (e) { this.emitServiceMessage(colors.bgYellow.yellow.black(' ! ') + ` Failed to load private key ${method.name}: ${e}`) continue } } - return method.type + if (method.type === 'keyboard-interactive') { + let state: russh.AuthenticatedSSHClient|russh.KeyboardInteractiveAuthenticationState = await this.ssh.startKeyboardInteractiveAuthentication(this.authUsername) + + while (true) { + if (state.state === 'failure') { + break + } + + const prompts = await state.prompts() + + let responses: string[] = [] + // OpenSSH can send a k-i request without prompts + // just respond ok to it + if (prompts.length > 0) { + const prompt = new KeyboardInteractivePrompt( + state.name, + state.instructions, + await state.prompts(), + ) + this.emitKeyboardInteractivePrompt(prompt) + + try { + // eslint-disable-next-line @typescript-eslint/await-thenable + responses = await prompt.promise + } catch { + break // this loop + } + } + + state = await this.ssh .continueKeyboardInteractiveAuthentication(responses) + + if (state instanceof russh.AuthenticatedSSHClient) { + return state + } + } + } } + return null } - async addPortForward (fw: ForwardedPort): Promise { - if (fw.type === PortForwardType.Local || fw.type === PortForwardType.Dynamic) { - await fw.startLocalListener((accept, reject, sourceAddress, sourcePort, targetAddress, targetPort) => { - this.logger.info(`New connection on ${fw}`) - this.ssh.forwardOut( - sourceAddress ?? '127.0.0.1', - sourcePort ?? 0, - targetAddress, - targetPort, - (err, stream) => { - if (err) { - // eslint-disable-next-line @typescript-eslint/no-base-to-string - this.emitServiceMessage(colors.bgRed.black(' X ') + ` Remote has rejected the forwarded connection to ${targetAddress}:${targetPort} via ${fw}: ${err}`) - reject() - return - } - const socket = accept() - stream.pipe(socket) - socket.pipe(stream) - stream.on('close', () => { - socket.destroy() - }) - socket.on('close', () => { - stream.close() - }) - }, - ) - }).then(() => { - this.emitServiceMessage(colors.bgGreen.black(' -> ') + ` Forwarded ${fw}`) - this.forwardedPorts.push(fw) - }).catch(e => { - this.emitServiceMessage(colors.bgRed.black(' X ') + ` Failed to forward port ${fw}: ${e}`) - throw e - }) - } - if (fw.type === PortForwardType.Remote) { - await new Promise((resolve, reject) => { - this.ssh.forwardIn(fw.host, fw.port, err => { - if (err) { - // eslint-disable-next-line @typescript-eslint/no-base-to-string - this.emitServiceMessage(colors.bgRed.black(' X ') + ` Remote rejected port forwarding for ${fw}: ${err}`) - reject(err) - return - } - resolve() - }) - }) - this.emitServiceMessage(colors.bgGreen.black(' <- ') + ` Forwarded ${fw}`) - this.forwardedPorts.push(fw) - } + async addPortForward (_fw: ForwardedPort): Promise { + // if (fw.type === PortForwardType.Local || fw.type === PortForwardType.Dynamic) { + // await fw.startLocalListener((accept, reject, sourceAddress, sourcePort, targetAddress, targetPort) => { + // this.logger.info(`New connection on ${fw}`) + // this.ssh.forwardOut( + // sourceAddress ?? '127.0.0.1', + // sourcePort ?? 0, + // targetAddress, + // targetPort, + // (err, stream) => { + // if (err) { + // // eslint-disable-next-line @typescript-eslint/no-base-to-string + // this.emitServiceMessage(colors.bgRed.black(' X ') + ` Remote has rejected the forwarded connection to ${targetAddress}:${targetPort} via ${fw}: ${err}`) + // reject() + // return + // } + // const socket = accept() + // stream.pipe(socket) + // socket.pipe(stream) + // stream.on('close', () => { + // socket.destroy() + // }) + // socket.on('close', () => { + // stream.close() + // }) + // }, + // ) + // }).then(() => { + // this.emitServiceMessage(colors.bgGreen.black(' -> ') + ` Forwarded ${fw}`) + // this.forwardedPorts.push(fw) + // }).catch(e => { + // this.emitServiceMessage(colors.bgRed.black(' X ') + ` Failed to forward port ${fw}: ${e}`) + // throw e + // }) + // } + // if (fw.type === PortForwardType.Remote) { + // await new Promise((resolve, reject) => { + // this.ssh.forwardIn(fw.host, fw.port, err => { + // if (err) { + // // eslint-disable-next-line @typescript-eslint/no-base-to-string + // this.emitServiceMessage(colors.bgRed.black(' X ') + ` Remote rejected port forwarding for ${fw}: ${err}`) + // reject(err) + // return + // } + // resolve() + // }) + // }) + // this.emitServiceMessage(colors.bgGreen.black(' <- ') + ` Forwarded ${fw}`) + // this.forwardedPorts.push(fw) + // } } async removePortForward (fw: ForwardedPort): Promise { - if (fw.type === PortForwardType.Local || fw.type === PortForwardType.Dynamic) { - fw.stopLocalListener() - this.forwardedPorts = this.forwardedPorts.filter(x => x !== fw) - } - if (fw.type === PortForwardType.Remote) { - this.ssh.unforwardIn(fw.host, fw.port) - this.forwardedPorts = this.forwardedPorts.filter(x => x !== fw) - } + // if (fw.type === PortForwardType.Local || fw.type === PortForwardType.Dynamic) { + // fw.stopLocalListener() + // this.forwardedPorts = this.forwardedPorts.filter(x => x !== fw) + // } + // if (fw.type === PortForwardType.Remote) { + // this.ssh.unforwardIn(fw.host, fw.port) + // this.forwardedPorts = this.forwardedPorts.filter(x => x !== fw) + // } this.emitServiceMessage(`Stopped forwarding ${fw}`) } @@ -593,25 +639,36 @@ export class SSHSession { this.willDestroy.complete() this.serviceMessage.complete() this.proxyCommandStream?.stop() - this.ssh.end() + this.ssh.disconnect() } - openShellChannel (options: { x11: boolean }): Promise { - return new Promise((resolve, reject) => { - this.ssh.shell({ term: 'xterm-256color' }, options, (err, shell) => { - if (err) { - reject(err) - } else { - resolve(shell) - } - }) + async openShellChannel (options: { x11: boolean }): Promise { + if (!(this.ssh instanceof russh.AuthenticatedSSHClient)) { + throw new Error('Cannot open shell channel before auth') + } + const ch = await this.ssh.openSessionChannel() + await ch.requestPTY('xterm-256color', { + columns: 80, + rows: 24, + pixHeight: 0, + pixWidth: 0, }) + if (options.x11) { + await ch.requestX11Forwarding({ + singleConnection: false, + authProtocol: 'MIT-MAGIC-COOKIE-1', + authCookie: crypto.randomBytes(16).toString('hex'), + screenNumber: 0, + }) + } + await ch.requestShell() + return ch } - async loadPrivateKey (name: string, privateKeyContents: Buffer): Promise { + async loadPrivateKey (name: string, privateKeyContents: Buffer): Promise { this.emitServiceMessage(`Loading private key: ${name}`) - const parsedKey = await this.parsePrivateKey(privateKeyContents.toString()) - this.activePrivateKey = parsedKey.toString('openssh') + //todo passphrase handling + this.activePrivateKey = await russh.KeyPair.parse(privateKeyContents.toString()) return this.activePrivateKey } diff --git a/tabby-ssh/webpack.config.mjs b/tabby-ssh/webpack.config.mjs index 16ce381b..028b4e0e 100644 --- a/tabby-ssh/webpack.config.mjs +++ b/tabby-ssh/webpack.config.mjs @@ -7,9 +7,4 @@ import config from '../webpack.plugin.config.mjs' export default () => config({ name: 'ssh', dirname: __dirname, - alias: { - 'cpu-features': false, - './crypto/build/Release/sshcrypto.node': false, - '../build/Release/cpufeatures.node': false, - }, }) diff --git a/webpack.plugin.config.mjs b/webpack.plugin.config.mjs index d8e78f57..e4cab027 100644 --- a/webpack.plugin.config.mjs +++ b/webpack.plugin.config.mjs @@ -157,6 +157,7 @@ export default options => { 'os', 'path', 'readline', + 'russh', '@luminati-io/socksv5', 'stream', 'windows-native-registry',