diff --git a/terminus-ssh/src/api.ts b/terminus-ssh/src/api.ts index 63b74adb..609c4a30 100644 --- a/terminus-ssh/src/api.ts +++ b/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 + 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 { return this.serviceMessage } private serviceMessage = new Subject() @@ -136,6 +139,11 @@ export class SSHSession extends BaseSession { async start (): Promise { 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 { this.serviceMessage.complete() + this.proxyCommandStream?.destroy() await super.destroy() } diff --git a/terminus-ssh/src/components/editConnectionModal.component.pug b/terminus-ssh/src/components/editConnectionModal.component.pug index 42d33a07..85b5b1c5 100644 --- a/terminus-ssh/src/components/editConnectionModal.component.pug +++ b/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) diff --git a/terminus-ssh/src/components/editConnectionModal.component.ts b/terminus-ssh/src/components/editConnectionModal.component.ts index 980322bb..30af201e 100644 --- a/terminus-ssh/src/components/editConnectionModal.component.ts +++ b/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 = {} defaultAlgorithms: Record = {} @@ -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) } diff --git a/terminus-ssh/src/components/sshTab.component.ts b/terminus-ssh/src/components/sshTab.component.ts index 3d95c1b4..cada3375 100644 --- a/terminus-ssh/src/components/sshTab.component.ts +++ b/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') diff --git a/terminus-ssh/src/services/ssh.service.ts b/terminus-ssh/src/services/ssh.service.ts index d59f8225..b771b6a8 100644 --- a/terminus-ssh/src/services/ssh.service.ts +++ b/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 { return this.output } + private output = new Subject() + + constructor (private command: string) { + super({ + allowHalfOpen: false, + }) + } + + async start (): Promise { + 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) {