ssh - added proxy command support - fixes #3722

This commit is contained in:
Eugene Pankov 2021-04-24 19:57:05 +02:00
parent ff0cd36b6a
commit 7cf8f8d58e
No known key found for this signature in database
GPG Key ID: 5896FCBBDD1CF4F4
5 changed files with 101 additions and 9 deletions

View File

@ -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()
}

View File

@ -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)

View File

@ -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)
}

View File

@ -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')

View File

@ -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) {