mirror of
https://github.com/Eugeny/tabby.git
synced 2025-06-09 14:00:03 +00:00
SSH session multiplexing - fixes #4795
This commit is contained in:
parent
44cbc9298f
commit
a78f3399fd
@ -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 {
|
||||||
|
@ -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)
|
||||||
|
@ -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)
|
||||||
|
@ -39,6 +39,7 @@ export class SSHProfilesService extends ProfileProvider<SSHProfile> {
|
|||||||
scripts: [],
|
scripts: [],
|
||||||
socksProxyHost: null,
|
socksProxyHost: null,
|
||||||
socksProxyPort: null,
|
socksProxyPort: null,
|
||||||
|
reuseSession: true,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
38
tabby-ssh/src/services/sshMultiplexer.service.ts
Normal file
38
tabby-ssh/src/services/sshMultiplexer.service.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
@ -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)
|
||||||
|
@ -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)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user