import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker' import colors from 'ansi-colors' import { Component, Injector, HostListener } from '@angular/core' import { NgbModal } from '@ng-bootstrap/ng-bootstrap' import { GetRecoveryTokenOptions, Platform, ProfilesService, RecoveryToken } from 'tabby-core' import { BaseTerminalTabComponent, ConnectableTerminalTabComponent } from 'tabby-terminal' 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' import { SSHMultiplexerService } from '../services/sshMultiplexer.service' /** @hidden */ @Component({ selector: 'ssh-tab', template: `${BaseTerminalTabComponent.template} ${require('./sshTab.component.pug')}`, styles: [ ...BaseTerminalTabComponent.styles, require('./sshTab.component.scss'), ], animations: BaseTerminalTabComponent.animations, }) export class SSHTabComponent extends ConnectableTerminalTabComponent<SSHProfile> { Platform = Platform sshSession: SSHSession|null = null session: SSHShellSession|null = null sftpPanelVisible = false sftpPath = '/' enableToolbar = true activeKIPrompt: KeyboardInteractivePrompt|null = null constructor ( injector: Injector, public ssh: SSHService, private ngbModal: NgbModal, private profilesService: ProfilesService, private sshMultiplexer: SSHMultiplexerService, ) { super(injector) this.sessionChanged$.subscribe(() => { this.activeKIPrompt = null }) } ngOnInit (): void { this.logger = this.log.create('terminalTab') this.subscribeUntilDestroyed(this.hotkeys.hotkey$, hotkey => { if (!this.hasFocus) { return } switch (hotkey) { case 'home': this.sendInput('\x1bOH' ) break case 'end': this.sendInput('\x1bOF' ) break case 'restart-ssh-session': this.reconnect() break case 'launch-winscp': if (this.sshSession) { this.ssh.launchWinSCP(this.sshSession) } break } }) super.ngOnInit() } async setupOneSession (injector: Injector, profile: SSHProfile, multiplex = true): Promise<SSHSession> { let session = await this.sshMultiplexer.getSession(profile) if (!multiplex || !session || !profile.options.reuseSession) { session = new SSHSession(injector, profile) if (profile.options.jumpHost) { const jumpConnection = (await this.profilesService.getProfiles()).find(x => x.id === profile.options.jumpHost) if (!jumpConnection) { throw new Error(`${profile.options.host}: jump host "${profile.options.jumpHost}" not found in your config`) } 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() } }) 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.attachSessionHandler(session.serviceMessage$, msg => { msg = msg.replace(/\n/g, '\r\n ') this.write(`\r${colors.black.bgWhite(' SSH ')} ${msg}\r\n`) }) this.attachSessionHandler(session.willDestroy$, () => { this.activeKIPrompt = null }) this.attachSessionHandler(session.keyboardInteractivePrompt$, prompt => { this.activeKIPrompt = prompt setTimeout(() => { this.frontend?.scrollToBottom() }) }) if (!session.open) { this.write('\r\n' + colors.black.bgWhite(' SSH ') + ` Connecting to ${session.profile.options.host}\r\n`) this.startSpinner(this.translate.instant(_('Connecting'))) try { await session.start() } finally { this.stopSpinner() } this.sshMultiplexer.addSession(session) } return session } protected onSessionDestroyed (): void { if (this.frontend) { // Session was closed abruptly this.write('\r\n' + colors.black.bgWhite(' SSH ') + ` ${this.sshSession?.profile.options.host}: session closed\r\n`) super.onSessionDestroyed() } } private async initializeSessionMaybeMultiplex (multiplex = true): Promise<void> { this.sshSession = await this.setupOneSession(this.injector, this.profile, multiplex) const session = new SSHShellSession(this.injector, this.sshSession, this.profile) this.setSession(session) this.attachSessionHandler(session.serviceMessage$, msg => { msg = msg.replace(/\n/g, '\r\n ') this.write(`\r${colors.black.bgWhite(' SSH ')} ${msg}\r\n`) session.resize(this.size.columns, this.size.rows) }) await session.start() if (this.config.store.ssh.clearServiceMessagesOnConnect) { this.frontend?.clear() } this.session?.resize(this.size.columns, this.size.rows) } async initializeSession (): Promise<void> { await super.initializeSession() try { await this.initializeSessionMaybeMultiplex(true) } catch { try { await this.initializeSessionMaybeMultiplex(false) } catch (e) { this.write(colors.black.bgRed(' X ') + ' ' + colors.red(e.message) + '\r\n') return } } } async getRecoveryToken (options?: GetRecoveryTokenOptions): Promise<RecoveryToken> { return { type: 'app:ssh-tab', profile: this.profile, savedState: options?.includeState && this.frontend?.saveState(), } } showPortForwarding (): void { const modal = this.ngbModal.open(SSHPortForwardingModalComponent).componentInstance as SSHPortForwardingModalComponent modal.session = this.sshSession! } async canClose (): Promise<boolean> { if (!this.session?.open) { return true } if (!(this.profile.options.warnOnClose ?? this.config.store.ssh.warnOnClose)) { return true } return (await this.platform.showMessageBox( { type: 'warning', message: this.translate.instant(_('Disconnect from {host}?'), this.profile.options), buttons: [ this.translate.instant(_('Disconnect')), this.translate.instant(_('Do not close')), ], defaultId: 0, cancelId: 1, }, )).response === 0 } async openSFTP (): Promise<void> { this.sftpPath = await this.session?.getWorkingDirectory() ?? this.sftpPath setTimeout(() => { this.sftpPanelVisible = true }, 100) } @HostListener('click') onClick (): void { this.sftpPanelVisible = false } protected isSessionExplicitlyTerminated (): boolean { return super.isSessionExplicitlyTerminated() || this.recentInputs.charCodeAt(this.recentInputs.length - 1) === 4 || this.recentInputs.endsWith('exit\r') } }