diff --git a/tabby-ssh/src/api/index.ts b/tabby-ssh/src/api/index.ts index f7d1429e..2b6fa1b7 100644 --- a/tabby-ssh/src/api/index.ts +++ b/tabby-ssh/src/api/index.ts @@ -1,3 +1,4 @@ export * from './contextMenu' export * from './interfaces' export * from './importer' +export * from './proxyStream' diff --git a/tabby-ssh/src/api/interfaces.ts b/tabby-ssh/src/api/interfaces.ts index b6373377..e2e8ff36 100644 --- a/tabby-ssh/src/api/interfaces.ts +++ b/tabby-ssh/src/api/interfaces.ts @@ -32,6 +32,8 @@ export interface SSHProfileOptions extends LoginScriptsOptions { forwardedPorts?: ForwardedPortConfig[] socksProxyHost?: string socksProxyPort?: number + httpProxyHost?: string + httpProxyPort?: number reuseSession?: boolean } diff --git a/tabby-ssh/src/api/proxyStream.ts b/tabby-ssh/src/api/proxyStream.ts new file mode 100644 index 00000000..4ffa520d --- /dev/null +++ b/tabby-ssh/src/api/proxyStream.ts @@ -0,0 +1,61 @@ +import { Observable, Subject } from 'rxjs' +import { Duplex } from 'stream' + +export class SSHProxyStreamSocket extends Duplex { + constructor (private parent: SSHProxyStream) { + super({ + allowHalfOpen: false, + }) + } + + _read (size: number): void { + this.parent.requestData(size) + } + + _write (chunk: Buffer, _encoding: string, callback: (error?: Error | null) => void): void { + this.parent.consumeInput(chunk).then(() => callback(null), e => callback(e)) + } + + _destroy (error: Error|null, callback: (error: Error|null) => void): void { + this.parent.handleStopRequest(error).then(() => callback(null), e => callback(e)) + } +} + +export abstract class SSHProxyStream { + get message$ (): Observable { return this.message } + get destroyed$ (): Observable { return this.destroyed } + get socket (): SSHProxyStreamSocket|null { return this._socket } + private message = new Subject() + private destroyed = new Subject() + private _socket: SSHProxyStreamSocket|null = null + + async start (): Promise { + if (!this._socket) { + this._socket = new SSHProxyStreamSocket(this) + } + return this._socket + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + abstract requestData (size: number): void + + abstract consumeInput (data: Buffer): Promise + + protected emitMessage (message: string): void { + this.message.next(message) + } + + protected emitOutput (data: Buffer): void { + this._socket?.push(data) + } + + async handleStopRequest (error: Error|null): Promise { + this.destroyed.next(error) + this.destroyed.complete() + this.message.complete() + } + + stop (error?: Error): void { + this._socket?.destroy(error) + } +} diff --git a/tabby-ssh/src/components/sshProfileSettings.component.pug b/tabby-ssh/src/components/sshProfileSettings.component.pug index a3ccd2a4..57aa248a 100644 --- a/tabby-ssh/src/components/sshProfileSettings.component.pug +++ b/tabby-ssh/src/components/sshProfileSettings.component.pug @@ -29,6 +29,11 @@ ul.nav-tabs(ngbNav, #nav='ngbNav') ) div(translate) SOCKS proxy .text-muted(translate) Connect through a proxy server + button.dropdown-item( + (click)='connectionMode = "httpProxy"', + ) + div(translate) HTTP proxy + .text-muted(translate) Using CONNECT method .form-group.w-100(*ngIf='connectionMode === "proxyCommand"') label(translate) Proxy command @@ -75,6 +80,22 @@ ul.nav-tabs(ngbNav, #nav='ngbNav') [(ngModel)]='profile.options.socksProxyPort', ) + .d-flex.w-100(*ngIf='connectionMode === "httpProxy"') + .form-group.w-100.mr-2 + label(translate) HTTP proxy host + input.form-control( + type='text', + [(ngModel)]='profile.options.httpProxyHost', + ) + + .form-group + label(translate) HTTP proxy port + input.form-control( + type='number', + placeholder='5000', + [(ngModel)]='profile.options.httpProxyPort', + ) + .form-group label(translate) Username input.form-control( diff --git a/tabby-ssh/src/components/sshProfileSettings.component.ts b/tabby-ssh/src/components/sshProfileSettings.component.ts index 7476a4f2..8100a00f 100644 --- a/tabby-ssh/src/components/sshProfileSettings.component.ts +++ b/tabby-ssh/src/components/sshProfileSettings.component.ts @@ -17,7 +17,7 @@ export class SSHProfileSettingsComponent { profile: SSHProfile hasSavedPassword: boolean - connectionMode: 'direct'|'proxyCommand'|'jumpHost'|'socksProxy' = 'direct' + connectionMode: 'direct'|'proxyCommand'|'jumpHost'|'socksProxy'|'httpProxy' = 'direct' supportedAlgorithms = supportedAlgorithms algorithms: Record> = {} @@ -50,6 +50,8 @@ export class SSHProfileSettingsComponent { this.connectionMode = 'jumpHost' } else if (this.profile.options.socksProxyHost) { this.connectionMode = 'socksProxy' + } else if (this.profile.options.httpProxyHost) { + this.connectionMode = 'httpProxy' } if (this.profile.options.user) { @@ -109,6 +111,10 @@ export class SSHProfileSettingsComponent { this.profile.options.socksProxyHost = undefined this.profile.options.socksProxyPort = undefined } + if (this.connectionMode !== 'httpProxy') { + this.profile.options.httpProxyHost = undefined + this.profile.options.httpProxyPort = undefined + } this.loginScriptsSettings?.save() } diff --git a/tabby-ssh/src/profiles.ts b/tabby-ssh/src/profiles.ts index f43bbca2..014cdbb6 100644 --- a/tabby-ssh/src/profiles.ts +++ b/tabby-ssh/src/profiles.ts @@ -40,6 +40,8 @@ export class SSHProfilesService extends ProfileProvider { scripts: [], socksProxyHost: null, socksProxyPort: null, + httpProxyHost: null, + httpProxyPort: null, reuseSession: true, }, } diff --git a/tabby-ssh/src/services/ssh.service.ts b/tabby-ssh/src/services/ssh.service.ts index ead5dc02..01cba789 100644 --- a/tabby-ssh/src/services/ssh.service.ts +++ b/tabby-ssh/src/services/ssh.service.ts @@ -1,13 +1,13 @@ import * as shellQuote from 'shell-quote' +import * as net from 'net' import socksv5 from '@luminati-io/socksv5' import { Duplex } from 'stream' import { Injectable } from '@angular/core' import { spawn } from 'child_process' import { ChildProcess } from 'node:child_process' -import { Subject, Observable } from 'rxjs' import { ConfigService, HostAppService, Platform, PlatformService } from 'tabby-core' import { SSHSession } from '../session/ssh' -import { SSHProfile } from '../api' +import { SSHProfile, SSHProxyStream, SSHProxyStreamSocket } from '../api' import { PasswordStorageService } from './passwordStorage.service' @Injectable({ providedIn: 'root' }) @@ -53,17 +53,63 @@ export class SSHService { } } -export class SocksProxyStream extends Duplex { +export class ProxyCommandStream extends SSHProxyStream { + private process: ChildProcess|null + + constructor (private command: string) { + super() + } + + async start (): Promise { + const argv = shellQuote.parse(this.command) + this.process = spawn(argv[0], argv.slice(1), { + windowsHide: true, + stdio: ['pipe', 'pipe', 'pipe'], + }) + this.process.on('error', error => { + this.stop(new Error(`Proxy command has failed to start: ${error.message}`)) + }) + this.process.on('exit', code => { + this.stop(new Error(`Proxy command has exited with code ${code}`)) + }) + this.process.stdout?.on('data', data => { + this.emitOutput(data) + }) + this.process.stdout?.on('error', (err) => { + this.stop(err) + }) + this.process.stderr?.on('data', data => { + this.emitMessage(data.toString()) + }) + return super.start() + } + + requestData (size: number): void { + this.process?.stdout?.read(size) + } + + async consumeInput (data: Buffer): Promise { + const process = this.process + if (process) { + await new Promise(resolve => process.stdin?.write(data, resolve)) + } + } + + async stop (error?: Error): Promise { + this.process?.kill() + super.stop(error) + } +} + +export class SocksProxyStream extends SSHProxyStream { private client: Duplex|null private header: Buffer|null constructor (private profile: SSHProfile) { - super({ - allowHalfOpen: false, - }) + super() } - async start (): Promise { + async start (): Promise { this.client = await new Promise((resolve, reject) => { const connector = socksv5.connect({ host: this.profile.options.host, @@ -75,87 +121,101 @@ export class SocksProxyStream extends Duplex { resolve(s) this.header = s.read() if (this.header) { - this.push(this.header) + this.emitOutput(this.header) } }) connector.on('error', (err) => { reject(err) - this.destroy(err) + this.stop(new Error(`SOCKS connection failed: ${err.message}`)) }) }) this.client?.on('data', data => { if (!this.header || data !== this.header) { // socksv5 doesn't reliably emit the first data event - this.push(data) + this.emitOutput(data) this.header = null } }) - this.client?.on('close', (err) => { - this.destroy(err) + this.client?.on('close', error => { + this.stop(error) }) + + return super.start() } - _read (size: number): void { + requestData (size: number): void { this.client?.read(size) } - _write (chunk: Buffer, _encoding: string, callback: (error?: Error | null) => void): void { - this.client?.write(chunk, callback) + async consumeInput (data: Buffer): Promise { + return new Promise((resolve, reject) => { + this.client?.write(data, undefined, err => err ? reject(err) : resolve()) + }) } - _destroy (error: Error|null, callback: (error: Error|null) => void): void { + async stop (error?: Error): Promise { this.client?.destroy() - callback(error) + super.stop(error) } } +export class HTTPProxyStream extends SSHProxyStream { + private client: Duplex|null + private connected = false -export class ProxyCommandStream extends Duplex { - private process: ChildProcess + constructor (private profile: SSHProfile) { + super() + } - get output$ (): Observable { return this.output } - private output = new Subject() + async start (): Promise { + this.client = await new Promise((resolve, reject) => { + const connector = net.createConnection({ + host: this.profile.options.httpProxyHost!, + port: this.profile.options.httpProxyPort!, + }, () => resolve(connector)) + connector.on('error', error => { + reject(error) + this.stop(new Error(`Proxy connection failed: ${error.message}`)) + }) + }) + this.client?.write(Buffer.from(`CONNECT ${this.profile.options.host}:${this.profile.options.port} HTTP/1.1\r\n\r\n`)) + this.client?.on('data', (data: Buffer) => { + if (this.connected) { + this.emitOutput(data) + } else { + if (data.slice(0, 5).equals(Buffer.from('HTTP/'))) { + const idx = data.indexOf('\n\n') + const headers = data.slice(0, idx).toString() + const code = parseInt(headers.split(' ')[1]) + if (code >= 200 && code < 300) { + this.emitMessage('Connected') + this.emitOutput(data.slice(idx + 2)) + this.connected = true + } else { + this.stop(new Error(`Connection failed, code ${code}`)) + } + } + } + }) + this.client?.on('close', error => { + this.stop(error) + }) - constructor (private command: string) { - super({ - allowHalfOpen: false, + return super.start() + } + + requestData (size: number): void { + this.client?.read(size) + } + + async consumeInput (data: Buffer): Promise { + return new Promise((resolve, reject) => { + this.client?.write(data, undefined, err => err ? reject(err) : resolve()) }) } - async start (): Promise { - const argv = shellQuote.parse(this.command) - this.process = spawn(argv[0], argv.slice(1), { - windowsHide: true, - stdio: ['pipe', 'pipe', 'ignore'], - }) - this.process.on('error', error => { - this.destroy(new Error(`Proxy command has failed to start: ${error.message}`)) - }) - 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 { - this.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) + async stop (error?: Error): Promise { + this.client?.destroy() + super.stop(error) } } diff --git a/tabby-ssh/src/session/ssh.ts b/tabby-ssh/src/session/ssh.ts index 189254de..33789177 100644 --- a/tabby-ssh/src/session/ssh.ts +++ b/tabby-ssh/src/session/ssh.ts @@ -12,12 +12,12 @@ import { Socket } from 'net' import { Client, ClientChannel, SFTPWrapper } from 'ssh2' import { Subject, Observable } from 'rxjs' import { HostKeyPromptModalComponent } from '../components/hostKeyPromptModal.component' -import { 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 { ALGORITHM_BLACKLIST, SSHAlgorithmType, PortForwardType, SSHProfile } from '../api' +import { ALGORITHM_BLACKLIST, SSHAlgorithmType, PortForwardType, SSHProfile, SSHProxyStream } from '../api' import { ForwardedPort } from './forwards' import { X11Socket } from './x11' @@ -62,8 +62,7 @@ export class SSHSession { sftp?: SFTPWrapper forwardedPorts: ForwardedPort[] = [] jumpStream: any - proxyCommandStream: ProxyCommandStream|null = null - socksProxyStream: SocksProxyStream|null = null + proxyCommandStream: SSHProxyStream|null = null savedPassword?: string get serviceMessage$ (): Observable { return this.serviceMessage } get keyboardInteractivePrompt$ (): Observable { return this.keyboardInteractivePrompt } @@ -264,20 +263,26 @@ export class SSHSession { try { if (this.profile.options.socksProxyHost) { this.emitServiceMessage(colors.bgBlue.black(' Proxy ') + ` Using ${this.profile.options.socksProxyHost}:${this.profile.options.socksProxyPort}`) - this.socksProxyStream = new SocksProxyStream(this.profile) - await this.socksProxyStream.start() + 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) - - this.proxyCommandStream.on('error', err => { - this.emitServiceMessage(colors.bgRed.black(' X ') + ` ${err.message}`) - this.destroy() + } + if (this.proxyCommandStream) { + this.proxyCommandStream.destroyed$.subscribe(err => { + if (err) { + this.emitServiceMessage(colors.bgRed.black(' X ') + ` ${err.message}`) + this.destroy() + } }) - this.proxyCommandStream.output$.subscribe((message: string) => { - this.emitServiceMessage(colors.bgBlue.black(' Proxy command ') + ' ' + message.trim()) + this.proxyCommandStream.message$.subscribe(message => { + this.emitServiceMessage(colors.bgBlue.black(' Proxy ') + ' ' + message.trim()) }) await this.proxyCommandStream.start() @@ -298,7 +303,7 @@ export class SSHSession { ssh.connect({ host: this.profile.options.host.trim(), port: this.profile.options.port ?? 22, - sock: this.proxyCommandStream ?? this.jumpStream ?? this.socksProxyStream, + sock: this.proxyCommandStream?.socket ?? this.jumpStream, username: this.authUsername ?? undefined, tryKeyboard: true, agent: this.agentPath, @@ -578,7 +583,7 @@ export class SSHSession { this.willDestroy.next() this.willDestroy.complete() this.serviceMessage.complete() - this.proxyCommandStream?.destroy() + this.proxyCommandStream?.stop() this.ssh.end() } diff --git a/tabby-terminal/src/components/terminalSettingsTab.component.pug b/tabby-terminal/src/components/terminalSettingsTab.component.pug index aa624969..765d2385 100644 --- a/tabby-terminal/src/components/terminalSettingsTab.component.pug +++ b/tabby-terminal/src/components/terminalSettingsTab.component.pug @@ -24,7 +24,7 @@ div ) div.mt-4 - h3 Keyboard + h3(translate) Keyboard .form-line .header @@ -48,7 +48,7 @@ div.mt-4 ) div.mt-4 - h3 Mouse + h3(translate) Mouse .form-line .header @@ -146,7 +146,7 @@ div.mt-4 ) div.mt-4 - h3 Sound + h3(translate) Sound .form-line .header @@ -204,7 +204,7 @@ div.mt-4 ) div.mt-4(*ngIf='hostApp.platform === Platform.Windows') - h3 Windows + h3(translate) Windows .form-line .header