From 44cbc9298f3642e14c051826b9d423b45d92d04d Mon Sep 17 00:00:00 2001 From: Eugene Pankov Date: Fri, 24 Dec 2021 15:18:02 +0100 Subject: [PATCH] separated ssh session and shell session classes --- tabby-ssh/src/components/sshTab.component.pug | 2 +- tabby-ssh/src/components/sshTab.component.ts | 35 +++-- tabby-ssh/src/session/shell.ts | 120 ++++++++++++++++++ tabby-ssh/src/session/ssh.ts | 102 ++++----------- tabby-ssh/src/tabContextMenu.ts | 2 +- .../src/api/baseTerminalTab.component.ts | 3 + tabby-terminal/src/features/debug.ts | 7 +- 7 files changed, 177 insertions(+), 94 deletions(-) create mode 100644 tabby-ssh/src/session/shell.ts diff --git a/tabby-ssh/src/components/sshTab.component.pug b/tabby-ssh/src/components/sshTab.component.pug index 1fb29e24..136211fc 100644 --- a/tabby-ssh/src/components/sshTab.component.pug +++ b/tabby-ssh/src/components/sshTab.component.pug @@ -42,7 +42,7 @@ sftp-panel.bg-dark( [(path)]='sftpPath', *ngIf='sftpPanelVisible', (click)='$event.stopPropagation()', - [session]='session', + [session]='sshSession', (closed)='sftpPanelVisible = false' ) diff --git a/tabby-ssh/src/components/sshTab.component.ts b/tabby-ssh/src/components/sshTab.component.ts index 27938b52..d81fb864 100644 --- a/tabby-ssh/src/components/sshTab.component.ts +++ b/tabby-ssh/src/components/sshTab.component.ts @@ -8,7 +8,7 @@ import { SSHService } from '../services/ssh.service' import { KeyboardInteractivePrompt, SSHSession } from '../session/ssh' import { SSHPortForwardingModalComponent } from './sshPortForwardingModal.component' import { SSHProfile } from '../api' - +import { SSHShellSession } from '../session/shell' /** @hidden */ @Component({ @@ -20,7 +20,8 @@ import { SSHProfile } from '../api' export class SSHTabComponent extends BaseTerminalTabComponent { Platform = Platform profile?: SSHProfile - session: SSHSession|null = null + sshSession: SSHSession|null = null + session: SSHShellSession|null = null sftpPanelVisible = false sftpPath = '/' enableToolbar = true @@ -63,8 +64,8 @@ export class SSHTabComponent extends BaseTerminalTabComponent { this.reconnect() break case 'launch-winscp': - if (this.session) { - this.ssh.launchWinSCP(this.session) + if (this.sshSession) { + this.ssh.launchWinSCP(this.sshSession) } break } @@ -96,7 +97,7 @@ export class SSHTabComponent extends BaseTerminalTabComponent { await this.setupOneSession(jumpSession, false) - this.attachSessionHandler(jumpSession.destroyed$, () => { + this.attachSessionHandler(jumpSession.willDestroy$, () => { if (session.open) { session.destroy() } @@ -127,10 +128,9 @@ export class SSHTabComponent extends BaseTerminalTabComponent { this.attachSessionHandler(session.serviceMessage$, msg => { this.write(`\r${colors.black.bgWhite(' SSH ')} ${msg}\r\n`) - session.resize(this.size.columns, this.size.rows) }) - this.attachSessionHandler(session.destroyed$, () => { + this.attachSessionHandler(session.willDestroy$, () => { this.activeKIPrompt = null }) @@ -163,7 +163,7 @@ export class SSHTabComponent extends BaseTerminalTabComponent { this.destroy() } else if (this.frontend) { // Session was closed abruptly - this.write('\r\n' + colors.black.bgWhite(' SSH ') + ` ${session.profile.options.host}: session closed\r\n`) + this.write('\r\n' + colors.black.bgWhite(' SSH ') + ` ${this.sshSession?.profile.options.host}: session closed\r\n`) if (!this.reconnectOffered) { this.reconnectOffered = true this.write('Press any key to reconnect\r\n') @@ -185,16 +185,23 @@ export class SSHTabComponent extends BaseTerminalTabComponent { return } - const session = new SSHSession(this.injector, this.profile) - this.setSession(session) - + this.sshSession = new SSHSession(this.injector, this.profile) try { - await this.setupOneSession(session, true) + await this.setupOneSession(this.sshSession, true) } catch (e) { this.write(colors.black.bgRed(' X ') + ' ' + colors.red(e.message) + '\r\n') } - this.session!.resize(this.size.columns, this.size.rows) + const session = new SSHShellSession(this.injector, this.sshSession) + + this.attachSessionHandler(session.serviceMessage$, msg => { + this.write(`\r${colors.black.bgWhite(' SSH ')} ${msg}\r\n`) + session.resize(this.size.columns, this.size.rows) + }) + + this.setSession(session) + await session.start() + this.session?.resize(this.size.columns, this.size.rows) } async getRecoveryToken (): Promise { @@ -207,7 +214,7 @@ export class SSHTabComponent extends BaseTerminalTabComponent { showPortForwarding (): void { const modal = this.ngbModal.open(SSHPortForwardingModalComponent).componentInstance as SSHPortForwardingModalComponent - modal.session = this.session! + modal.session = this.sshSession! } async reconnect (): Promise { diff --git a/tabby-ssh/src/session/shell.ts b/tabby-ssh/src/session/shell.ts new file mode 100644 index 00000000..ab2d4a99 --- /dev/null +++ b/tabby-ssh/src/session/shell.ts @@ -0,0 +1,120 @@ +import { Observable, Subject } from 'rxjs' +import colors from 'ansi-colors' +import stripAnsi from 'strip-ansi' +import { ClientChannel } from 'ssh2' +import { Injector } from '@angular/core' +import { LogService } from 'tabby-core' +import { BaseSession } from 'tabby-terminal' +import { SSHSession } from './ssh' +import { SSHProfile } from '../api' + + +export class SSHShellSession extends BaseSession { + shell?: ClientChannel + private profile: SSHProfile + get serviceMessage$ (): Observable { return this.serviceMessage } + private serviceMessage = new Subject() + private ssh: SSHSession|null + + constructor ( + injector: Injector, + ssh: SSHSession, + ) { + super(injector.get(LogService).create(`ssh-shell-${ssh.profile.options.host}-${ssh.profile.options.port}`)) + this.ssh = ssh + this.profile = ssh.profile + this.setLoginScriptsOptions(this.profile.options) + } + + async start (): Promise { + if (!this.ssh) { + throw new Error('SSH session not set') + } + + this.ssh.ref() + this.ssh.willDestroy$.subscribe(() => { + this.destroy() + }) + + this.logger.debug('Opening shell') + + try { + this.shell = await this.ssh.openShellChannel({ x11: this.profile.options.x11 ?? false }) + } catch (err) { + this.emitServiceMessage(colors.bgRed.black(' X ') + ` Remote rejected opening a shell channel: ${err}`) + if (err.toString().includes('Unable to request X11')) { + this.emitServiceMessage(' Make sure `xauth` is installed on the remote side') + } + return + } + + this.open = true + this.logger.debug('Shell open') + + this.loginScriptProcessor?.executeUnconditionalScripts() + + this.shell.on('greeting', greeting => { + this.emitServiceMessage(`Shell greeting: ${greeting}`) + }) + + this.shell.on('banner', banner => { + this.emitServiceMessage(`Shell banner: ${banner}`) + }) + + this.shell.on('data', data => { + this.emitOutput(data) + }) + + this.shell.on('end', () => { + this.logger.info('Shell session ended') + if (this.open) { + this.destroy() + } + }) + } + + emitServiceMessage (msg: string): void { + 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) + } + } + + write (data: Buffer): void { + if (this.shell) { + this.shell.write(data) + } + } + + kill (signal?: string): void { + this.shell?.signal(signal ?? 'TERM') + } + + async destroy (): Promise { + this.logger.debug('Closing shell') + this.serviceMessage.complete() + this.kill() + this.ssh?.unref() + this.ssh = null + await super.destroy() + } + + async getChildProcesses (): Promise { + return [] + } + + async gracefullyKillProcess (): Promise { + this.kill('TERM') + } + + supportsWorkingDirectory (): boolean { + return !!this.reportedCWD + } + + async getWorkingDirectory (): Promise { + return this.reportedCWD ?? null + } +} diff --git a/tabby-ssh/src/session/ssh.ts b/tabby-ssh/src/session/ssh.ts index 566306e8..97ba932c 100644 --- a/tabby-ssh/src/session/ssh.ts +++ b/tabby-ssh/src/session/ssh.ts @@ -7,8 +7,7 @@ import colors from 'ansi-colors' import stripAnsi from 'strip-ansi' import { Injector, NgZone } from '@angular/core' import { NgbModal } from '@ng-bootstrap/ng-bootstrap' -import { ConfigService, FileProvidersService, HostAppService, NotificationsService, Platform, PlatformService, wrapPromise, PromptModalComponent, LogService } from 'tabby-core' -import { BaseSession } from 'tabby-terminal' +import { ConfigService, FileProvidersService, HostAppService, NotificationsService, Platform, PlatformService, wrapPromise, PromptModalComponent, LogService, Logger } from 'tabby-core' import { Socket } from 'net' import { Client, ClientChannel, SFTPWrapper } from 'ssh2' import { Subject, Observable } from 'rxjs' @@ -48,7 +47,7 @@ export class KeyboardInteractivePrompt { } } -export class SSHSession extends BaseSession { +export class SSHSession { shell?: ClientChannel ssh: Client sftp?: SFTPWrapper @@ -59,14 +58,20 @@ export class SSHSession extends BaseSession { savedPassword?: string get serviceMessage$ (): Observable { return this.serviceMessage } get keyboardInteractivePrompt$ (): Observable { return this.keyboardInteractivePrompt } + get willDestroy$ (): Observable { return this.willDestroy } agentPath?: string activePrivateKey: string|null = null authUsername: string|null = null + open = false + + private logger: Logger + private refCount = 0 private remainingAuthMethods: AuthMethod[] = [] private serviceMessage = new Subject() private keyboardInteractivePrompt = new Subject() + private willDestroy = new Subject() private keychainPasswordUsed = false private passwordStorage: PasswordStorageService @@ -82,7 +87,7 @@ export class SSHSession extends BaseSession { private injector: Injector, public profile: SSHProfile, ) { - super(injector.get(LogService).create(`ssh-${profile.options.host}-${profile.options.port}`)) + this.logger = injector.get(LogService).create(`ssh-${profile.options.host}-${profile.options.port}`) this.passwordStorage = injector.get(PasswordStorageService) this.ngbModal = injector.get(NgbModal) @@ -93,13 +98,11 @@ export class SSHSession extends BaseSession { this.fileProviders = injector.get(FileProvidersService) this.config = injector.get(ConfigService) - this.destroyed$.subscribe(() => { + this.willDestroy$.subscribe(() => { for (const port of this.forwardedPorts) { port.stopLocalListener() } }) - - this.setLoginScriptsOptions(profile.options) } async init (): Promise { @@ -307,39 +310,6 @@ export class SSHSession extends BaseSession { return } - // ----------- - - try { - this.shell = await this.openShellChannel({ x11: this.profile.options.x11 }) - } catch (err) { - this.emitServiceMessage(colors.bgRed.black(' X ') + ` Remote rejected opening a shell channel: ${err}`) - if (err.toString().includes('Unable to request X11')) { - this.emitServiceMessage(' Make sure `xauth` is installed on the remote side') - } - return - } - - this.loginScriptProcessor?.executeUnconditionalScripts() - - this.shell.on('greeting', greeting => { - this.emitServiceMessage(`Shell greeting: ${greeting}`) - }) - - this.shell.on('banner', banner => { - this.emitServiceMessage(`Shell banner: ${banner}`) - }) - - this.shell.on('data', data => { - this.emitOutput(data) - }) - - this.shell.on('end', () => { - this.logger.info('Shell session ended') - if (this.open) { - this.destroy() - } - }) - 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) @@ -557,49 +527,16 @@ export class SSHSession extends BaseSession { this.emitServiceMessage(`Stopped forwarding ${fw}`) } - resize (columns: number, rows: number): void { - if (this.shell) { - this.shell.setWindow(rows, columns, rows, columns) - } - } - - write (data: Buffer): void { - if (this.shell) { - this.shell.write(data) - } - } - - kill (signal?: string): void { - if (this.shell) { - this.shell.signal(signal ?? 'TERM') - } - } - async destroy (): Promise { + this.logger.info('Destroying') + this.willDestroy.next() + this.willDestroy.complete() this.serviceMessage.complete() this.proxyCommandStream?.destroy() - this.kill() this.ssh.end() - await super.destroy() } - async getChildProcesses (): Promise { - return [] - } - - async gracefullyKillProcess (): Promise { - this.kill('TERM') - } - - supportsWorkingDirectory (): boolean { - return !!this.reportedCWD - } - - async getWorkingDirectory (): Promise { - return this.reportedCWD ?? null - } - - private openShellChannel (options): Promise { + openShellChannel (options: { x11: boolean }): Promise { return new Promise((resolve, reject) => { this.ssh.shell({ term: 'xterm-256color' }, options, (err, shell) => { if (err) { @@ -674,4 +611,15 @@ export class SSHSession extends BaseSession { } } } + + ref (): void { + this.refCount++ + } + + unref (): void { + this.refCount-- + if (this.refCount === 0) { + this.destroy() + } + } } diff --git a/tabby-ssh/src/tabContextMenu.ts b/tabby-ssh/src/tabContextMenu.ts index 8f72ca54..bf96a32c 100644 --- a/tabby-ssh/src/tabContextMenu.ts +++ b/tabby-ssh/src/tabContextMenu.ts @@ -30,7 +30,7 @@ export class SFTPContextMenu extends TabContextMenuItemProvider { items.push({ label: 'Launch WinSCP', click: (): void => { - this.ssh.launchWinSCP(tab.session!) + this.ssh.launchWinSCP(tab.sshSession!) }, }) } diff --git a/tabby-terminal/src/api/baseTerminalTab.component.ts b/tabby-terminal/src/api/baseTerminalTab.component.ts index 359a48cf..1ea9f4b0 100644 --- a/tabby-terminal/src/api/baseTerminalTab.component.ts +++ b/tabby-terminal/src/api/baseTerminalTab.component.ts @@ -313,6 +313,9 @@ export class BaseTerminalTabComponent extends BaseTabComponent implements OnInit }, 1000) this.session?.releaseInitialDataBuffer() + this.sessionChanged$.subscribe(() => { + this.session?.releaseInitialDataBuffer() + }) }) this.alternateScreenActive$.subscribe(x => { diff --git a/tabby-terminal/src/features/debug.ts b/tabby-terminal/src/features/debug.ts index 0653db9e..3364f7c0 100644 --- a/tabby-terminal/src/features/debug.ts +++ b/tabby-terminal/src/features/debug.ts @@ -16,13 +16,18 @@ export class DebugDecorator extends TerminalDecorator { let sessionOutputBuffer = '' const bufferLength = 8192 - this.subscribeUntilDetached(terminal, terminal.session!.output$.subscribe(data => { + const handler = data => { sessionOutputBuffer += data if (sessionOutputBuffer.length > bufferLength) { sessionOutputBuffer = sessionOutputBuffer.substring(sessionOutputBuffer.length - bufferLength) } + } + this.subscribeUntilDetached(terminal, terminal.sessionChanged$.subscribe(session => { + this.subscribeUntilDetached(terminal, session?.output$.subscribe(handler)) })) + this.subscribeUntilDetached(terminal, terminal.session?.output$.subscribe(handler)) + terminal.addEventListenerUntilDestroyed(terminal.content.nativeElement, 'keyup', (e: KeyboardEvent) => { // Ctrl-Shift-Alt-1 if (e.which === 49 && e.ctrlKey && e.shiftKey && e.altKey) {