SSH session multiplexing - fixes #4795

This commit is contained in:
Eugene Pankov 2021-12-24 18:34:48 +01:00
parent 44cbc9298f
commit a78f3399fd
No known key found for this signature in database
GPG Key ID: 5896FCBBDD1CF4F4
7 changed files with 100 additions and 51 deletions

View File

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

View File

@ -162,6 +162,12 @@ ul.nav-tabs(ngbNav, #nav='ngbNav')
.description Will prevent the SSH greeting from showing up .description Will prevent the SSH greeting from showing up
toggle([(ngModel)]='profile.options.skipBanner') 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 .form-line
.header .header
.title Keep Alive Interval (Milliseconds) .title Keep Alive Interval (Milliseconds)

View File

@ -9,6 +9,7 @@ import { KeyboardInteractivePrompt, SSHSession } from '../session/ssh'
import { SSHPortForwardingModalComponent } from './sshPortForwardingModal.component' import { SSHPortForwardingModalComponent } from './sshPortForwardingModal.component'
import { SSHProfile } from '../api' import { SSHProfile } from '../api'
import { SSHShellSession } from '../session/shell' import { SSHShellSession } from '../session/shell'
import { SSHMultiplexerService } from '../services/sshMultiplexer.service'
/** @hidden */ /** @hidden */
@Component({ @Component({
@ -26,7 +27,6 @@ export class SSHTabComponent extends BaseTerminalTabComponent {
sftpPath = '/' sftpPath = '/'
enableToolbar = true enableToolbar = true
activeKIPrompt: KeyboardInteractivePrompt|null = null activeKIPrompt: KeyboardInteractivePrompt|null = null
private sessionStack: SSHSession[] = []
private recentInputs = '' private recentInputs = ''
private reconnectOffered = false private reconnectOffered = false
@ -35,6 +35,7 @@ export class SSHTabComponent extends BaseTerminalTabComponent {
public ssh: SSHService, public ssh: SSHService,
private ngbModal: NgbModal, private ngbModal: NgbModal,
private profilesService: ProfilesService, private profilesService: ProfilesService,
private sshMultiplexer: SSHMultiplexerService,
) { ) {
super(injector) super(injector)
this.sessionChanged$.subscribe(() => { this.sessionChanged$.subscribe(() => {
@ -82,50 +83,45 @@ export class SSHTabComponent extends BaseTerminalTabComponent {
super.ngOnInit() super.ngOnInit()
} }
async setupOneSession (session: SSHSession, interactive: boolean): Promise<void> { async setupOneSession (injector: Injector, profile: SSHProfile): Promise<SSHSession> {
if (session.profile.options.jumpHost) { let session = this.sshMultiplexer.getSession(profile)
const jumpConnection: PartialProfile<SSHProfile>|null = this.config.store.profiles.find(x => x.id === session.profile.options.jumpHost) if (!session || !profile.options.reuseSession) {
session = new SSHSession(injector, profile)
if (!jumpConnection) { if (profile.options.jumpHost) {
throw new Error(`${session.profile.options.host}: jump host "${session.profile.options.jumpHost}" not found in your config`) const jumpConnection: PartialProfile<SSHProfile>|null = this.config.store.profiles.find(x => x.id === profile.options.jumpHost)
}
const jumpSession = new SSHSession( if (!jumpConnection) {
this.injector, throw new Error(`${profile.options.host}: jump host "${profile.options.jumpHost}" not found in your config`)
this.profilesService.getConfigProxyForProfile(jumpConnection)
)
await this.setupOneSession(jumpSession, false)
this.attachSessionHandler(jumpSession.willDestroy$, () => {
if (session.open) {
session.destroy()
} }
})
session.jumpStream = await new Promise((resolve, reject) => jumpSession.ssh.forwardOut( const jumpSession = await this.setupOneSession(
'127.0.0.1', 0, session.profile.options.host, session.profile.options.port ?? 22, this.injector,
(err, stream) => { this.profilesService.getConfigProxyForProfile(jumpConnection)
if (err) { )
jumpSession.emitServiceMessage(colors.bgRed.black(' X ') + ` Could not set up port forward on ${jumpConnection.name}`)
reject(err) jumpSession.ref()
return session.willDestroy$.subscribe(() => jumpSession.unref())
jumpSession.willDestroy$.subscribe(() => {
if (session?.open) {
session.destroy()
} }
resolve(stream) })
}
))
session.jumpStream.on('close', () => { session.jumpStream = await new Promise((resolve, reject) => jumpSession.ssh.forwardOut(
jumpSession.destroy() '127.0.0.1', 0, profile.options.host, profile.options.port ?? 22,
}) (err, stream) => {
if (err) {
this.sessionStack.push(session) 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.attachSessionHandler(session.serviceMessage$, msg => {
this.write(`\r${colors.black.bgWhite(' SSH ')} ${msg}\r\n`) this.write(`\r${colors.black.bgWhite(' SSH ')} ${msg}\r\n`)
}) })
@ -141,14 +137,24 @@ export class SSHTabComponent extends BaseTerminalTabComponent {
}) })
}) })
try { if (!session.open) {
await session.start(interactive) this.write('\r\n' + colors.black.bgWhite(' SSH ') + ` Connecting to ${session.profile.options.host}\r\n`)
this.stopSpinner()
} catch (e) { this.startSpinner('Connecting')
this.stopSpinner()
this.write(colors.black.bgRed(' X ') + ' ' + colors.red(e.message) + '\r\n') try {
return 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 { protected attachSessionHandlers (): void {
@ -185,11 +191,11 @@ export class SSHTabComponent extends BaseTerminalTabComponent {
return return
} }
this.sshSession = new SSHSession(this.injector, this.profile)
try { try {
await this.setupOneSession(this.sshSession, true) this.sshSession = await this.setupOneSession(this.injector, this.profile)
} catch (e) { } catch (e) {
this.write(colors.black.bgRed(' X ') + ' ' + colors.red(e.message) + '\r\n') this.write(colors.black.bgRed(' X ') + ' ' + colors.red(e.message) + '\r\n')
return
} }
const session = new SSHShellSession(this.injector, this.sshSession) const session = new SSHShellSession(this.injector, this.sshSession)

View File

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

View File

@ -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<string, SSHSession>()
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<SSHProfile>): 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
}
}

View File

@ -77,6 +77,7 @@ export class SSHShellSession extends BaseSession {
this.serviceMessage.next(msg) this.serviceMessage.next(msg)
this.logger.info(stripAnsi(msg)) this.logger.info(stripAnsi(msg))
} }
resize (columns: number, rows: number): void { resize (columns: number, rows: number): void {
if (this.shell) { if (this.shell) {
this.shell.setWindow(rows, columns, rows, columns) this.shell.setWindow(rows, columns, rows, columns)

View File

@ -169,7 +169,7 @@ export class SSHSession {
} }
async start (interactive = true): Promise<void> { async start (): Promise<void> {
const log = (s: any) => this.emitServiceMessage(s) const log = (s: any) => this.emitServiceMessage(s)
const ssh = new Client() const ssh = new Client()
@ -306,10 +306,6 @@ export class SSHSession {
this.open = true this.open = true
if (!interactive) {
return
}
this.ssh.on('tcp connection', (details, accept, reject) => { this.ssh.on('tcp connection', (details, accept, reject) => {
this.logger.info(`Incoming forwarded connection: (remote) ${details.srcIP}:${details.srcPort} -> (local) ${details.destIP}:${details.destPort}`) 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) const forward = this.forwardedPorts.find(x => x.port === details.destPort)