SSH proxy support, unified proxy streams interface - fixed #5698

This commit is contained in:
Eugene Pankov 2022-02-16 22:14:06 +01:00
parent 3eb4bd53a9
commit 81e1757ae9
No known key found for this signature in database
GPG Key ID: 5896FCBBDD1CF4F4
9 changed files with 236 additions and 78 deletions

View File

@ -1,3 +1,4 @@
export * from './contextMenu' export * from './contextMenu'
export * from './interfaces' export * from './interfaces'
export * from './importer' export * from './importer'
export * from './proxyStream'

View File

@ -32,6 +32,8 @@ export interface SSHProfileOptions extends LoginScriptsOptions {
forwardedPorts?: ForwardedPortConfig[] forwardedPorts?: ForwardedPortConfig[]
socksProxyHost?: string socksProxyHost?: string
socksProxyPort?: number socksProxyPort?: number
httpProxyHost?: string
httpProxyPort?: number
reuseSession?: boolean reuseSession?: boolean
} }

View File

@ -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<string> { return this.message }
get destroyed$ (): Observable<Error|null> { return this.destroyed }
get socket (): SSHProxyStreamSocket|null { return this._socket }
private message = new Subject<string>()
private destroyed = new Subject<Error|null>()
private _socket: SSHProxyStreamSocket|null = null
async start (): Promise<SSHProxyStreamSocket> {
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<void>
protected emitMessage (message: string): void {
this.message.next(message)
}
protected emitOutput (data: Buffer): void {
this._socket?.push(data)
}
async handleStopRequest (error: Error|null): Promise<void> {
this.destroyed.next(error)
this.destroyed.complete()
this.message.complete()
}
stop (error?: Error): void {
this._socket?.destroy(error)
}
}

View File

@ -29,6 +29,11 @@ ul.nav-tabs(ngbNav, #nav='ngbNav')
) )
div(translate) SOCKS proxy div(translate) SOCKS proxy
.text-muted(translate) Connect through a proxy server .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"') .form-group.w-100(*ngIf='connectionMode === "proxyCommand"')
label(translate) Proxy command label(translate) Proxy command
@ -75,6 +80,22 @@ ul.nav-tabs(ngbNav, #nav='ngbNav')
[(ngModel)]='profile.options.socksProxyPort', [(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 .form-group
label(translate) Username label(translate) Username
input.form-control( input.form-control(

View File

@ -17,7 +17,7 @@ export class SSHProfileSettingsComponent {
profile: SSHProfile profile: SSHProfile
hasSavedPassword: boolean hasSavedPassword: boolean
connectionMode: 'direct'|'proxyCommand'|'jumpHost'|'socksProxy' = 'direct' connectionMode: 'direct'|'proxyCommand'|'jumpHost'|'socksProxy'|'httpProxy' = 'direct'
supportedAlgorithms = supportedAlgorithms supportedAlgorithms = supportedAlgorithms
algorithms: Record<string, Record<string, boolean>> = {} algorithms: Record<string, Record<string, boolean>> = {}
@ -50,6 +50,8 @@ export class SSHProfileSettingsComponent {
this.connectionMode = 'jumpHost' this.connectionMode = 'jumpHost'
} else if (this.profile.options.socksProxyHost) { } else if (this.profile.options.socksProxyHost) {
this.connectionMode = 'socksProxy' this.connectionMode = 'socksProxy'
} else if (this.profile.options.httpProxyHost) {
this.connectionMode = 'httpProxy'
} }
if (this.profile.options.user) { if (this.profile.options.user) {
@ -109,6 +111,10 @@ export class SSHProfileSettingsComponent {
this.profile.options.socksProxyHost = undefined this.profile.options.socksProxyHost = undefined
this.profile.options.socksProxyPort = undefined this.profile.options.socksProxyPort = undefined
} }
if (this.connectionMode !== 'httpProxy') {
this.profile.options.httpProxyHost = undefined
this.profile.options.httpProxyPort = undefined
}
this.loginScriptsSettings?.save() this.loginScriptsSettings?.save()
} }

View File

@ -40,6 +40,8 @@ export class SSHProfilesService extends ProfileProvider<SSHProfile> {
scripts: [], scripts: [],
socksProxyHost: null, socksProxyHost: null,
socksProxyPort: null, socksProxyPort: null,
httpProxyHost: null,
httpProxyPort: null,
reuseSession: true, reuseSession: true,
}, },
} }

View File

@ -1,13 +1,13 @@
import * as shellQuote from 'shell-quote' import * as shellQuote from 'shell-quote'
import * as net from 'net'
import socksv5 from '@luminati-io/socksv5' import socksv5 from '@luminati-io/socksv5'
import { Duplex } from 'stream' import { Duplex } from 'stream'
import { Injectable } from '@angular/core' import { Injectable } from '@angular/core'
import { spawn } from 'child_process' import { spawn } from 'child_process'
import { ChildProcess } from 'node:child_process' import { ChildProcess } from 'node:child_process'
import { Subject, Observable } from 'rxjs'
import { ConfigService, HostAppService, Platform, PlatformService } from 'tabby-core' import { ConfigService, HostAppService, Platform, PlatformService } from 'tabby-core'
import { SSHSession } from '../session/ssh' import { SSHSession } from '../session/ssh'
import { SSHProfile } from '../api' import { SSHProfile, SSHProxyStream, SSHProxyStreamSocket } from '../api'
import { PasswordStorageService } from './passwordStorage.service' import { PasswordStorageService } from './passwordStorage.service'
@Injectable({ providedIn: 'root' }) @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<SSHProxyStreamSocket> {
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<void> {
const process = this.process
if (process) {
await new Promise(resolve => process.stdin?.write(data, resolve))
}
}
async stop (error?: Error): Promise<void> {
this.process?.kill()
super.stop(error)
}
}
export class SocksProxyStream extends SSHProxyStream {
private client: Duplex|null private client: Duplex|null
private header: Buffer|null private header: Buffer|null
constructor (private profile: SSHProfile) { constructor (private profile: SSHProfile) {
super({ super()
allowHalfOpen: false,
})
} }
async start (): Promise<void> { async start (): Promise<SSHProxyStreamSocket> {
this.client = await new Promise((resolve, reject) => { this.client = await new Promise((resolve, reject) => {
const connector = socksv5.connect({ const connector = socksv5.connect({
host: this.profile.options.host, host: this.profile.options.host,
@ -75,87 +121,101 @@ export class SocksProxyStream extends Duplex {
resolve(s) resolve(s)
this.header = s.read() this.header = s.read()
if (this.header) { if (this.header) {
this.push(this.header) this.emitOutput(this.header)
} }
}) })
connector.on('error', (err) => { connector.on('error', (err) => {
reject(err) reject(err)
this.destroy(err) this.stop(new Error(`SOCKS connection failed: ${err.message}`))
}) })
}) })
this.client?.on('data', data => { this.client?.on('data', data => {
if (!this.header || data !== this.header) { if (!this.header || data !== this.header) {
// socksv5 doesn't reliably emit the first data event // socksv5 doesn't reliably emit the first data event
this.push(data) this.emitOutput(data)
this.header = null this.header = null
} }
}) })
this.client?.on('close', (err) => { this.client?.on('close', error => {
this.destroy(err) this.stop(error)
}) })
return super.start()
} }
_read (size: number): void { requestData (size: number): void {
this.client?.read(size) this.client?.read(size)
} }
_write (chunk: Buffer, _encoding: string, callback: (error?: Error | null) => void): void { async consumeInput (data: Buffer): Promise<void> {
this.client?.write(chunk, callback) 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<void> {
this.client?.destroy() 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 { constructor (private profile: SSHProfile) {
private process: ChildProcess super()
}
get output$ (): Observable<string> { return this.output } async start (): Promise<SSHProxyStreamSocket> {
private output = new Subject<string>() 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) { return super.start()
super({ }
allowHalfOpen: false,
requestData (size: number): void {
this.client?.read(size)
}
async consumeInput (data: Buffer): Promise<void> {
return new Promise((resolve, reject) => {
this.client?.write(data, undefined, err => err ? reject(err) : resolve())
}) })
} }
async start (): Promise<void> { async stop (error?: Error): Promise<void> {
const argv = shellQuote.parse(this.command) this.client?.destroy()
this.process = spawn(argv[0], argv.slice(1), { super.stop(error)
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)
} }
} }

View File

@ -12,12 +12,12 @@ import { Socket } from 'net'
import { Client, ClientChannel, SFTPWrapper } from 'ssh2' import { Client, ClientChannel, SFTPWrapper } from 'ssh2'
import { Subject, Observable } from 'rxjs' import { Subject, Observable } from 'rxjs'
import { HostKeyPromptModalComponent } from '../components/hostKeyPromptModal.component' 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 { PasswordStorageService } from '../services/passwordStorage.service'
import { SSHKnownHostsService } from '../services/sshKnownHosts.service' import { SSHKnownHostsService } from '../services/sshKnownHosts.service'
import { promisify } from 'util' import { promisify } from 'util'
import { SFTPSession } from './sftp' 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 { ForwardedPort } from './forwards'
import { X11Socket } from './x11' import { X11Socket } from './x11'
@ -62,8 +62,7 @@ export class SSHSession {
sftp?: SFTPWrapper sftp?: SFTPWrapper
forwardedPorts: ForwardedPort[] = [] forwardedPorts: ForwardedPort[] = []
jumpStream: any jumpStream: any
proxyCommandStream: ProxyCommandStream|null = null proxyCommandStream: SSHProxyStream|null = null
socksProxyStream: SocksProxyStream|null = null
savedPassword?: string savedPassword?: string
get serviceMessage$ (): Observable<string> { return this.serviceMessage } get serviceMessage$ (): Observable<string> { return this.serviceMessage }
get keyboardInteractivePrompt$ (): Observable<KeyboardInteractivePrompt> { return this.keyboardInteractivePrompt } get keyboardInteractivePrompt$ (): Observable<KeyboardInteractivePrompt> { return this.keyboardInteractivePrompt }
@ -264,20 +263,26 @@ export class SSHSession {
try { try {
if (this.profile.options.socksProxyHost) { if (this.profile.options.socksProxyHost) {
this.emitServiceMessage(colors.bgBlue.black(' Proxy ') + ` Using ${this.profile.options.socksProxyHost}:${this.profile.options.socksProxyPort}`) this.emitServiceMessage(colors.bgBlue.black(' Proxy ') + ` Using ${this.profile.options.socksProxyHost}:${this.profile.options.socksProxyPort}`)
this.socksProxyStream = new SocksProxyStream(this.profile) this.proxyCommandStream = new SocksProxyStream(this.profile)
await this.socksProxyStream.start() }
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) { if (this.profile.options.proxyCommand) {
this.emitServiceMessage(colors.bgBlue.black(' Proxy command ') + ` Using ${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 = new ProxyCommandStream(this.profile.options.proxyCommand)
}
this.proxyCommandStream.on('error', err => { if (this.proxyCommandStream) {
this.emitServiceMessage(colors.bgRed.black(' X ') + ` ${err.message}`) this.proxyCommandStream.destroyed$.subscribe(err => {
this.destroy() if (err) {
this.emitServiceMessage(colors.bgRed.black(' X ') + ` ${err.message}`)
this.destroy()
}
}) })
this.proxyCommandStream.output$.subscribe((message: string) => { this.proxyCommandStream.message$.subscribe(message => {
this.emitServiceMessage(colors.bgBlue.black(' Proxy command ') + ' ' + message.trim()) this.emitServiceMessage(colors.bgBlue.black(' Proxy ') + ' ' + message.trim())
}) })
await this.proxyCommandStream.start() await this.proxyCommandStream.start()
@ -298,7 +303,7 @@ export class SSHSession {
ssh.connect({ ssh.connect({
host: this.profile.options.host.trim(), host: this.profile.options.host.trim(),
port: this.profile.options.port ?? 22, port: this.profile.options.port ?? 22,
sock: this.proxyCommandStream ?? this.jumpStream ?? this.socksProxyStream, sock: this.proxyCommandStream?.socket ?? this.jumpStream,
username: this.authUsername ?? undefined, username: this.authUsername ?? undefined,
tryKeyboard: true, tryKeyboard: true,
agent: this.agentPath, agent: this.agentPath,
@ -578,7 +583,7 @@ export class SSHSession {
this.willDestroy.next() this.willDestroy.next()
this.willDestroy.complete() this.willDestroy.complete()
this.serviceMessage.complete() this.serviceMessage.complete()
this.proxyCommandStream?.destroy() this.proxyCommandStream?.stop()
this.ssh.end() this.ssh.end()
} }

View File

@ -24,7 +24,7 @@ div
) )
div.mt-4 div.mt-4
h3 Keyboard h3(translate) Keyboard
.form-line .form-line
.header .header
@ -48,7 +48,7 @@ div.mt-4
) )
div.mt-4 div.mt-4
h3 Mouse h3(translate) Mouse
.form-line .form-line
.header .header
@ -146,7 +146,7 @@ div.mt-4
) )
div.mt-4 div.mt-4
h3 Sound h3(translate) Sound
.form-line .form-line
.header .header
@ -204,7 +204,7 @@ div.mt-4
) )
div.mt-4(*ngIf='hostApp.platform === Platform.Windows') div.mt-4(*ngIf='hostApp.platform === Platform.Windows')
h3 Windows h3(translate) Windows
.form-line .form-line
.header .header