Eugene 1 rok temu
rodzic
commit
a01d693eec

+ 0 - 1
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",

+ 0 - 39
patches/ssh2+1.11.0.patch

@@ -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': {

+ 0 - 1
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",

+ 41 - 17
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<string, string> = {}
+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'),
+}
 
-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 defaultAlgorithms = {
+    [SSHAlgorithmType.KEX]: [
+        'curve25519-sha256',
+        '[email protected]',
+        'diffie-hellman-group16-sha512',
+        'diffie-hellman-group14-sha256',
+        'ext-info-c',
+        'ext-info-s',
+        '[email protected]',
+        '[email protected]',
+    ],
+    [SSHAlgorithmType.HOSTKEY]: [
+        'ssh-ed25519',
+        'ecdsa-sha2-nistp256',
+        'ecdsa-sha2-nistp521',
+        'rsa-sha2-256',
+        'rsa-sha2-512',
+    ],
+    [SSHAlgorithmType.CIPHER]: [
+        '[email protected]',
+        '[email protected]',
+        'aes256-ctr',
+        'aes192-ctr',
+        'aes128-ctr',
+    ],
+    [SSHAlgorithmType.HMAC]: [
+        '[email protected]',
+        '[email protected]',
+        'hmac-sha2-512',
+        'hmac-sha2-256',
+        '[email protected]',
+        'hmac-sha1',
+    ],
 }

+ 0 - 10
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 = []
-}

+ 11 - 11
tabby-ssh/src/components/sshTab.component.ts

@@ -94,17 +94,17 @@ export class SSHTabComponent extends ConnectableTerminalTabComponent<SSHProfile>
                     }
                 })
 
-                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)
+                //     },
+                // ))
             }
         }
 

+ 7 - 13
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<SSHProfile> {
@@ -29,10 +29,10 @@ export class SSHProfilesService extends QuickConnectProfileProvider<SSHProfile>
             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<SSHProfile>
     ) {
         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()
         }
     }

+ 2 - 2
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}`)

+ 20 - 17
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<string> { return this.serviceMessage }
     private serviceMessage = new Subject<string>()
     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.on('banner', banner => {
+        //     this.emitServiceMessage(`Shell banner: ${banner}`)
+        // })
 
-        this.shell.on('data', data => {
-            this.emitOutput(data)
+        this.shell.data$.subscribe(data => {
+            this.emitOutput(Buffer.from(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<void> {

+ 371 - 314
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<string[]>((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<void> { 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<KeyboardInteractivePrompt>()
     private willDestroy = new Subject<void>()
     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<SFTPSession> {
-        if (!this.sftp) {
-            this.sftp = await wrapPromise(this.zone, promisify<SFTPWrapper>(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<SFTPWrapper>(f => this.ssh.sftp(f))())
+        // }
+        // return new SFTPSession(this.sftp, this.injector)
     }
 
 
     async start (): Promise<void> {
-        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<void> = 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<void> = 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,
-                ))
-            }))
-
-            ssh.on('greeting', greeting => {
-                if (!this.profile.options.skipBanner) {
-                    log('Greeting: ' + greeting)
-                }
-            })
+        if (this.authUsername?.startsWith('$')) {
+            try {
+                const result = process.env[this.authUsername.slice(1)]
+                this.authUsername = result ?? this.authUsername
+            } catch {
+                this.authUsername = 'root'
+            }
+        }
 
-            ssh.on('banner', banner => {
-                if (!this.profile.options.skipBanner) {
-                    log(banner)
-                }
-            })
-        })
+        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')
+        }
 
-        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()
-                    }
-                })
+        // auth success
 
-                this.proxyCommandStream.message$.subscribe(message => {
-                    this.emitServiceMessage(colors.bgBlue.black(' Proxy ') + ' ' + message.trim())
-                })
+        if (this.savedPassword) {
+            this.passwordStorage.savePassword(this.profile, this.savedPassword)
+        }
 
-                await this.proxyCommandStream.start()
-            }
+        //zone ?
 
-            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'
-                }
-            }
+        // const resultPromise: Promise<void> = new Promise(async (resolve, reject) => {
 
-            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.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()
+            //         }
+            //     })
+
+            //     this.proxyCommandStream.message$.subscribe(message => {
+            //         this.emitServiceMessage(colors.bgBlue.black(' Proxy ') + ' ' + message.trim())
+            //     })
+
+            //     await this.proxyCommandStream.start()
+            // }
+
+
+            // 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('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()
-            }
-        })
+        // 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}`)
+
+        //     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<boolean> {
+    private async verifyHostKey (key: russh.SshPublicKey): Promise<boolean> {
         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<any> {
+    async handleAuth (methodsLeft?: string[] | null): Promise<russh.AuthenticatedSSHClient|null> {
         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)
 
-    async addPortForward (fw: ForwardedPort): Promise<void> {
-        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
+                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
                         }
-                        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<void>((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)
+
+                    state = await this.ssh .continueKeyboardInteractiveAuthentication(responses)
+
+                    if (state instanceof russh.AuthenticatedSSHClient) {
+                        return state
+                    }
+                }
+            }
         }
+        return null
+    }
+
+    async addPortForward (_fw: ForwardedPort): Promise<void> {
+        // 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<void>((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<void> {
-        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<ClientChannel> {
-        return new Promise<ClientChannel>((resolve, reject) => {
-            this.ssh.shell({ term: 'xterm-256color' }, options, (err, shell) => {
-                if (err) {
-                    reject(err)
-                } else {
-                    resolve(shell)
-                }
-            })
+    async openShellChannel (options: { x11: boolean }): Promise<russh.Channel> {
+        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<string|null> {
+    async loadPrivateKey (name: string, privateKeyContents: Buffer): Promise<russh.KeyPair> {
         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
     }
 

+ 0 - 5
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,
-    },
 })

+ 1 - 0
webpack.plugin.config.mjs

@@ -157,6 +157,7 @@ export default options => {
             'os',
             'path',
             'readline',
+            'russh',
             '@luminati-io/socksv5',
             'stream',
             'windows-native-registry',