mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
250 lines
7.0 KiB
TypeScript
250 lines
7.0 KiB
TypeScript
import { spawn, type ChildProcess } from 'node:child_process'
|
|
import { connect, createServer } from 'node:net'
|
|
import { buildSshArgs, findSystemSsh, type SystemSshBuildArgsOptions } from './ssh-system-fallback'
|
|
import type { SshTarget } from '../../shared/ssh-types'
|
|
|
|
export const SYSTEM_SSH_FORWARD_STARTUP_GRACE_MS = 750
|
|
export const SYSTEM_SSH_FORWARD_LISTENER_PROBE_INTERVAL_MS = 50
|
|
export const SYSTEM_SSH_FORWARD_STOP_TIMEOUT_MS = 2_000
|
|
|
|
export type SystemSshPortForwardProcess = {
|
|
process: ChildProcess
|
|
waitForStartup: () => Promise<void>
|
|
close: () => Promise<void>
|
|
dispose: () => void
|
|
}
|
|
|
|
export function spawnSystemSshPortForward(
|
|
target: SshTarget,
|
|
localPort: number,
|
|
remoteHost: string,
|
|
remotePort: number,
|
|
options?: SystemSshBuildArgsOptions
|
|
): ChildProcess {
|
|
const sshPath = findSystemSsh()
|
|
if (!sshPath) {
|
|
throw new Error(
|
|
'No system ssh binary found. Install OpenSSH to use system SSH port forwarding.'
|
|
)
|
|
}
|
|
|
|
const args = buildSshArgs(target, { ...options, suppressOrcaControlMaster: true })
|
|
const destinationIndex = args.lastIndexOf('--')
|
|
const forwardArgs = [
|
|
'-N',
|
|
'-o',
|
|
'ExitOnForwardFailure=yes',
|
|
'-L',
|
|
`127.0.0.1:${localPort}:${remoteHost}:${remotePort}`
|
|
]
|
|
if (destinationIndex === -1) {
|
|
args.unshift(...forwardArgs)
|
|
} else {
|
|
// Why: OpenSSH parses options only before `--`; after it, args are the
|
|
// destination and optional remote command.
|
|
args.splice(destinationIndex, 0, ...forwardArgs)
|
|
}
|
|
|
|
// Why: port-forward ssh processes are not wired to Orca credential prompts;
|
|
// system SSH forwards must authenticate via OpenSSH config, agent, or control socket.
|
|
return spawn(sshPath, args, {
|
|
stdio: ['ignore', 'ignore', 'pipe'],
|
|
windowsHide: true
|
|
})
|
|
}
|
|
|
|
export function startSystemSshPortForwardProcess(
|
|
target: SshTarget,
|
|
localPort: number,
|
|
remoteHost: string,
|
|
remotePort: number,
|
|
options?: SystemSshBuildArgsOptions
|
|
): Promise<SystemSshPortForwardProcess> {
|
|
return assertLocalForwardPortAvailable(localPort).then(() => {
|
|
const process = spawnSystemSshPortForward(target, localPort, remoteHost, remotePort, options)
|
|
return {
|
|
process,
|
|
waitForStartup: () => waitForSystemSshForwardStartup(process, localPort),
|
|
close: () => waitForSystemSshForwardStop(process),
|
|
dispose: () => {
|
|
try {
|
|
process.kill('SIGTERM')
|
|
} catch {
|
|
/* best-effort teardown */
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
export function assertLocalForwardPortAvailable(localPort: number): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
const server = createServer()
|
|
const cleanup = (): void => {
|
|
server.removeListener('error', onError)
|
|
server.removeListener('listening', onListening)
|
|
}
|
|
const onError = (err: Error): void => {
|
|
cleanup()
|
|
reject(new Error(`Local port 127.0.0.1:${localPort} is not available: ${err.message}`))
|
|
}
|
|
const onListening = (): void => {
|
|
cleanup()
|
|
server.close(() => resolve())
|
|
}
|
|
server.once('error', onError)
|
|
server.once('listening', onListening)
|
|
server.listen(localPort, '127.0.0.1')
|
|
})
|
|
}
|
|
|
|
export function waitForSystemSshForwardStartup(
|
|
process: ChildProcess,
|
|
localPort: number
|
|
): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
let stderr = ''
|
|
let settled = false
|
|
let probeTimer: ReturnType<typeof setTimeout> | null = null
|
|
let graceTimer: ReturnType<typeof setTimeout> | null = null
|
|
const cleanup = (): void => {
|
|
if (probeTimer) {
|
|
clearTimeout(probeTimer)
|
|
}
|
|
if (graceTimer) {
|
|
clearTimeout(graceTimer)
|
|
}
|
|
process.off('error', onError)
|
|
process.off('exit', onExit)
|
|
process.stderr?.off('data', onStderr)
|
|
}
|
|
const finish = (callback: () => void): void => {
|
|
if (settled) {
|
|
return
|
|
}
|
|
settled = true
|
|
cleanup()
|
|
callback()
|
|
}
|
|
const onStderr = (chunk: Buffer): void => {
|
|
stderr += chunk.toString('utf-8')
|
|
}
|
|
const onError = (error: Error): void => {
|
|
finish(() => reject(error))
|
|
}
|
|
const onExit = (code: number | null): void => {
|
|
finish(() => reject(systemSshForwardError(code, stderr)))
|
|
}
|
|
const scheduleProbe = (): void => {
|
|
probeTimer = setTimeout(() => {
|
|
probeLocalForward(localPort).then(
|
|
() => finish(resolve),
|
|
() => {
|
|
if (!settled) {
|
|
scheduleProbe()
|
|
}
|
|
}
|
|
)
|
|
}, SYSTEM_SSH_FORWARD_LISTENER_PROBE_INTERVAL_MS)
|
|
}
|
|
|
|
process.stderr?.on('data', onStderr)
|
|
process.once('error', onError)
|
|
process.once('exit', onExit)
|
|
graceTimer = setTimeout(() => {
|
|
finish(resolve)
|
|
}, SYSTEM_SSH_FORWARD_STARTUP_GRACE_MS)
|
|
scheduleProbe()
|
|
})
|
|
}
|
|
|
|
export function waitForSystemSshForwardStop(process: ChildProcess): Promise<void> {
|
|
return new Promise((resolve) => {
|
|
let settled = false
|
|
const cleanup = (): void => {
|
|
clearTimeout(escalationTimer)
|
|
process.off('exit', onExit)
|
|
}
|
|
const finish = (): void => {
|
|
if (settled) {
|
|
return
|
|
}
|
|
settled = true
|
|
cleanup()
|
|
resolve()
|
|
}
|
|
const onExit = (): void => {
|
|
finish()
|
|
}
|
|
const hasExited = (): boolean => process.exitCode !== null || process.signalCode !== null
|
|
const kill = (signal: NodeJS.Signals): void => {
|
|
try {
|
|
const sent = process.kill(signal)
|
|
if (!sent && hasExited()) {
|
|
finish()
|
|
}
|
|
} catch {
|
|
if (hasExited()) {
|
|
finish()
|
|
}
|
|
}
|
|
}
|
|
const escalationTimer = setTimeout(() => {
|
|
// Why: update/reconnect callers must not rebind while a stubborn ssh -L
|
|
// process still owns the local port.
|
|
kill('SIGKILL')
|
|
}, SYSTEM_SSH_FORWARD_STOP_TIMEOUT_MS)
|
|
|
|
process.once('exit', onExit)
|
|
kill('SIGTERM')
|
|
})
|
|
}
|
|
|
|
export function systemSshForwardError(code: number | null, stderr: string): Error {
|
|
const detail = bestErrorLine(stderr)
|
|
return new Error(
|
|
`System SSH port forward failed${code !== null ? ` (exit ${code})` : ''}${
|
|
detail ? `: ${detail}` : ''
|
|
}`
|
|
)
|
|
}
|
|
|
|
function bestErrorLine(stderr: string): string {
|
|
return (
|
|
stderr
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.findLast(Boolean) ?? ''
|
|
)
|
|
}
|
|
|
|
function probeLocalForward(localPort: number): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
const socket = connect({ host: '127.0.0.1', port: localPort })
|
|
const cleanup = (): void => {
|
|
socket.off('connect', onConnect)
|
|
socket.off('error', onError)
|
|
socket.off('timeout', onTimeout)
|
|
}
|
|
const onConnect = (): void => {
|
|
cleanup()
|
|
socket.destroy()
|
|
resolve()
|
|
}
|
|
const onError = (err: Error): void => {
|
|
cleanup()
|
|
socket.destroy()
|
|
reject(err)
|
|
}
|
|
const onTimeout = (): void => {
|
|
cleanup()
|
|
socket.destroy()
|
|
reject(new Error(`Timed out probing local forward on 127.0.0.1:${localPort}`))
|
|
}
|
|
socket.setTimeout(SYSTEM_SSH_FORWARD_LISTENER_PROBE_INTERVAL_MS)
|
|
socket.once('connect', onConnect)
|
|
socket.once('error', onError)
|
|
socket.once('timeout', onTimeout)
|
|
})
|
|
}
|