From a78f3399fde411ed4bcd9d9473a7b77b87d05161 Mon Sep 17 00:00:00 2001 From: Eugene Pankov Date: Fri, 24 Dec 2021 18:34:48 +0100 Subject: [PATCH] SSH session multiplexing - fixes #4795 --- tabby-ssh/src/api/interfaces.ts | 1 + .../sshProfileSettings.component.pug | 6 ++ tabby-ssh/src/components/sshTab.component.ts | 98 ++++++++++--------- tabby-ssh/src/profiles.ts | 1 + .../src/services/sshMultiplexer.service.ts | 38 +++++++ tabby-ssh/src/session/shell.ts | 1 + tabby-ssh/src/session/ssh.ts | 6 +- 7 files changed, 100 insertions(+), 51 deletions(-) create mode 100644 tabby-ssh/src/services/sshMultiplexer.service.ts diff --git a/tabby-ssh/src/api/interfaces.ts b/tabby-ssh/src/api/interfaces.ts index 80b7d436..b6373377 100644 --- a/tabby-ssh/src/api/interfaces.ts +++ b/tabby-ssh/src/api/interfaces.ts @@ -32,6 +32,7 @@ export interface SSHProfileOptions extends LoginScriptsOptions { forwardedPorts?: ForwardedPortConfig[] socksProxyHost?: string socksProxyPort?: number + reuseSession?: boolean } export enum PortForwardType { diff --git a/tabby-ssh/src/components/sshProfileSettings.component.pug b/tabby-ssh/src/components/sshProfileSettings.component.pug index 3baceef2..099fb06d 100644 --- a/tabby-ssh/src/components/sshProfileSettings.component.pug +++ b/tabby-ssh/src/components/sshProfileSettings.component.pug @@ -162,6 +162,12 @@ ul.nav-tabs(ngbNav, #nav='ngbNav') .description Will prevent the SSH greeting from showing up toggle([(ngModel)]='profile.options.skipBanner') + .form-line + .header + .title Reuse session for multiple tabs + .description Multiplex multiple shells through the same connection + toggle([(ngModel)]='profile.options.reuseSession') + .form-line .header .title Keep Alive Interval (Milliseconds) diff --git a/tabby-ssh/src/components/sshTab.component.ts b/tabby-ssh/src/components/sshTab.component.ts index d81fb864..f6dc3333 100644 --- a/tabby-ssh/src/components/sshTab.component.ts +++ b/tabby-ssh/src/components/sshTab.component.ts @@ -9,6 +9,7 @@ import { KeyboardInteractivePrompt, SSHSession } from '../session/ssh' import { SSHPortForwardingModalComponent } from './sshPortForwardingModal.component' import { SSHProfile } from '../api' import { SSHShellSession } from '../session/shell' +import { SSHMultiplexerService } from '../services/sshMultiplexer.service' /** @hidden */ @Component({ @@ -26,7 +27,6 @@ export class SSHTabComponent extends BaseTerminalTabComponent { sftpPath = '/' enableToolbar = true activeKIPrompt: KeyboardInteractivePrompt|null = null - private sessionStack: SSHSession[] = [] private recentInputs = '' private reconnectOffered = false @@ -35,6 +35,7 @@ export class SSHTabComponent extends BaseTerminalTabComponent { public ssh: SSHService, private ngbModal: NgbModal, private profilesService: ProfilesService, + private sshMultiplexer: SSHMultiplexerService, ) { super(injector) this.sessionChanged$.subscribe(() => { @@ -82,50 +83,45 @@ export class SSHTabComponent extends BaseTerminalTabComponent { super.ngOnInit() } - async setupOneSession (session: SSHSession, interactive: boolean): Promise { - if (session.profile.options.jumpHost) { - const jumpConnection: PartialProfile|null = this.config.store.profiles.find(x => x.id === session.profile.options.jumpHost) + async setupOneSession (injector: Injector, profile: SSHProfile): Promise { + let session = this.sshMultiplexer.getSession(profile) + if (!session || !profile.options.reuseSession) { + session = new SSHSession(injector, profile) - if (!jumpConnection) { - throw new Error(`${session.profile.options.host}: jump host "${session.profile.options.jumpHost}" not found in your config`) - } + if (profile.options.jumpHost) { + const jumpConnection: PartialProfile|null = this.config.store.profiles.find(x => x.id === profile.options.jumpHost) - const jumpSession = new SSHSession( - this.injector, - this.profilesService.getConfigProxyForProfile(jumpConnection) - ) - - await this.setupOneSession(jumpSession, false) - - this.attachSessionHandler(jumpSession.willDestroy$, () => { - if (session.open) { - session.destroy() + if (!jumpConnection) { + throw new Error(`${profile.options.host}: jump host "${profile.options.jumpHost}" not found in your config`) } - }) - session.jumpStream = await new Promise((resolve, reject) => jumpSession.ssh.forwardOut( - '127.0.0.1', 0, session.profile.options.host, session.profile.options.port ?? 22, - (err, stream) => { - if (err) { - jumpSession.emitServiceMessage(colors.bgRed.black(' X ') + ` Could not set up port forward on ${jumpConnection.name}`) - reject(err) - return + const jumpSession = await this.setupOneSession( + this.injector, + this.profilesService.getConfigProxyForProfile(jumpConnection) + ) + + jumpSession.ref() + session.willDestroy$.subscribe(() => jumpSession.unref()) + jumpSession.willDestroy$.subscribe(() => { + if (session?.open) { + session.destroy() } - resolve(stream) - } - )) + }) - session.jumpStream.on('close', () => { - jumpSession.destroy() - }) - - this.sessionStack.push(session) + session.jumpStream = await new Promise((resolve, reject) => jumpSession.ssh.forwardOut( + '127.0.0.1', 0, profile.options.host, profile.options.port ?? 22, + (err, stream) => { + if (err) { + jumpSession.emitServiceMessage(colors.bgRed.black(' X ') + ` Could not set up port forward on ${jumpConnection.name}`) + reject(err) + return + } + resolve(stream) + } + )) + } } - this.write('\r\n' + colors.black.bgWhite(' SSH ') + ` Connecting to ${session.profile.options.host}\r\n`) - - this.startSpinner('Connecting') - this.attachSessionHandler(session.serviceMessage$, msg => { this.write(`\r${colors.black.bgWhite(' SSH ')} ${msg}\r\n`) }) @@ -141,14 +137,24 @@ export class SSHTabComponent extends BaseTerminalTabComponent { }) }) - try { - await session.start(interactive) - this.stopSpinner() - } catch (e) { - this.stopSpinner() - this.write(colors.black.bgRed(' X ') + ' ' + colors.red(e.message) + '\r\n') - return + if (!session.open) { + this.write('\r\n' + colors.black.bgWhite(' SSH ') + ` Connecting to ${session.profile.options.host}\r\n`) + + this.startSpinner('Connecting') + + try { + await session.start() + this.stopSpinner() + } catch (e) { + this.stopSpinner() + this.write(colors.black.bgRed(' X ') + ' ' + colors.red(e.message) + '\r\n') + return session + } + + this.sshMultiplexer.addSession(session) } + + return session } protected attachSessionHandlers (): void { @@ -185,11 +191,11 @@ export class SSHTabComponent extends BaseTerminalTabComponent { return } - this.sshSession = new SSHSession(this.injector, this.profile) try { - await this.setupOneSession(this.sshSession, true) + this.sshSession = await this.setupOneSession(this.injector, this.profile) } catch (e) { this.write(colors.black.bgRed(' X ') + ' ' + colors.red(e.message) + '\r\n') + return } const session = new SSHShellSession(this.injector, this.sshSession) diff --git a/tabby-ssh/src/profiles.ts b/tabby-ssh/src/profiles.ts index 32a20c4c..a36fd795 100644 --- a/tabby-ssh/src/profiles.ts +++ b/tabby-ssh/src/profiles.ts @@ -39,6 +39,7 @@ export class SSHProfilesService extends ProfileProvider { scripts: [], socksProxyHost: null, socksProxyPort: null, + reuseSession: true, }, } diff --git a/tabby-ssh/src/services/sshMultiplexer.service.ts b/tabby-ssh/src/services/sshMultiplexer.service.ts new file mode 100644 index 00000000..10360bfc --- /dev/null +++ b/tabby-ssh/src/services/sshMultiplexer.service.ts @@ -0,0 +1,38 @@ +import { Injectable } from '@angular/core' +import { SSHProfile } from '../api' +import { ConfigService, PartialProfile, ProfilesService } from 'tabby-core' +import { SSHSession } from '../session/ssh' + +@Injectable({ providedIn: 'root' }) +export class SSHMultiplexerService { + private sessions = new Map() + + constructor ( + private config: ConfigService, + private profilesService: ProfilesService, + ) { } + + addSession (session: SSHSession): void { + const key = this.getMultiplexerKey(session.profile) + this.sessions.set(key, session) + session.willDestroy$.subscribe(() => { + this.sessions.delete(key) + }) + } + + getSession (profile: PartialProfile): SSHSession|null { + const fullProfile = this.profilesService.getConfigProxyForProfile(profile) + const key = this.getMultiplexerKey(fullProfile) + return this.sessions.get(key) ?? null + } + + private getMultiplexerKey (profile: SSHProfile) { + let key = `${profile.options.host}:${profile.options.port}:${profile.options.user}:${profile.options.proxyCommand}:${profile.options.socksProxyHost}:${profile.options.socksProxyPort}` + if (profile.options.jumpHost) { + const jumpConnection = this.config.store.profiles.find(x => x.id === profile.options.jumpHost) + const jumpProfile = this.profilesService.getConfigProxyForProfile(jumpConnection) + key += '$' + this.getMultiplexerKey(jumpProfile) + } + return key + } +} diff --git a/tabby-ssh/src/session/shell.ts b/tabby-ssh/src/session/shell.ts index ab2d4a99..36691751 100644 --- a/tabby-ssh/src/session/shell.ts +++ b/tabby-ssh/src/session/shell.ts @@ -77,6 +77,7 @@ export class SSHShellSession extends BaseSession { this.serviceMessage.next(msg) this.logger.info(stripAnsi(msg)) } + resize (columns: number, rows: number): void { if (this.shell) { this.shell.setWindow(rows, columns, rows, columns) diff --git a/tabby-ssh/src/session/ssh.ts b/tabby-ssh/src/session/ssh.ts index 97ba932c..7ae50807 100644 --- a/tabby-ssh/src/session/ssh.ts +++ b/tabby-ssh/src/session/ssh.ts @@ -169,7 +169,7 @@ export class SSHSession { } - async start (interactive = true): Promise { + async start (): Promise { const log = (s: any) => this.emitServiceMessage(s) const ssh = new Client() @@ -306,10 +306,6 @@ export class SSHSession { this.open = true - if (!interactive) { - return - } - this.ssh.on('tcp connection', (details, accept, reject) => { this.logger.info(`Incoming forwarded connection: (remote) ${details.srcIP}:${details.srcPort} -> (local) ${details.destIP}:${details.destPort}`) const forward = this.forwardedPorts.find(x => x.port === details.destPort)