mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 16:03:26 +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>
130 lines
4.0 KiB
TypeScript
130 lines
4.0 KiB
TypeScript
import { createHash } from 'node:crypto'
|
|
import { lstatSync, mkdirSync, rmSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { isAbsolute, join as pathJoin } from 'node:path'
|
|
import type { SshTarget } from '../../shared/ssh-types'
|
|
import type { SshResolvedConfig } from './ssh-config-parser'
|
|
|
|
export type SystemSshResolvedConfig = Pick<
|
|
SshResolvedConfig,
|
|
| 'hostname'
|
|
| 'port'
|
|
| 'user'
|
|
| 'identityFile'
|
|
| 'identityAgent'
|
|
| 'identitiesOnly'
|
|
| 'forwardAgent'
|
|
| 'proxyCommand'
|
|
| 'proxyJump'
|
|
| 'proxyUseFdpass'
|
|
| 'controlMaster'
|
|
| 'controlPath'
|
|
| 'controlPersist'
|
|
>
|
|
|
|
const OPENSSH_CONTROL_SOCKET_SUFFIX_BUDGET = 18
|
|
const UNIX_SOCKET_PATH_LIMIT = process.platform === 'darwin' ? 104 : 108
|
|
const CONTROL_SOCKET_PATH_MAX_LENGTH = UNIX_SOCKET_PATH_LIMIT - OPENSSH_CONTROL_SOCKET_SUFFIX_BUDGET
|
|
|
|
export function getControlSocketPath(
|
|
target: SshTarget,
|
|
resolvedConfig?: SystemSshResolvedConfig | null
|
|
): string | null {
|
|
if (process.platform === 'win32') {
|
|
return null
|
|
}
|
|
const uid = process.getuid?.()
|
|
if (uid === undefined) {
|
|
return null
|
|
}
|
|
|
|
const dir = findControlSocketDirectory(uid)
|
|
if (!dir) {
|
|
return null
|
|
}
|
|
|
|
// Why: include both persisted target fields and fresh ssh -G output so a
|
|
// live ControlPersist master is not reused after config-backed routes change.
|
|
const key = JSON.stringify({
|
|
target: {
|
|
id: target.id,
|
|
configHost: target.configHost || '',
|
|
host: target.host || '',
|
|
port: target.port || 22,
|
|
user: target.username || '',
|
|
proxyCommand: target.proxyCommand || '',
|
|
jumpHost: target.jumpHost || '',
|
|
identityFile: target.identityFile || '',
|
|
identityAgent: target.identityAgent || '',
|
|
identitiesOnly: target.identitiesOnly || false
|
|
},
|
|
resolved: normalizeResolvedConfig(resolvedConfig)
|
|
})
|
|
const hash = createHash('sha256').update(key).digest('hex').slice(0, 16)
|
|
const socketPath = pathJoin(dir, hash)
|
|
return socketPath.length <= CONTROL_SOCKET_PATH_MAX_LENGTH ? socketPath : null
|
|
}
|
|
|
|
export function removeControlSocketPath(socketPath: string): void {
|
|
try {
|
|
rmSync(socketPath, { force: true })
|
|
} catch {
|
|
// Best-effort stale socket cleanup; the retry can still use `-S none`.
|
|
}
|
|
}
|
|
|
|
function findControlSocketDirectory(uid: number): string | null {
|
|
const candidates = getControlSocketDirectoryCandidates(uid)
|
|
for (const dir of candidates) {
|
|
if (ensurePrivateDirectory(dir, uid)) {
|
|
return dir
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
function getControlSocketDirectoryCandidates(uid: number): string[] {
|
|
const candidates: string[] = []
|
|
const xdgRuntimeDir = process.env.XDG_RUNTIME_DIR
|
|
if (xdgRuntimeDir && isAbsolute(xdgRuntimeDir)) {
|
|
candidates.push(pathJoin(xdgRuntimeDir, 'orca-ssh'))
|
|
}
|
|
candidates.push(pathJoin(tmpdir(), `orca-ssh-${uid}`))
|
|
return candidates
|
|
}
|
|
|
|
function ensurePrivateDirectory(dir: string, uid: number): boolean {
|
|
try {
|
|
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
// Why: mkdir mode is ignored for pre-existing dirs; lstat rejects symlink
|
|
// swaps and the owner/perms check avoids exposing the mux socket cross-user.
|
|
const st = lstatSync(dir)
|
|
return st.isDirectory() && st.uid === uid && (st.mode & 0o77) === 0
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
function normalizeResolvedConfig(
|
|
resolvedConfig: SystemSshResolvedConfig | null | undefined
|
|
): Record<string, unknown> | null {
|
|
if (!resolvedConfig) {
|
|
return null
|
|
}
|
|
return {
|
|
hostname: resolvedConfig.hostname || '',
|
|
port: resolvedConfig.port || 22,
|
|
user: resolvedConfig.user || '',
|
|
identityFile: resolvedConfig.identityFile ?? [],
|
|
identityAgent: resolvedConfig.identityAgent || '',
|
|
identitiesOnly: resolvedConfig.identitiesOnly || false,
|
|
forwardAgent: resolvedConfig.forwardAgent || false,
|
|
proxyCommand: resolvedConfig.proxyCommand || '',
|
|
proxyJump: resolvedConfig.proxyJump || '',
|
|
proxyUseFdpass: resolvedConfig.proxyUseFdpass || false,
|
|
controlMaster: resolvedConfig.controlMaster || 'no',
|
|
controlPath: resolvedConfig.controlPath || '',
|
|
controlPersist: resolvedConfig.controlPersist || 'no'
|
|
}
|
|
}
|