mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 08:03:13 +00:00
* feat(ssh): add ControlMaster multiplexing for system SSH transport System SSH transport spawns a new OpenSSH process per exec command (platform detect, relay install check, node resolution, relay launch, socket probe). Each process pays the full SSH handshake cost — ~9s on Uber devpods — making a typical relay connect take 54s+ and reliably exceeding the 15s startup reconnect budget. Add SSH ControlMaster multiplexing via a per-target socket in $TMPDIR/orca-ssh-ctl/<hash>.sock. The first command establishes the master; subsequent commands reuse it at ~100ms per exec instead of ~9s. ControlPersist=300 keeps the master alive after commands exit so rapid reconnects (e.g. on tab focus) also benefit. Windows is excluded since OpenSSH's ControlMaster support there is limited. * fix(ssh): address ControlMaster key collision and directory permission risks - Use target.id in the socket key so distinct SSH targets can never collide even when configHost/port/user happen to match - Switch from SHA1 to SHA256 and extend hash slice from 12 to 16 chars - Stat the control-socket directory after mkdirSync to reject pre-existing dirs that are symlinks, foreign-owned, or have group/other write bits (mkdirSync mode is ignored on pre-existing dirs) - Update two tests that used exact spawn-arg arrays; replace with ordering assertions (forward flags before --) that stay correct regardless of which extra ControlMaster options are injected * fix(ssh): bind ControlPath identity to route and reject symlinked ctl dir Fold proxyCommand/jumpHost/identity fields into the ControlPath hash so a target whose route is edited no longer reuses a still-alive master built on the old route. Switch the control-socket dir check from statSync to lstatSync so a planted symlink fails the directory validation outright. * test(ssh): drop tautological argv re-assertion in spawn checks The toHaveBeenCalledWith re-passed the args array extracted from the same mock call, making that argument position always pass. argv content is already verified by the index-ordering assertions above; use expect.any(Array) so the spawn check only claims what it actually verifies (binary path, stdio). * fix(ssh): harden system ssh connection reuse Co-authored-by: Orca <help@stably.ai> * test(ssh): isolate control socket runtime dir Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Test <test@example.com> Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com> Co-authored-by: Orca <help@stably.ai>
160 lines
4.4 KiB
TypeScript
160 lines
4.4 KiB
TypeScript
import { spawn, type ChildProcess } from 'node:child_process'
|
|
import { Duplex } from 'node:stream'
|
|
import type { ClientChannel } from 'ssh2'
|
|
import type { SshTarget } from '../../shared/ssh-types'
|
|
import { wrapRemoteCommandForPosixShell, type SshExecOptions } from './ssh-connection-utils'
|
|
import { buildSshArgs, type SystemSshBuildArgsOptions } from './system-ssh-args'
|
|
import { findSystemSsh } from './system-ssh-binary'
|
|
|
|
export type SystemSshProcess = {
|
|
stdin: NodeJS.WritableStream
|
|
stdout: NodeJS.ReadableStream
|
|
stderr: NodeJS.ReadableStream
|
|
kill: () => void
|
|
onExit: (cb: (code: number | null) => void) => void
|
|
pid: number | undefined
|
|
}
|
|
|
|
export type SystemSshCommandChannel = ClientChannel & {
|
|
_process?: ChildProcess
|
|
}
|
|
|
|
type SystemSshCommandOptions = SshExecOptions & SystemSshBuildArgsOptions
|
|
|
|
/**
|
|
* Spawn a system ssh process connecting to the given target.
|
|
* Used when ssh2 cannot handle the auth method (FIDO2, ControlMaster).
|
|
*
|
|
* The returned process's stdin/stdout are used as the transport for
|
|
* the relay's JSON-RPC protocol, exactly like an ssh2 channel.
|
|
*/
|
|
export function spawnSystemSsh(
|
|
target: SshTarget,
|
|
options?: SystemSshBuildArgsOptions
|
|
): SystemSshProcess {
|
|
const sshPath = findSystemSsh()
|
|
if (!sshPath) {
|
|
throw new Error(
|
|
'No system ssh binary found. Install OpenSSH to use FIDO2 keys or ControlMaster.'
|
|
)
|
|
}
|
|
|
|
const args = buildSshArgs(target, options)
|
|
const proc = spawn(sshPath, args, {
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
windowsHide: true
|
|
})
|
|
|
|
return wrapChildProcess(proc)
|
|
}
|
|
|
|
export function spawnSystemSshCommand(
|
|
target: SshTarget,
|
|
command: string,
|
|
options?: SystemSshCommandOptions
|
|
): SystemSshCommandChannel {
|
|
const sshPath = findSystemSsh()
|
|
if (!sshPath) {
|
|
throw new Error(
|
|
'No system ssh binary found. Install OpenSSH to use ProxyUseFdpass, FIDO2 keys, or ControlMaster.'
|
|
)
|
|
}
|
|
|
|
const remoteCommand =
|
|
options?.wrapCommand === false ? command : wrapRemoteCommandForPosixShell(command)
|
|
const proc = spawn(sshPath, [...buildSshArgs(target, options), remoteCommand], {
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
windowsHide: true
|
|
})
|
|
return wrapCommandProcess(proc)
|
|
}
|
|
|
|
function wrapChildProcess(proc: ChildProcess): SystemSshProcess {
|
|
return {
|
|
stdin: proc.stdin!,
|
|
stdout: proc.stdout!,
|
|
stderr: proc.stderr!,
|
|
pid: proc.pid,
|
|
kill: () => {
|
|
try {
|
|
proc.kill('SIGTERM')
|
|
} catch {
|
|
// Process may already be dead
|
|
}
|
|
},
|
|
onExit: (cb) => {
|
|
proc.on('exit', (code) => cb(code))
|
|
}
|
|
}
|
|
}
|
|
|
|
function wrapCommandProcess(proc: ChildProcess): SystemSshCommandChannel {
|
|
const duplex = new Duplex({
|
|
read() {},
|
|
write(chunk, encoding, cb) {
|
|
proc.stdin!.write(chunk, encoding, cb)
|
|
}
|
|
})
|
|
const channel = duplex as unknown as SystemSshCommandChannel
|
|
|
|
const mutableChannel = channel as unknown as {
|
|
stdin: NodeJS.WritableStream
|
|
stderr: NodeJS.ReadableStream
|
|
_process?: ChildProcess
|
|
close: () => void
|
|
}
|
|
mutableChannel.stdin = proc.stdin!
|
|
mutableChannel.stderr = proc.stderr!
|
|
mutableChannel._process = proc
|
|
mutableChannel.close = () => {
|
|
try {
|
|
proc.kill('SIGTERM')
|
|
} catch {
|
|
// Process may already be dead
|
|
}
|
|
}
|
|
|
|
const cleanupProcessListeners = (): void => {
|
|
proc.stdout!.off('data', onStdoutData)
|
|
proc.stdout!.off('end', onStdoutEnd)
|
|
proc.off('exit', onExit)
|
|
proc.off('close', onClose)
|
|
proc.off('error', onProcessError)
|
|
proc.stdin!.off('error', onStreamError)
|
|
proc.stdout!.off('error', onStreamError)
|
|
}
|
|
const fail = (err: Error): void => {
|
|
cleanupProcessListeners()
|
|
duplex.destroy(err)
|
|
}
|
|
const onStdoutData = (data: Buffer): void => {
|
|
duplex.push(data)
|
|
}
|
|
const onStdoutEnd = (): void => {
|
|
duplex.push(null)
|
|
}
|
|
const onExit = (code: number | null, signal?: NodeJS.Signals | null): void => {
|
|
channel.emit('exit', code, signal)
|
|
}
|
|
const onClose = (code: number | null, signal?: NodeJS.Signals | null): void => {
|
|
cleanupProcessListeners()
|
|
channel.emit('close', code, signal)
|
|
}
|
|
const onProcessError = (err: Error): void => {
|
|
fail(err)
|
|
}
|
|
const onStreamError = (err: Error): void => {
|
|
fail(err)
|
|
}
|
|
|
|
proc.stdout!.on('data', onStdoutData)
|
|
proc.stdout!.on('end', onStdoutEnd)
|
|
proc.on('exit', onExit)
|
|
proc.on('close', onClose)
|
|
proc.on('error', onProcessError)
|
|
proc.stdin!.on('error', onStreamError)
|
|
proc.stdout!.on('error', onStreamError)
|
|
|
|
return channel
|
|
}
|