Files
tabby/tabby-ssh/src/session/ssh.ts
Eugene b0e0709a36 lint
2024-08-20 09:12:18 +02:00

729 lines
30 KiB
TypeScript

import * as fs from 'mz/fs'
import * as crypto from 'crypto'
import colors from 'ansi-colors'
import stripAnsi from 'strip-ansi'
import * as shellQuote from 'shell-quote'
import { Injector } from '@angular/core'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { ConfigService, FileProvidersService, NotificationsService, PromptModalComponent, LogService, Logger, TranslateService, Platform, HostAppService } from 'tabby-core'
import { Socket } from 'net'
import { Subject, Observable } from 'rxjs'
import { HostKeyPromptModalComponent } from '../components/hostKeyPromptModal.component'
// import { HTTPProxyStream, SocksProxyStream } from '../services/ssh.service'
import { PasswordStorageService } from '../services/passwordStorage.service'
import { SSHKnownHostsService } from '../services/sshKnownHosts.service'
import { SFTPSession } from './sftp'
import { SSHAlgorithmType, SSHProfile, SSHProxyStream, AutoPrivateKeyLocator, PortForwardType } from '../api'
import { ForwardedPort } from './forwards'
import { X11Socket } from './x11'
import { supportedAlgorithms } from '../algorithms'
import * as russh from 'russh'
const WINDOWS_OPENSSH_AGENT_PIPE = '\\\\.\\pipe\\openssh-ssh-agent'
export interface Prompt {
prompt: string
echo?: boolean
}
type AuthMethod = {
type: 'none'|'password'|'keyboard-interactive'|'hostbased'
} | {
type: 'publickey'
name: string
contents: Buffer
} | {
type: 'agent',
kind: 'unix-socket',
path: string
} | {
type: 'agent',
kind: 'named-pipe',
path: string
} | {
type: 'agent',
kind: 'pageant',
}
export class KeyboardInteractivePrompt {
readonly responses: string[] = []
private _resolve: (value: string[]) => void
private _reject: (reason: any) => void
readonly promise = new Promise<string[]>((resolve, reject) => {
this._resolve = resolve
this._reject = reject
})
constructor (
public name: string,
public instruction: string,
public prompts: Prompt[],
) {
this.responses = new Array(this.prompts.length).fill('')
}
respond (): void {
this._resolve(this.responses)
}
reject (): void {
this._reject(new Error('Keyboard-interactive auth rejected'))
}
}
export class SSHSession {
shell?: russh.Channel
ssh: russh.SSHClient|russh.AuthenticatedSSHClient
sftp?: russh.SFTP
forwardedPorts: ForwardedPort[] = []
jumpChannel: russh.Channel|null = null
proxyCommandStream: SSHProxyStream|null = null
savedPassword?: string
get serviceMessage$ (): Observable<string> { return this.serviceMessage }
get keyboardInteractivePrompt$ (): Observable<KeyboardInteractivePrompt> { return this.keyboardInteractivePrompt }
get willDestroy$ (): Observable<void> { return this.willDestroy }
activePrivateKey: russh.KeyPair|null = null
authUsername: string|null = null
open = false
private logger: Logger
private refCount = 0
private remainingAuthMethods: AuthMethod[] = []
private serviceMessage = new Subject<string>()
private keyboardInteractivePrompt = new Subject<KeyboardInteractivePrompt>()
private willDestroy = new Subject<void>()
private keychainPasswordUsed = false
private passwordStorage: PasswordStorageService
private ngbModal: NgbModal
private hostApp: HostAppService
private notifications: NotificationsService
private fileProviders: FileProvidersService
private config: ConfigService
private translate: TranslateService
private knownHosts: SSHKnownHostsService
private privateKeyImporters: AutoPrivateKeyLocator[]
constructor (
private injector: Injector,
public profile: SSHProfile,
) {
this.logger = injector.get(LogService).create(`ssh-${profile.options.host}-${profile.options.port}`)
this.passwordStorage = injector.get(PasswordStorageService)
this.ngbModal = injector.get(NgbModal)
this.hostApp = injector.get(HostAppService)
this.notifications = injector.get(NotificationsService)
this.fileProviders = injector.get(FileProvidersService)
this.config = injector.get(ConfigService)
this.translate = injector.get(TranslateService)
this.knownHosts = injector.get(SSHKnownHostsService)
this.privateKeyImporters = injector.get(AutoPrivateKeyLocator, [])
this.willDestroy$.subscribe(() => {
for (const port of this.forwardedPorts) {
port.stopLocalListener()
}
})
}
async init (): Promise<void> {
this.remainingAuthMethods = [{ type: 'none' }]
if (!this.profile.options.auth || this.profile.options.auth === 'publicKey') {
if (this.profile.options.privateKeys?.length) {
for (const pk of this.profile.options.privateKeys) {
try {
this.remainingAuthMethods.push({
type: 'publickey',
name: pk,
contents: await this.fileProviders.retrieveFile(pk),
})
} catch (error) {
this.emitServiceMessage(colors.bgYellow.yellow.black(' ! ') + ` Could not load private key ${pk}: ${error}`)
}
}
} else {
for (const importer of this.privateKeyImporters) {
for (const [name, contents] of await importer.getKeys()) {
this.remainingAuthMethods.push({
type: 'publickey',
name,
contents,
})
}
}
}
}
if (!this.profile.options.auth || this.profile.options.auth === 'agent') {
if (this.hostApp.platform === Platform.Windows) {
if (this.config.store.ssh.agentType === 'auto') {
if (await fs.exists(WINDOWS_OPENSSH_AGENT_PIPE)) {
this.remainingAuthMethods.push({
type: 'agent',
kind: 'named-pipe',
path: WINDOWS_OPENSSH_AGENT_PIPE,
})
} else if (russh.isPageantRunning()) {
this.remainingAuthMethods.push({
type: 'agent',
kind: 'pageant',
})
} else {
this.emitServiceMessage(colors.bgYellow.yellow.black(' ! ') + ` Agent auth selected, but no running Agent process is found`)
}
} else if (this.config.store.ssh.agentType === 'pageant') {
this.remainingAuthMethods.push({
type: 'agent',
kind: 'pageant',
})
} else {
this.remainingAuthMethods.push({
type: 'agent',
kind: 'named-pipe',
path: this.config.store.ssh.agentPath || WINDOWS_OPENSSH_AGENT_PIPE,
})
}
} else {
this.remainingAuthMethods.push({
type: 'agent',
kind: 'unix-socket',
path: process.env.SSH_AUTH_SOCK!,
})
}
}
if (!this.profile.options.auth || this.profile.options.auth === 'password') {
this.remainingAuthMethods.push({ type: 'password' })
}
if (!this.profile.options.auth || this.profile.options.auth === 'keyboardInteractive') {
this.remainingAuthMethods.push({ type: 'keyboard-interactive' })
}
this.remainingAuthMethods.push({ type: 'hostbased' })
}
async openSFTP (): Promise<SFTPSession> {
if (!(this.ssh instanceof russh.AuthenticatedSSHClient)) {
throw new Error('Cannot open SFTP session before auth')
}
if (!this.sftp) {
this.sftp = await this.ssh.openSFTPChannel()
}
return new SFTPSession(this.sftp, this.injector)
}
async start (): Promise<void> {
// const log = (s: any) => this.emitServiceMessage(s)
await this.init()
const algorithms = {}
for (const key of Object.values(SSHAlgorithmType)) {
algorithms[key] = this.profile.options.algorithms![key].filter(x => supportedAlgorithms[key].includes(x))
}
// todo migrate connection opts
// eslint-disable-next-line @typescript-eslint/init-declarations
let transport: russh.SshTransport
if (this.profile.options.proxyCommand) {
this.emitServiceMessage(colors.bgBlue.black(' Proxy command ') + ` Using ${this.profile.options.proxyCommand}`)
const argv = shellQuote.parse(this.profile.options.proxyCommand)
transport = await russh.SshTransport.newCommand(argv[0], argv.slice(1))
// TODO stderr service messages
// this.proxyCommandStream.destroyed$.subscribe(err => {
// if (err) {
// this.emitServiceMessage(colors.bgRed.black(' X ') + ` ${err.message}`)
// this.destroy()
// }
// })
// this.proxyCommandStream.message$.subscribe(message => {
// this.emitServiceMessage(colors.bgBlue.black(' Proxy ') + ' ' + message.trim())
// })
} else if (this.jumpChannel) {
transport = await russh.SshTransport.newSshChannel(await this.jumpChannel.take())
this.jumpChannel = null
} else {
transport = await russh.SshTransport.newSocket(`${this.profile.options.host.trim()}:${this.profile.options.port ?? 22}`)
}
this.ssh = await russh.SSHClient.connect(
transport,
async key => {
if (!await this.verifyHostKey(key)) {
return false
}
this.logger.info('Host key verified')
return true
},
{
preferred: {
ciphers: this.profile.options.algorithms?.[SSHAlgorithmType.CIPHER]?.filter(x => supportedAlgorithms[SSHAlgorithmType.CIPHER].includes(x)),
kex: this.profile.options.algorithms?.[SSHAlgorithmType.KEX]?.filter(x => supportedAlgorithms[SSHAlgorithmType.KEX].includes(x)),
mac: this.profile.options.algorithms?.[SSHAlgorithmType.HMAC]?.filter(x => supportedAlgorithms[SSHAlgorithmType.HMAC].includes(x)),
key: this.profile.options.algorithms?.[SSHAlgorithmType.HOSTKEY]?.filter(x => supportedAlgorithms[SSHAlgorithmType.HOSTKEY].includes(x)),
},
},
)
this.ssh.banner$.subscribe(banner => {
if (!this.profile.options.skipBanner) {
this.emitServiceMessage(banner)
}
})
this.ssh.disconnect$.subscribe(() => {
if (this.open) {
this.destroy()
}
})
// Authentication
this.authUsername ??= this.profile.options.user
if (!this.authUsername) {
const modal = this.ngbModal.open(PromptModalComponent)
modal.componentInstance.prompt = `Username for ${this.profile.options.host}`
try {
const result = await modal.result.catch(() => null)
this.authUsername = result?.value ?? null
} catch {
this.authUsername = 'root'
}
}
if (this.authUsername?.startsWith('$')) {
try {
const result = process.env[this.authUsername.slice(1)]
this.authUsername = result ?? this.authUsername
} catch {
this.authUsername = 'root'
}
}
const authenticatedClient = await this.handleAuth()
if (authenticatedClient) {
this.ssh = authenticatedClient
} else {
this.ssh.disconnect()
this.passwordStorage.deletePassword(this.profile)
// eslint-disable-next-line @typescript-eslint/no-base-to-string
throw new Error('Authentication rejected')
}
// auth success
if (this.savedPassword) {
this.passwordStorage.savePassword(this.profile, this.savedPassword)
}
try {
// if (this.profile.options.socksProxyHost) {
// this.emitServiceMessage(colors.bgBlue.black(' Proxy ') + ` Using ${this.profile.options.socksProxyHost}:${this.profile.options.socksProxyPort}`)
// this.proxyCommandStream = new SocksProxyStream(this.profile)
// }
// if (this.profile.options.httpProxyHost) {
// this.emitServiceMessage(colors.bgBlue.black(' Proxy ') + ` Using ${this.profile.options.httpProxyHost}:${this.profile.options.httpProxyPort}`)
// this.proxyCommandStream = new HTTPProxyStream(this.profile)
// }
// await this.proxyCommandStream.start()
// }
// ssh.connect({
// agent: this.agentPath,
// agentForward: this.profile.options.agentForward && !!this.agentPath,
// keepaliveInterval: this.profile.options.keepaliveInterval ?? 15000,
// keepaliveCountMax: this.profile.options.keepaliveCountMax,
// readyTimeout: this.profile.options.readyTimeout,
// })
} catch (e) {
this.notifications.error(e.message)
throw e
}
for (const fw of this.profile.options.forwardedPorts ?? []) {
this.addPortForward(Object.assign(new ForwardedPort(), fw))
}
this.open = true
this.ssh.tcpChannelOpen$.subscribe(async event => {
this.logger.info(`Incoming forwarded connection: ${event.clientAddress}:${event.clientPort} -> ${event.targetAddress}:${event.targetPort}`)
const forward = this.forwardedPorts.find(x => x.port === event.targetPort && x.host === event.targetAddress)
if (!forward) {
this.emitServiceMessage(colors.bgRed.black(' X ') + ` Rejected incoming forwarded connection for unrecognized port ${event.targetAddress}:${event.targetPort}`)
return
}
const socket = new Socket()
socket.connect(forward.targetPort, forward.targetAddress)
socket.on('error', e => {
// eslint-disable-next-line @typescript-eslint/no-base-to-string
this.emitServiceMessage(colors.bgRed.black(' X ') + ` Could not forward the remote connection to ${forward.targetAddress}:${forward.targetPort}: ${e}`)
event.channel.close()
})
event.channel.data$.subscribe(data => socket.write(data))
socket.on('data', data => event.channel.write(Uint8Array.from(data)))
event.channel.closed$.subscribe(() => socket.destroy())
socket.on('close', () => event.channel.close())
socket.on('connect', () => {
this.logger.info('Connection forwarded')
})
})
this.ssh.x11ChannelOpen$.subscribe(async event => {
this.logger.info(`Incoming X11 connection from ${event.clientAddress}:${event.clientPort}`)
const displaySpec = (this.config.store.ssh.x11Display || process.env.DISPLAY) ?? 'localhost:0'
this.logger.debug(`Trying display ${displaySpec}`)
const socket = new X11Socket()
try {
const x11Stream = await socket.connect(displaySpec)
this.logger.info('Connection forwarded')
event.channel.data$.subscribe(data => {
x11Stream.write(data)
})
x11Stream.on('data', data => {
event.channel.write(Uint8Array.from(data))
})
event.channel.closed$.subscribe(() => {
socket.destroy()
})
x11Stream.on('close', () => {
event.channel.close()
})
} catch (e) {
// eslint-disable-next-line @typescript-eslint/no-base-to-string
this.emitServiceMessage(colors.bgRed.black(' X ') + ` Could not connect to the X server: ${e}`)
this.emitServiceMessage(` Tabby tried to connect to ${JSON.stringify(X11Socket.resolveDisplaySpec(displaySpec))} based on the DISPLAY environment var (${displaySpec})`)
if (process.platform === 'win32') {
this.emitServiceMessage(' To use X forwarding, you need a local X server, e.g.:')
this.emitServiceMessage(' * VcXsrv: https://sourceforge.net/projects/vcxsrv/')
this.emitServiceMessage(' * Xming: https://sourceforge.net/projects/xming/')
}
event.channel.close()
}
})
}
private async verifyHostKey (key: russh.SshPublicKey): Promise<boolean> {
this.emitServiceMessage('Host key fingerprint:')
this.emitServiceMessage(colors.white.bgBlack(` ${key.algorithm()} `) + colors.bgBlackBright(' ' + key.fingerprint() + ' '))
if (!this.config.store.ssh.verifyHostKeys) {
return true
}
const selector = {
host: this.profile.options.host,
port: this.profile.options.port ?? 22,
type: key.algorithm(),
}
const keyDigest = crypto.createHash('sha256').update(key.bytes()).digest('base64')
const knownHost = this.knownHosts.getFor(selector)
if (!knownHost || knownHost.digest !== keyDigest) {
const modal = this.ngbModal.open(HostKeyPromptModalComponent)
modal.componentInstance.selector = selector
modal.componentInstance.digest = keyDigest
return modal.result.catch(() => false)
}
return true
}
emitServiceMessage (msg: string): void {
this.serviceMessage.next(msg)
this.logger.info(stripAnsi(msg))
}
emitKeyboardInteractivePrompt (prompt: KeyboardInteractivePrompt): void {
this.logger.info('Keyboard-interactive auth:', prompt.name, prompt.instruction)
this.emitServiceMessage(colors.bgBlackBright(' ') + ` Keyboard-interactive auth requested: ${prompt.name}`)
if (prompt.instruction) {
for (const line of prompt.instruction.split('\n')) {
this.emitServiceMessage(line)
}
}
this.keyboardInteractivePrompt.next(prompt)
}
async handleAuth (methodsLeft?: string[] | null): Promise<russh.AuthenticatedSSHClient|null> {
this.activePrivateKey = null
if (!(this.ssh instanceof russh.SSHClient)) {
throw new Error('Wrong state for auth handling')
}
if (!this.authUsername) {
throw new Error('No username')
}
while (true) {
const method = this.remainingAuthMethods.shift()
if (!method) {
return null
}
if (methodsLeft && !methodsLeft.includes(method.type) && method.type !== 'agent') {
// Agent can still be used even if not in methodsLeft
this.logger.info('Server does not support auth method', method.type)
continue
}
if (method.type === 'password') {
if (this.profile.options.password) {
this.emitServiceMessage(this.translate.instant('Using preset password'))
const result = await this.ssh.authenticateWithPassword(this.authUsername, this.profile.options.password)
if (result) {
return result
}
}
if (!this.keychainPasswordUsed && this.profile.options.user) {
const password = await this.passwordStorage.loadPassword(this.profile)
if (password) {
this.emitServiceMessage(this.translate.instant('Trying saved password'))
this.keychainPasswordUsed = true
const result = await this.ssh.authenticateWithPassword(this.authUsername, password)
if (result) {
return result
}
}
}
const modal = this.ngbModal.open(PromptModalComponent)
modal.componentInstance.prompt = `Password for ${this.authUsername}@${this.profile.options.host}`
modal.componentInstance.password = true
modal.componentInstance.showRememberCheckbox = true
try {
const promptResult = await modal.result.catch(() => null)
if (promptResult) {
if (promptResult.remember) {
this.savedPassword = promptResult.value
}
const result = await this.ssh.authenticateWithPassword(this.authUsername, promptResult.value)
if (result) {
return result
}
} else {
continue
}
} catch {
continue
}
}
if (method.type === 'publickey') {
try {
const key = await this.loadPrivateKey(method.name, method.contents)
const result = await this.ssh.authenticateWithKeyPair(this.authUsername, key)
if (result) {
return result
}
} catch (e) {
this.emitServiceMessage(colors.bgYellow.yellow.black(' ! ') + ` Failed to load private key ${method.name}: ${e}`)
continue
}
}
if (method.type === 'keyboard-interactive') {
let state: russh.AuthenticatedSSHClient|russh.KeyboardInteractiveAuthenticationState = await this.ssh.startKeyboardInteractiveAuthentication(this.authUsername)
while (true) {
if (state.state === 'failure') {
break
}
const prompts = state.prompts()
let responses: string[] = []
// OpenSSH can send a k-i request without prompts
// just respond ok to it
if (prompts.length > 0) {
const prompt = new KeyboardInteractivePrompt(
state.name,
state.instructions,
state.prompts(),
)
this.emitKeyboardInteractivePrompt(prompt)
try {
// eslint-disable-next-line @typescript-eslint/await-thenable
responses = await prompt.promise
} catch {
break // this loop
}
}
state = await this.ssh .continueKeyboardInteractiveAuthentication(responses)
if (state instanceof russh.AuthenticatedSSHClient) {
return state
}
}
}
if (method.type === 'agent') {
try {
const result = await this.ssh.authenticateWithAgent(this.authUsername, method)
if (result) {
return result
}
} catch (e) {
this.emitServiceMessage(colors.bgYellow.yellow.black(' ! ') + ` Failed to authenticate using agent: ${e}`)
continue
}
}
}
return null
}
async addPortForward (fw: ForwardedPort): Promise<void> {
if (fw.type === PortForwardType.Local || fw.type === PortForwardType.Dynamic) {
await fw.startLocalListener(async (accept, reject, sourceAddress, sourcePort, targetAddress, targetPort) => {
this.logger.info(`New connection on ${fw}`)
if (!(this.ssh instanceof russh.AuthenticatedSSHClient)) {
this.logger.error(`Connection while unauthenticated on ${fw}`)
reject()
return
}
const channel = await this.ssh.openTCPForwardChannel({
addressToConnectTo: targetAddress,
portToConnectTo: targetPort,
originatorAddress: sourceAddress ?? '127.0.0.1',
originatorPort: sourcePort ?? 0,
}).catch(err => {
this.emitServiceMessage(colors.bgRed.black(' X ') + ` Remote has rejected the forwarded connection to ${targetAddress}:${targetPort} via ${fw}: ${err}`)
reject()
throw err
})
const socket = accept()
channel.data$.subscribe(data => socket.write(data))
socket.on('data', data => channel.write(Uint8Array.from(data)))
channel.closed$.subscribe(() => socket.destroy())
socket.on('close', () => channel.close())
}).then(() => {
this.emitServiceMessage(colors.bgGreen.black(' -> ') + ` Forwarded ${fw}`)
this.forwardedPorts.push(fw)
}).catch(e => {
this.emitServiceMessage(colors.bgRed.black(' X ') + ` Failed to forward port ${fw}: ${e}`)
throw e
})
}
if (fw.type === PortForwardType.Remote) {
if (!(this.ssh instanceof russh.AuthenticatedSSHClient)) {
throw new Error('Cannot add remote port forward before auth')
}
try {
await this.ssh.forwardTCPPort(fw.host, fw.port)
} catch (err) {
// eslint-disable-next-line @typescript-eslint/no-base-to-string
this.emitServiceMessage(colors.bgRed.black(' X ') + ` Remote rejected port forwarding for ${fw}: ${err}`)
return
}
this.emitServiceMessage(colors.bgGreen.black(' <- ') + ` Forwarded ${fw}`)
this.forwardedPorts.push(fw)
}
}
async removePortForward (fw: ForwardedPort): Promise<void> {
if (fw.type === PortForwardType.Local || fw.type === PortForwardType.Dynamic) {
fw.stopLocalListener()
this.forwardedPorts = this.forwardedPorts.filter(x => x !== fw)
}
if (fw.type === PortForwardType.Remote) {
if (!(this.ssh instanceof russh.AuthenticatedSSHClient)) {
throw new Error('Cannot remove remote port forward before auth')
}
this.ssh.stopForwardingTCPPort(fw.host, fw.port)
this.forwardedPorts = this.forwardedPorts.filter(x => x !== fw)
}
this.emitServiceMessage(`Stopped forwarding ${fw}`)
}
async destroy (): Promise<void> {
this.logger.info('Destroying')
this.willDestroy.next()
this.willDestroy.complete()
this.serviceMessage.complete()
this.proxyCommandStream?.stop()
this.ssh.disconnect()
}
async openShellChannel (options: { x11: boolean }): Promise<russh.Channel> {
if (!(this.ssh instanceof russh.AuthenticatedSSHClient)) {
throw new Error('Cannot open shell channel before auth')
}
const ch = await this.ssh.openSessionChannel()
await ch.requestPTY('xterm-256color', {
columns: 80,
rows: 24,
pixHeight: 0,
pixWidth: 0,
})
if (options.x11) {
await ch.requestX11Forwarding({
singleConnection: false,
authProtocol: 'MIT-MAGIC-COOKIE-1',
authCookie: crypto.randomBytes(16).toString('hex'),
screenNumber: 0,
})
}
await ch.requestShell()
return ch
}
async loadPrivateKey (name: string, privateKeyContents: Buffer): Promise<russh.KeyPair> {
this.emitServiceMessage(`Loading private key: ${name}`)
this.activePrivateKey = await this.loadPrivateKeyWithPassphraseMaybe(privateKeyContents.toString())
return this.activePrivateKey
}
async loadPrivateKeyWithPassphraseMaybe (privateKey: string): Promise<russh.KeyPair> {
const keyHash = crypto.createHash('sha512').update(privateKey).digest('hex')
let triedSavedPassphrase = false
let passphrase: string|null = null
while (true) {
try {
return await russh.KeyPair.parse(privateKey, passphrase ?? undefined)
} catch (e) {
if (!triedSavedPassphrase) {
passphrase = await this.passwordStorage.loadPrivateKeyPassword(keyHash)
triedSavedPassphrase = true
continue
}
if (e.toString() === 'Error: Keys(KeyIsEncrypted)' || e.toString() === 'Error: Keys(SshKey(Crypto))') {
await this.passwordStorage.deletePrivateKeyPassword(keyHash)
const modal = this.ngbModal.open(PromptModalComponent)
modal.componentInstance.prompt = 'Private key passphrase'
modal.componentInstance.password = true
modal.componentInstance.showRememberCheckbox = true
const result = await modal.result.catch(() => {
throw new Error('Passphrase prompt cancelled')
})
passphrase = result?.value
if (passphrase && result.remember) {
this.passwordStorage.savePrivateKeyPassword(keyHash, passphrase)
}
} else {
this.notifications.error('Could not read the private key', e.toString())
throw e
}
}
}
}
ref (): void {
this.refCount++
}
unref (): void {
this.refCount--
if (this.refCount === 0) {
this.destroy()
}
}
}