浏览代码

ssh - added proxy command support - fixes #3722

Eugene Pankov 4 年之前
父节点
当前提交
7cf8f8d58e

+ 9 - 0
terminus-ssh/src/api.ts

@@ -6,6 +6,7 @@ import { Server, Socket, createServer, createConnection } from 'net'
 import { Client, ClientChannel } from 'ssh2'
 import { Logger } from 'terminus-core'
 import { Subject, Observable } from 'rxjs'
+import { ProxyCommandStream } from './services/ssh.service'
 
 export interface LoginScript {
     expect: string
@@ -42,6 +43,7 @@ export interface SSHConnection {
     agentForward?: boolean
     warnOnClose?: boolean
     algorithms?: Record<string, string[]>
+    proxyCommand?: string
 }
 
 export enum PortForwardType {
@@ -117,6 +119,7 @@ export class SSHSession extends BaseSession {
     forwardedPorts: ForwardedPort[] = []
     logger: Logger
     jumpStream: any
+    proxyCommandStream: ProxyCommandStream|null = null
 
     get serviceMessage$ (): Observable<string> { return this.serviceMessage }
     private serviceMessage = new Subject<string>()
@@ -136,6 +139,11 @@ export class SSHSession extends BaseSession {
     async start (): Promise<void> {
         this.open = true
 
+        this.proxyCommandStream?.on('error', err => {
+            this.emitServiceMessage(colors.bgRed.black(' X ') + ` ${err.message}`)
+            this.destroy()
+        })
+
         try {
             this.shell = await this.openShellChannel({ x11: this.connection.x11 })
         } catch (err) {
@@ -361,6 +369,7 @@ export class SSHSession extends BaseSession {
 
     async destroy (): Promise<void> {
         this.serviceMessage.complete()
+        this.proxyCommandStream?.destroy()
         await super.destroy()
     }
 

+ 22 - 6
terminus-ssh/src/components/editConnectionModal.component.pug

@@ -19,8 +19,8 @@
                         [(ngModel)]='connection.group',
                     )
 
-                .d-flex
-                    .form-group
+                .d-flex.w-100(*ngIf='!useProxyCommand')
+                    .form-group.w-100.mr-4
                         label Host
                         input.form-control(
                             type='text',
@@ -35,6 +35,9 @@
                             [(ngModel)]='connection.port',
                         )
 
+                .alert.alert-info(*ngIf='useProxyCommand')
+                    .mr-auto Using a proxy command instead of a network connection
+
                 .form-group
                     label Username
                     input.form-control(
@@ -42,9 +45,9 @@
                         [(ngModel)]='connection.user',
                     )
 
-                .form-line
-                    .header
-                        .title Authentication
+                .form-group
+                    label Authentication method
+
                 .btn-group.w-100(
                     [(ngModel)]='connection.auth',
                     ngbRadioGroup
@@ -99,7 +102,7 @@
         li(ngbNavItem)
             a(ngbNavLink) Advanced
             ng-template(ngbNavContent)
-                .form-line
+                .form-line(*ngIf='!useProxyCommand')
                     .header
                         .title Jump host
                     select.form-control([(ngModel)]='connection.jumpHost')
@@ -165,6 +168,19 @@
                         [(ngModel)]='connection.readyTimeout',
                     )
 
+                .form-line(*ngIf='!connection.jumpHost')
+                    .header
+                        .title Use a proxy command
+                        .description Command's stdin/stdout is used instead of a network connection
+                    toggle([(ngModel)]='useProxyCommand')
+
+                .form-group(*ngIf='useProxyCommand && !connection.jumpHost')
+                    label Proxy command
+                    input.form-control(
+                        type='text',
+                        [(ngModel)]='connection.proxyCommand',
+                    )
+
         li(ngbNavItem)
             a(ngbNavLink) Ciphers
             ng-template(ngbNavContent)

+ 6 - 0
terminus-ssh/src/components/editConnectionModal.component.ts

@@ -14,6 +14,7 @@ import { ALGORITHMS } from 'ssh2-streams/lib/constants'
 export class EditConnectionModalComponent {
     connection: SSHConnection
     hasSavedPassword: boolean
+    useProxyCommand: boolean
 
     supportedAlgorithms: Record<string, string> = {}
     defaultAlgorithms: Record<string, string[]> = {}
@@ -51,6 +52,8 @@ export class EditConnectionModalComponent {
         this.connection.scripts = this.connection.scripts ?? []
         this.connection.auth = this.connection.auth ?? null
 
+        this.useProxyCommand = !!this.connection.proxyCommand
+
         for (const k of Object.values(SSHAlgorithmType)) {
             // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
             if (!this.connection.algorithms[k]) {
@@ -102,6 +105,9 @@ export class EditConnectionModalComponent {
                 .filter(([_, v]) => !!v)
                 .map(([key, _]) => key)
         }
+        if (!this.useProxyCommand) {
+            this.connection.proxyCommand = undefined
+        }
         this.modalInstance.close(this.connection)
     }
 

+ 2 - 2
terminus-ssh/src/components/sshTab.component.ts

@@ -119,7 +119,7 @@ export class SSHTabComponent extends BaseTerminalTabComponent {
         }))
 
 
-        this.write('\r\n' + colors.black.bgCyan(' SSH ') + ` Connecting to ${session.connection.host}\r\n`)
+        this.write('\r\n' + colors.black.bgWhite(' SSH ') + ` Connecting to ${session.connection.host}\r\n`)
 
         const spinner = new Spinner({
             text: 'Connecting',
@@ -156,7 +156,7 @@ export class SSHTabComponent extends BaseTerminalTabComponent {
                 this.destroy()
             } else {
                 // Session was closed abruptly
-                this.write('\r\n' + colors.black.bgCyan(' SSH ') + ` ${session.connection.host}: session closed\r\n`)
+                this.write('\r\n' + colors.black.bgWhite(' SSH ') + ` ${session.connection.host}: session closed\r\n`)
                 if (!this.reconnectOffered) {
                     this.reconnectOffered = true
                     this.write('Press any key to reconnect\r\n')

+ 62 - 1
terminus-ssh/src/services/ssh.service.ts

@@ -1,4 +1,5 @@
 import colors from 'ansi-colors'
+import { Duplex } from 'stream'
 import stripAnsi from 'strip-ansi'
 import { open as openTemp } from 'temp'
 import { Injectable, NgZone } from '@angular/core'
@@ -7,14 +8,17 @@ import { Client } from 'ssh2'
 import { SSH2Stream } from 'ssh2-streams'
 import * as fs from 'mz/fs'
 import { execFile } from 'mz/child_process'
+import { exec } from 'child_process'
 import * as path from 'path'
 import * as sshpk from 'sshpk'
+import { Subject, Observable } from 'rxjs'
 import { HostAppService, Platform, Logger, LogService, ElectronService, AppService, SelectorOption, ConfigService, NotificationsService } from 'terminus-core'
 import { SettingsTabComponent } from 'terminus-settings'
 import { ALGORITHM_BLACKLIST, SSHConnection, SSHSession } from '../api'
 import { PromptModalComponent } from '../components/promptModal.component'
 import { PasswordStorageService } from './passwordStorage.service'
 import { SSHTabComponent } from '../components/sshTab.component'
+import { ChildProcess } from 'node:child_process'
 
 const WINDOWS_OPENSSH_AGENT_PIPE = '\\\\.\\pipe\\openssh-ssh-agent'
 
@@ -252,9 +256,21 @@ export class SSHService {
             authMethodsLeft.push('hostbased')
 
             try {
+                if (session.connection.proxyCommand) {
+                    log(`Using proxy command: ${session.connection.proxyCommand}`)
+                    session.proxyCommandStream = new ProxyCommandStream(session.connection.proxyCommand)
+
+                    session.proxyCommandStream.output$.subscribe((message: string) => {
+                        session.emitServiceMessage(colors.bgBlue.black(' Proxy command ') + ' ' + message)
+                    })
+
+                    await session.proxyCommandStream.start()
+                }
+
                 ssh.connect({
                     host: session.connection.host,
                     port: session.connection.port ?? 22,
+                    sock: session.proxyCommandStream ?? session.jumpStream,
                     username: session.connection.user,
                     password: session.connection.privateKey ? undefined : '',
                     privateKey: privateKey ?? undefined,
@@ -271,7 +287,6 @@ export class SSHService {
                     },
                     hostHash: 'sha256' as any,
                     algorithms,
-                    sock: session.jumpStream,
                     authHandler: methodsLeft => {
                         while (true) {
                             const method = authMethodsLeft.shift()
@@ -448,6 +463,52 @@ export class SSHService {
     }
 }
 
+export class ProxyCommandStream extends Duplex {
+    private process: ChildProcess
+
+    get output$ (): Observable<string> { return this.output }
+    private output = new Subject<string>()
+
+    constructor (private command: string) {
+        super({
+            allowHalfOpen: false,
+        })
+    }
+
+    async start (): Promise<void> {
+        this.process = exec(this.command, {
+            windowsHide: true,
+            encoding: 'buffer',
+        })
+        this.process.on('exit', code => {
+            this.destroy(new Error(`Proxy command has exited with code ${code}`))
+        })
+        this.process.stdout?.on('data', data => {
+            this.push(data)
+        })
+        this.process.stdout?.on('error', (err) => {
+            this.destroy(err)
+        })
+        this.process.stderr?.on('data', data => {
+            this.output.next(data.toString())
+        })
+    }
+
+    _read (size: number): void {
+        process.stdout.read(size)
+    }
+
+    _write (chunk: Buffer, _encoding: string, callback: (error?: Error | null) => void): void {
+        this.process.stdin?.write(chunk, callback)
+    }
+
+    _destroy (error: Error|null, callback: (error: Error|null) => void): void {
+        this.process.kill()
+        this.output.complete()
+        callback(error)
+    }
+}
+
 /* eslint-disable */
 const _authPassword = SSH2Stream.prototype.authPassword
 SSH2Stream.prototype.authPassword = async function (username, passwordFn: any) {