Files
orca/src/main/ssh/ssh-control-socket.ts
T
4dbc9f3817 feat(ssh): add ControlMaster multiplexing for system SSH transport (#6922)
* 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>
2026-07-01 21:29:48 -07:00

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'
}
}