From 9856249c88ddb61eb3763f4a73c343a1565d50c6 Mon Sep 17 00:00:00 2001 From: Eugene Pankov Date: Sun, 28 Nov 2021 18:21:26 +0100 Subject: [PATCH] fixed #5000 - native socksv5 connection support in ssh and connection mode UI overhaul --- tabby-ssh/package.json | 2 +- tabby-ssh/src/api/interfaces.ts | 2 + .../sshProfileSettings.component.pug | 83 +++++++++++++------ .../sshProfileSettings.component.ts | 32 ++++++- tabby-ssh/src/profiles.ts | 2 + tabby-ssh/src/services/ssh.service.ts | 56 +++++++++++++ tabby-ssh/src/session/forwards.ts | 2 +- tabby-ssh/src/session/ssh.ts | 14 ++-- tabby-ssh/yarn.lock | 14 ++-- .../src/api/baseTerminalTab.component.ts | 2 + web/polyfills.ts | 2 +- webpack.plugin.config.js | 2 +- 12 files changed, 169 insertions(+), 44 deletions(-) diff --git a/tabby-ssh/package.json b/tabby-ssh/package.json index a36569b1..c6e5ad18 100644 --- a/tabby-ssh/package.json +++ b/tabby-ssh/package.json @@ -29,7 +29,7 @@ }, "dependencies": { "run-script-os": "^1.1.3", - "socksv5": "^0.0.6" + "@luminati-io/socksv5": "^0.0.7" }, "peerDependencies": { "@angular/animations": "^9.1.9", diff --git a/tabby-ssh/src/api/interfaces.ts b/tabby-ssh/src/api/interfaces.ts index fd70e91f..e449561c 100644 --- a/tabby-ssh/src/api/interfaces.ts +++ b/tabby-ssh/src/api/interfaces.ts @@ -30,6 +30,8 @@ export interface SSHProfileOptions extends LoginScriptsOptions { algorithms?: Record proxyCommand?: string forwardedPorts?: ForwardedPortConfig[] + socksProxyHost?: string + socksProxyPort?: number } export enum PortForwardType { diff --git a/tabby-ssh/src/components/sshProfileSettings.component.pug b/tabby-ssh/src/components/sshProfileSettings.component.pug index b5b2fc08..3baceef2 100644 --- a/tabby-ssh/src/components/sshProfileSettings.component.pug +++ b/tabby-ssh/src/components/sshProfileSettings.component.pug @@ -2,15 +2,48 @@ ul.nav-tabs(ngbNav, #nav='ngbNav') li(ngbNavItem) a(ngbNavLink) General ng-template(ngbNavContent) - .d-flex.w-100(*ngIf='!useProxyCommand') - .form-group.w-100.mr-4 + .d-flex.w-100.mt-3 + .form-group.mr-2( + ngbDropdown + ) + label Connection + button.btn.btn-secondary.d-block(ngbDropdownToggle) {{getConnectionDropdownTitle()}} + div(ngbDropdownMenu) + button.dropdown-item( + (click)='connectionMode = "direct"', + ) Direct + button.dropdown-item( + *ngIf='hostApp.platform !== Platform.Web', + (click)='connectionMode = "proxyCommand"', + ) + div Proxy command + .text-muted Command's stdin/stdout is used instead of a network connection + button.dropdown-item( + (click)='connectionMode = "jumpHost"', + ) + div Jump host + .text-muted Connect to a different host first and use it as a proxy + button.dropdown-item( + (click)='connectionMode = "socksProxy"', + ) + div SOCKS proxy + .text-muted Connect through a proxy server + + .form-group.w-100(*ngIf='connectionMode === "proxyCommand"') + label Proxy command + input.form-control( + type='text', + [(ngModel)]='profile.options.proxyCommand', + ) + + .form-group.w-100.mr-2(*ngIf='connectionMode !== "proxyCommand"') label Host input.form-control( type='text', [(ngModel)]='profile.options.host', ) - .form-group + .form-group(*ngIf='connectionMode !== "proxyCommand"') label Port input.form-control( type='number', @@ -18,8 +51,28 @@ ul.nav-tabs(ngbNav, #nav='ngbNav') [(ngModel)]='profile.options.port', ) - .alert.alert-info(*ngIf='useProxyCommand') - .mr-auto Using a proxy command instead of a network connection + .form-group(*ngIf='connectionMode === "jumpHost"') + label Jump host + select.form-control([(ngModel)]='profile.options.jumpHost') + option([ngValue]='null') Select + option([ngValue]='x.id', *ngFor='let x of jumpHosts') {{x.name}} + + + .d-flex.w-100(*ngIf='connectionMode === "socksProxy"') + .form-group.w-100.mr-2 + label SOCKS proxy host + input.form-control( + type='text', + [(ngModel)]='profile.options.socksProxyHost', + ) + + .form-group + label SOCKS proxy port + input.form-control( + type='number', + placeholder='5000', + [(ngModel)]='profile.options.socksProxyPort', + ) .form-group label Username @@ -93,13 +146,6 @@ ul.nav-tabs(ngbNav, #nav='ngbNav') li(ngbNavItem) a(ngbNavLink) Advanced ng-template(ngbNavContent) - .form-line(*ngIf='!useProxyCommand') - .header - .title Jump host - select.form-control([(ngModel)]='profile.options.jumpHost') - option(value='') None - option([ngValue]='x.id', *ngFor='let x of jumpHosts') {{x.name}} - .form-line(ng:if='hostApp.platform !== Platform.Web') .header .title X11 forwarding @@ -143,19 +189,6 @@ ul.nav-tabs(ngbNav, #nav='ngbNav') [(ngModel)]='profile.options.readyTimeout', ) - .form-line(*ngIf='!profile.options.jumpHost && hostApp.platform !== Platform.Web') - .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 && !profile.options.jumpHost') - label Proxy command - input.form-control( - type='text', - [(ngModel)]='profile.options.proxyCommand', - ) - li(ngbNavItem) a(ngbNavLink) Ciphers ng-template(ngbNavContent) diff --git a/tabby-ssh/src/components/sshProfileSettings.component.ts b/tabby-ssh/src/components/sshProfileSettings.component.ts index 350c4cc4..7476a4f2 100644 --- a/tabby-ssh/src/components/sshProfileSettings.component.ts +++ b/tabby-ssh/src/components/sshProfileSettings.component.ts @@ -16,7 +16,8 @@ export class SSHProfileSettingsComponent { Platform = Platform profile: SSHProfile hasSavedPassword: boolean - useProxyCommand: boolean + + connectionMode: 'direct'|'proxyCommand'|'jumpHost'|'socksProxy' = 'direct' supportedAlgorithms = supportedAlgorithms algorithms: Record> = {} @@ -43,7 +44,14 @@ export class SSHProfileSettingsComponent { this.profile.options.auth = this.profile.options.auth ?? null this.profile.options.privateKeys ??= [] - this.useProxyCommand = !!this.profile.options.proxyCommand + if (this.profile.options.proxyCommand) { + this.connectionMode = 'proxyCommand' + } else if (this.profile.options.jumpHost) { + this.connectionMode = 'jumpHost' + } else if (this.profile.options.socksProxyHost) { + this.connectionMode = 'socksProxy' + } + if (this.profile.options.user) { try { this.hasSavedPassword = !!await this.passwordStorage.loadPassword(this.profile) @@ -90,9 +98,18 @@ export class SSHProfileSettingsComponent { .map(([key, _]) => key) this.profile.options.algorithms![k].sort() } - if (!this.useProxyCommand) { + + if (this.connectionMode !== 'jumpHost') { + this.profile.options.jumpHost = undefined + } + if (this.connectionMode !== 'proxyCommand') { this.profile.options.proxyCommand = undefined } + if (this.connectionMode !== 'socksProxy') { + this.profile.options.socksProxyHost = undefined + this.profile.options.socksProxyPort = undefined + } + this.loginScriptsSettings?.save() } @@ -104,4 +121,13 @@ export class SSHProfileSettingsComponent { onForwardRemoved (fw: ForwardedPortConfig) { this.profile.options.forwardedPorts = this.profile.options.forwardedPorts?.filter(x => x !== fw) } + + getConnectionDropdownTitle () { + return { + direct: 'Direct', + proxyCommand: 'Proxy command', + jumpHost: 'Jump host', + socksProxy: 'SOCKS proxy', + }[this.connectionMode] + } } diff --git a/tabby-ssh/src/profiles.ts b/tabby-ssh/src/profiles.ts index f412f60a..32a20c4c 100644 --- a/tabby-ssh/src/profiles.ts +++ b/tabby-ssh/src/profiles.ts @@ -37,6 +37,8 @@ export class SSHProfilesService extends ProfileProvider { proxyCommand: null, forwardedPorts: [], scripts: [], + socksProxyHost: null, + socksProxyPort: null, }, } diff --git a/tabby-ssh/src/services/ssh.service.ts b/tabby-ssh/src/services/ssh.service.ts index 232cf4eb..c09fa36e 100644 --- a/tabby-ssh/src/services/ssh.service.ts +++ b/tabby-ssh/src/services/ssh.service.ts @@ -1,4 +1,5 @@ import * as shellQuote from 'shell-quote' +import socksv5 from '@luminati-io/socksv5' import { Duplex } from 'stream' import { Injectable } from '@angular/core' import { spawn } from 'child_process' @@ -52,6 +53,61 @@ export class SSHService { } } +export class SocksProxyStream extends Duplex { + private client: Duplex|null + private header: Buffer|null + + constructor (private profile: SSHProfile) { + super({ + allowHalfOpen: false, + }) + } + + async start (): Promise { + this.client = await new Promise((resolve, reject) => { + const connector = socksv5.connect({ + host: this.profile.options.host, + port: this.profile.options.port, + proxyHost: this.profile.options.socksProxyHost ?? '127.0.0.1', + proxyPort: this.profile.options.socksProxyPort ?? 5000, + auths: [socksv5.auth.None()], + }, s => { + resolve(s) + this.header = s.read() + this.push(this.header) + }) + connector.on('error', (err) => { + reject(err) + this.destroy(err) + }) + }) + this.client?.on('data', data => { + if (data !== this.header) { + // socksv5 doesn't reliably emit the first data event + this.push(data) + this.header = null + } + }) + this.client?.on('close', (err) => { + this.destroy(err) + }) + } + + _read (size: number): void { + this.client?.read(size) + } + + _write (chunk: Buffer, _encoding: string, callback: (error?: Error | null) => void): void { + this.client?.write(chunk, callback) + } + + _destroy (error: Error|null, callback: (error: Error|null) => void): void { + this.client?.destroy() + callback(error) + } +} + + export class ProxyCommandStream extends Duplex { private process: ChildProcess diff --git a/tabby-ssh/src/session/forwards.ts b/tabby-ssh/src/session/forwards.ts index 1422c94c..8220e3a6 100644 --- a/tabby-ssh/src/session/forwards.ts +++ b/tabby-ssh/src/session/forwards.ts @@ -1,4 +1,4 @@ -import socksv5 from 'socksv5' +import socksv5 from '@luminati-io/socksv5' import { Server, Socket, createServer } from 'net' import { ForwardedPortConfig, PortForwardType } from '../api' diff --git a/tabby-ssh/src/session/ssh.ts b/tabby-ssh/src/session/ssh.ts index 16f53b57..292268b4 100644 --- a/tabby-ssh/src/session/ssh.ts +++ b/tabby-ssh/src/session/ssh.ts @@ -12,7 +12,7 @@ import { BaseSession } from 'tabby-terminal' import { Socket } from 'net' import { Client, ClientChannel, SFTPWrapper } from 'ssh2' import { Subject, Observable } from 'rxjs' -import { ProxyCommandStream } from '../services/ssh.service' +import { ProxyCommandStream, SocksProxyStream } from '../services/ssh.service' import { PasswordStorageService } from '../services/passwordStorage.service' import { promisify } from 'util' import { SFTPSession } from './sftp' @@ -50,6 +50,7 @@ export class SSHSession extends BaseSession { forwardedPorts: ForwardedPort[] = [] jumpStream: any proxyCommandStream: ProxyCommandStream|null = null + socksProxyStream: SocksProxyStream|null = null savedPassword?: string get serviceMessage$ (): Observable { return this.serviceMessage } get keyboardInteractivePrompt$ (): Observable { return this.keyboardInteractivePrompt } @@ -231,6 +232,11 @@ export class SSHSession extends BaseSession { }) 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() + } 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) @@ -262,7 +268,7 @@ export class SSHSession extends BaseSession { ssh.connect({ host: this.profile.options.host.trim(), port: this.profile.options.port ?? 22, - sock: this.proxyCommandStream ?? this.jumpStream, + sock: this.proxyCommandStream ?? this.jumpStream ?? this.socksProxyStream, username: this.authUsername ?? undefined, tryKeyboard: true, agent: this.agentPath, @@ -279,9 +285,7 @@ export class SSHSession extends BaseSession { algorithms, authHandler: (methodsLeft, partialSuccess, callback) => { this.zone.run(async () => { - const a = await this.handleAuth(methodsLeft) - console.warn(a) - callback(a) + callback(await this.handleAuth(methodsLeft)) }) }, }) diff --git a/tabby-ssh/yarn.lock b/tabby-ssh/yarn.lock index 5adce8d8..f9fab488 100644 --- a/tabby-ssh/yarn.lock +++ b/tabby-ssh/yarn.lock @@ -2,6 +2,13 @@ # yarn lockfile v1 +"@luminati-io/socksv5@^0.0.7": + version "0.0.7" + resolved "https://registry.yarnpkg.com/@luminati-io/socksv5/-/socksv5-0.0.7.tgz#87414177d473c97aaefa907a3fe454d62d2fceca" + integrity sha512-paEEbcstjMZb2SvFHsSUOzimkx80/pFmMG5T3XR6Keb4NeBfYWEAtlVeiF39OrHRf9AjpNxahhwzdCAlLXZ4Hw== + dependencies: + ipv6 "*" + "@types/node@*", "@types/node@16.0.1": version "16.0.1" resolved "https://registry.yarnpkg.com/@types/node/-/node-16.0.1.tgz#70cedfda26af7a2ca073fdcc9beb2fff4aa693f8" @@ -215,13 +222,6 @@ safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== -socksv5@^0.0.6: - version "0.0.6" - resolved "https://registry.yarnpkg.com/socksv5/-/socksv5-0.0.6.tgz#1327235ff7e8de21ac434a0a579dc69c3f071061" - integrity sha1-EycjX/fo3iGsQ0oKV53GnD8HEGE= - dependencies: - ipv6 "*" - sprintf@0.1.x: version "0.1.5" resolved "https://registry.yarnpkg.com/sprintf/-/sprintf-0.1.5.tgz#8f83e39a9317c1a502cb7db8050e51c679f6edcf" diff --git a/tabby-terminal/src/api/baseTerminalTab.component.ts b/tabby-terminal/src/api/baseTerminalTab.component.ts index 856e0fd3..359a48cf 100644 --- a/tabby-terminal/src/api/baseTerminalTab.component.ts +++ b/tabby-terminal/src/api/baseTerminalTab.component.ts @@ -298,6 +298,7 @@ export class BaseTerminalTabComponent extends BaseTabComponent implements OnInit this.frontend.resize$.pipe(first()).subscribe(async ({ columns, rows }) => { this.size = { columns, rows } this.frontendReady.next() + this.frontendReady.complete() this.config.enabledServices(this.decorators).forEach(decorator => { try { @@ -554,6 +555,7 @@ export class BaseTerminalTabComponent extends BaseTabComponent implements OnInit } }) this.output.complete() + this.frontendReady.complete() super.destroy() if (this.session?.open) { diff --git a/web/polyfills.ts b/web/polyfills.ts index cc059900..53de8b04 100644 --- a/web/polyfills.ts +++ b/web/polyfills.ts @@ -84,7 +84,7 @@ Tabby.registerModule('crypto', { }, }) Tabby.registerMock('dns', {}) -Tabby.registerMock('socksv5', {}) +Tabby.registerMock('@luminati-io/socksv5', {}) Tabby.registerMock('util', require('util/')) Tabby.registerMock('keytar', { getPassword: () => null, diff --git a/webpack.plugin.config.js b/webpack.plugin.config.js index 69ce91fa..78ca1b8f 100644 --- a/webpack.plugin.config.js +++ b/webpack.plugin.config.js @@ -109,7 +109,7 @@ module.exports = options => { 'os', 'path', 'readline', - 'socksv5', + '@luminati-io/socksv5', 'stream', 'windows-native-registry', 'windows-process-tree',