perf(ssh): opt-in proxy connection reuse for system SSH on Windows (#33030)

* perf(ssh): reuse one proxy connection for system SSH on Windows

Windows OpenSSH cannot multiplex (ControlMaster fails with "getsockname
failed: Not a socket"), so every system-SSH exec for a ProxyCommand or
ProxyJump target re-ran the proxy handshake. Hold one `ssh -N -L` through
the real proxy per session and dial later execs over 127.0.0.1 with
HostName/Port/ProxyCommand=none/ProxyJump=none/HostKeyAlias overrides.

The route starts after connect, is proven with a login before use, falls
back to per-command proxying on any failure, honors
systemSshConnectionReuse: false, and is torn down with its proxy child.

Rebased from #31731 (commits b1cdd46, 3835c2d, 6268f2f) onto current main,
including the pending-route cancellation fix from b301b6a.

Co-authored-by: Neil <neil@stably.ai>

* refactor(ssh): stop the loopback forward through process-host's tree kill

Reuse signalProcessTree instead of a hand-rolled taskkill-then-kill
fallback: it already joins an in-flight tree kill, refuses a reaped PID,
and falls back to killing the root, so the route keeps no parallel copy
of that logic.

* fix(ssh): harden the Windows loopback route against review findings

- Manual targets: take the forward's sshd port and the HostKeyAlias from
  the stored endpoint; `ssh -G` ran on the label and named another host.
- Hold the forward with ServerAlive probes so a silently dead proxy ends
  it and commands fall back instead of stalling on 127.0.0.1.
- Never route forwards: their argv must stay stable for orphan recovery,
  and the route's own forward must take the real proxy.
- Upload sessions read the route per file, so an exited route's port is
  not dialled after it closes.
- Skip the route for security-key identities, whose forward and probe
  logins would each ask for an unprompted touch.

* fix(ssh): keep the loopback route for manual targets no Host block claims

The unclaimed-alias endpoint rewrite only applies to config-backed targets,
so manual targets with a configHost no block names stay eligible.

* fix(ssh): keep the held loopback forward non-interactive

The forward is an optional background login; with BatchMode=no a password or
passphrase login raised an extra prompt right after connect, which the
non-interactive probe then discarded anyway.

* fix(ssh): admit the loopback route only when it changes nothing but the route

Overriding HostName to 127.0.0.1 changes which ssh_config blocks match, so a
`Match host <real host>` block could silently switch the remote User (or
identities, certificates, known-hosts files) for every routed command. Run
`ssh -G` with and without the route options and refuse the route on any
difference outside HostName/Port/HostKeyAlias/ProxyCommand/ProxyJump.

Also ask the agent with `ssh-add -L` and refuse the route when it may hold a
security key, so the background forward and probe never request a touch.
Both checks run through process-host and fail closed.

* feat(ssh): make Windows proxy connection reuse an opt-in per-target setting

The loopback route rewrites HostName, which ssh_config can key settings on,
so it no longer turns on by itself: a new optional systemSshProxyConnectionReuse
field (default off, Windows only) enables it from the SSH host's Advanced
settings. Old clients and servers ignore or omit the field.

Admission stays as the safety net and now:
- takes the forward's sshd port and HostKeyAlias from the direct `ssh -G`,
  so a manual host's own Host block cannot send the route to another sshd;
- refuses any non-routing value with a `%` token, which `ssh -G` can leave
  unexpanded while a routed login expands `%h` to 127.0.0.1;
- checks the security-key and agent identities of every ProxyJump hop;
- asserts the routed config really dials 127.0.0.1 with no proxy left.

* fix(ssh): offer proxy reuse only for hosts this desktop dials, refuse ${VAR} paths

- The switch now appears only for this desktop's own SSH hosts. A paired
  server's add-host dialog never shows or sends it, so an older server's
  strict schema is never handed the new field, and a client's platform no
  longer decides a server's setting.
- Admission also refuses `${VAR}` references, which `ssh -G` prints
  unexpanded while a login expands them.
- Advanced no longer auto-opens just for the opt-in.

---------

Co-authored-by: Bingxin Xu <xubingxin.xbx@gmail.com>
This commit is contained in:
Neil
2026-10-23 21:22:08 -07:00
committed by GitHub
co-authored by Bingxin Xu
parent 77efa5aeeb
commit cd2eeebe3e
27 changed files with 1780 additions and 20 deletions
@@ -102,7 +102,6 @@ src/main/ssh/ssh-proxy-command.ts
src/main/ssh/system-transport/system-ssh-command.ts
src/main/ssh/system-transport/system-ssh-dynamic-forward-process.ts
src/main/ssh/system-transport/system-ssh-file-transfer.ts
src/main/ssh/system-transport/system-ssh-forward-process.ts
src/main/ssh/system-transport/system-ssh-operation-lifecycle.ts
src/main/startup/ensure-virtual-display.ts
src/main/windows-process-tree-kill.ts
@@ -264,6 +264,33 @@ describe('Store', () => {
expect(updatedTarget).not.toHaveProperty('systemSshConnectionReuse')
})
it('persists only an explicit opt-in to Windows proxy connection reuse', async () => {
const store = await createStore()
store.addSshTarget({
id: 'ssh-proxy-reuse-on',
label: 'Proxy reuse on',
host: 'proxied.example.com',
port: 22,
username: 'dev',
systemSshProxyConnectionReuse: true
})
store.addSshTarget({
id: 'ssh-proxy-reuse-off',
label: 'Proxy reuse off',
host: 'direct.example.com',
port: 22,
username: 'dev',
systemSshProxyConnectionReuse: false
})
store.flush()
const persisted = readDataFile() as { sshTargets?: Record<string, unknown>[] }
const on = persisted.sshTargets?.find((t) => t.id === 'ssh-proxy-reuse-on')
const off = persisted.sshTargets?.find((t) => t.id === 'ssh-proxy-reuse-off')
expect(on?.systemSshProxyConnectionReuse).toBe(true)
expect(off).not.toHaveProperty('systemSshProxyConnectionReuse')
})
it('persists known relay runtime choices and drops unknown ones', async () => {
const store = await createStore()
store.addSshTarget({
@@ -27,6 +27,7 @@ export function normalizeSshTarget(t: SshTarget): SshTarget {
const currentGracePeriodSeconds = target.relayGracePeriodSeconds
const legacyGracePeriodSeconds = target.remoteWorkspaceSyncGracePeriodSeconds
const systemSshConnectionReuse = target.systemSshConnectionReuse
const systemSshProxyConnectionReuse = target.systemSshProxyConnectionReuse
const remoteRuntime = target.remoteRuntime
const remoteRuntimeResolution = normalizeSshRemoteRuntimeResolution(
target.remoteRuntimeResolution
@@ -36,6 +37,7 @@ export function normalizeSshTarget(t: SshTarget): SshTarget {
delete target.remoteWorkspaceSyncGracePeriodSeconds
delete target.relayGracePeriodSeconds
delete target.systemSshConnectionReuse
delete target.systemSshProxyConnectionReuse
delete target.remoteRuntime
delete target.remoteRuntimeResolution
delete target.experimentalPtySourceCreditV1
@@ -60,6 +62,9 @@ export function normalizeSshTarget(t: SshTarget): SshTarget {
if (systemSshConnectionReuse === false) {
normalized.systemSshConnectionReuse = false
}
if (systemSshProxyConnectionReuse === true) {
normalized.systemSshProxyConnectionReuse = true
}
// Known values survive, so an explicit Host Node choice outlives a later default flip;
// an unknown value from a newer build must not change the runtime.
const knownRuntime = SSH_REMOTE_RUNTIMES.find((runtime) => runtime === remoteRuntime)
+1
View File
@@ -61,6 +61,7 @@ function pickSshEditableFields(source: SshEditableTargetInput): SshEditableTarge
jumpHost: source.jumpHost,
relayGracePeriodSeconds: source.relayGracePeriodSeconds,
systemSshConnectionReuse: source.systemSshConnectionReuse,
systemSshProxyConnectionReuse: source.systemSshProxyConnectionReuse,
remoteRuntime: source.remoteRuntime,
allowRemoteCliControl: source.allowRemoteCliControl
} satisfies Record<SshEditableTargetField, unknown>
@@ -102,7 +102,9 @@ export async function createSshFileUploadSession(
signal,
hostPlatform: options?.hostPlatform,
exclusive: uploadOptions?.exclusive,
...buildArgsOptions
...buildArgsOptions,
// Why live: the loopback route can die mid-session, and a dead route's port must not be dialled.
loopbackRoute: host.systemSshBuildArgsOptions().loopbackRoute
}),
close: () => {}
}
@@ -0,0 +1,124 @@
import { EventEmitter } from 'node:events'
import { PassThrough } from 'node:stream'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { resetSshConnectionMocks } from './ssh-connection-test-harness'
import { createCallbacks, createTarget } from './ssh-connection-test-fixtures'
import { SshConnection } from './ssh-connection'
const { startForwardMock, waitForwardMock } = vi.hoisted(() => ({
startForwardMock: vi.fn(),
waitForwardMock: vi.fn()
}))
vi.mock('ssh2', async () =>
(await import('../__tests__/ssh-connection-test-client')).createSsh2Module()
)
vi.mock('../system-transport/system-ssh-binary', async () =>
(await import('./ssh-connection-test-harness')).createSystemSshBinaryModule()
)
vi.mock('../system-transport/system-ssh-args', async (importOriginal) =>
(await import('./ssh-connection-test-harness')).withSystemSshArgsMocks(await importOriginal())
)
vi.mock('../system-transport/system-ssh-command', async (importOriginal) =>
(await import('./ssh-connection-test-harness')).withSystemSshCommandMocks(await importOriginal())
)
vi.mock('../ssh-control-socket', async () =>
(await import('./ssh-connection-test-harness')).createControlSocketModule()
)
vi.mock('../config/ssh-config-parser', async () =>
(await import('./ssh-connection-test-harness')).createSshConfigParserModule()
)
vi.mock('../config/ssh-g-config-resolution', async (importOriginal) =>
(await import('./ssh-connection-test-harness')).withSshGConfigResolutionMocks(
await importOriginal()
)
)
// Why: admission would otherwise run this machine's ssh -G and ssh-add.
vi.mock('../system-transport/system-ssh-loopback-route-admission', () => ({
admitSystemSshLoopbackRoute: async () => ({
admitted: true,
sshdPort: 22,
hostKeyAlias: 'example.com'
})
}))
vi.mock('../system-transport/system-ssh-loopback-route', async (importOriginal) => ({
...(await importOriginal()),
shouldUseSystemSshLoopbackRoute: () => true
}))
vi.mock('../system-transport/system-ssh-forward-process', () => ({
startSystemSshPortForwardProcess: startForwardMock,
waitForSystemSshForwardStartup: waitForwardMock
}))
describe('pending real loopback route startup ownership', () => {
beforeEach(() => {
resetSshConnectionMocks()
startForwardMock.mockReset()
waitForwardMock.mockReset()
})
it('disconnect stops the already spawned forward before startup completes', async () => {
resetSshConnectionMocks()
const dispose = vi.fn()
const process = Object.assign(new EventEmitter(), {
pid: undefined,
exitCode: null,
signalCode: null,
stderr: new PassThrough()
})
startForwardMock.mockResolvedValue({ process, localPort: 50001, dispose })
let failStartup = (_error: Error): void => {}
waitForwardMock.mockImplementation(
() =>
new Promise((_resolve, reject) => {
failStartup = reject
})
)
const conn = new SshConnection(
createTarget({ proxyCommand: 'ssh -W %h:%p bastion.example.com' }),
createCallbacks()
)
await conn.connect()
await vi.waitFor(() => expect(waitForwardMock).toHaveBeenCalled())
await conn.disconnect()
const stoppedAtDisconnect = dispose.mock.calls.length
failStartup(new Error('review cleanup: interrupted slow handshake'))
await vi.waitFor(() => expect(dispose).toHaveBeenCalled())
expect(stoppedAtDisconnect).toBeGreaterThan(0)
})
it('cancels the superseded forward while keeping the new startup owned', async () => {
const createForward = () => ({
process: Object.assign(new EventEmitter(), {
pid: undefined,
exitCode: null,
signalCode: null
}),
localPort: 50001,
dispose: vi.fn()
})
const stale = createForward()
const current = createForward()
startForwardMock.mockResolvedValueOnce(stale).mockResolvedValueOnce(current)
const failures: ((error: Error) => void)[] = []
waitForwardMock.mockImplementation(
() => new Promise((_resolve, reject) => failures.push(reject))
)
const conn = new SshConnection(
createTarget({ proxyCommand: 'ssh -W %h:%p bastion.example.com' }),
createCallbacks()
)
await conn.connect()
await vi.waitFor(() => expect(waitForwardMock).toHaveBeenCalledTimes(1))
await conn.reconnect()
await vi.waitFor(() => expect(waitForwardMock).toHaveBeenCalledTimes(2))
expect(stale.dispose).toHaveBeenCalledTimes(1)
expect(current.dispose).not.toHaveBeenCalled()
failures[0]?.(new Error('superseded handshake'))
await Promise.resolve()
await Promise.resolve()
await conn.disconnect()
expect(current.dispose).toHaveBeenCalledTimes(1)
failures[1]?.(new Error('disconnected handshake'))
await Promise.resolve()
expect(conn.getSystemSshBuildArgsOptions().loopbackRoute).toBeUndefined()
})
})
@@ -0,0 +1,180 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { resetSshConnectionMocks } from './ssh-connection-test-harness'
import { createCallbacks, createTarget } from './ssh-connection-test-fixtures'
import { SshConnection } from './ssh-connection'
import type { SystemSshLoopbackRoute } from '../system-transport/system-ssh-loopback-route'
import { uploadFileViaSystemSsh } from '../system-transport/system-ssh-file-binary-transfer'
const { startRouteMock } = vi.hoisted(() => ({ startRouteMock: vi.fn() }))
vi.mock('ssh2', async () =>
(await import('../__tests__/ssh-connection-test-client')).createSsh2Module()
)
vi.mock('../system-transport/system-ssh-binary', async () =>
(await import('./ssh-connection-test-harness')).createSystemSshBinaryModule()
)
vi.mock('../system-transport/system-ssh-args', async (importOriginal) =>
(await import('./ssh-connection-test-harness')).withSystemSshArgsMocks(await importOriginal())
)
vi.mock('../system-transport/system-ssh-command', async (importOriginal) =>
(await import('./ssh-connection-test-harness')).withSystemSshCommandMocks(await importOriginal())
)
vi.mock('../ssh-control-socket', async () =>
(await import('./ssh-connection-test-harness')).createControlSocketModule()
)
vi.mock('../system-transport/system-ssh-file-binary-transfer', async (importOriginal) =>
(await import('./ssh-connection-test-harness')).withSystemSshFileBinaryTransferMocks(
await importOriginal()
)
)
vi.mock('../config/ssh-config-parser', async () =>
(await import('./ssh-connection-test-harness')).createSshConfigParserModule()
)
vi.mock('../config/ssh-g-config-resolution', async (importOriginal) =>
(await import('./ssh-connection-test-harness')).withSshGConfigResolutionMocks(
await importOriginal()
)
)
// Why the gate is forced on: it is platform-gated to win32 and covered in its own suite; this suite is the lifecycle.
vi.mock('../system-transport/system-ssh-loopback-route', () => ({
shouldUseSystemSshLoopbackRoute: () => true,
startSystemSshLoopbackRoute: startRouteMock
}))
function createRoute(localPort: number): SystemSshLoopbackRoute {
return {
args: { localPort, hostKeyAlias: 'example.com' },
onExit: vi.fn(),
dispose: vi.fn()
}
}
function deferRouteStart(): (route: SystemSshLoopbackRoute) => void {
let resolveRoute: (route: SystemSshLoopbackRoute) => void = () => undefined
startRouteMock.mockImplementationOnce(
() =>
new Promise<SystemSshLoopbackRoute>((resolve) => {
resolveRoute = resolve
})
)
return (route) => resolveRoute(route)
}
describe('SshConnection loopback route', () => {
beforeEach(() => {
resetSshConnectionMocks()
startRouteMock.mockReset()
})
it('routes later commands through the route once it is proven', async () => {
const finishStart = deferRouteStart()
const conn = new SshConnection(
createTarget({ proxyCommand: 'ssh -W %h:%p bastion.example.com' }),
createCallbacks()
)
await conn.connect()
expect(conn.getSystemSshBuildArgsOptions().loopbackRoute).toBeUndefined()
finishStart(createRoute(50001))
await vi.waitFor(() =>
expect(conn.getSystemSshBuildArgsOptions().loopbackRoute).toEqual({
localPort: 50001,
hostKeyAlias: 'example.com'
})
)
})
it('still builds a route for a reconnect that superseded a start in flight', async () => {
const finishStaleStart = deferRouteStart()
const finishCurrentStart = deferRouteStart()
const conn = new SshConnection(
createTarget({ proxyCommand: 'ssh -W %h:%p bastion.example.com' }),
createCallbacks()
)
await conn.connect()
await conn.reconnect()
expect(conn.getState().status).toBe('connected')
expect(startRouteMock).toHaveBeenCalledTimes(2)
const stale = createRoute(50001)
finishStaleStart(stale)
await vi.waitFor(() => expect(stale.dispose).toHaveBeenCalled())
expect(conn.getSystemSshBuildArgsOptions().loopbackRoute).toBeUndefined()
finishCurrentStart(createRoute(50002))
await vi.waitFor(() =>
expect(conn.getSystemSshBuildArgsOptions().loopbackRoute?.localPort).toBe(50002)
)
})
it('drops the route when its forward exits, so commands fall back to the proxy', async () => {
const finishStart = deferRouteStart()
const conn = new SshConnection(
createTarget({ proxyCommand: 'ssh -W %h:%p bastion.example.com' }),
createCallbacks()
)
await conn.connect()
const route = createRoute(50001)
finishStart(route)
await vi.waitFor(() => expect(route.onExit).toHaveBeenCalled())
const onExit = vi.mocked(route.onExit).mock.calls[0]?.[0]
onExit?.()
expect(conn.getSystemSshBuildArgsOptions().loopbackRoute).toBeUndefined()
})
it('stops an open upload session from dialling a route that has since exited', async () => {
const finishStart = deferRouteStart()
const conn = new SshConnection(
createTarget({ proxyCommand: 'ssh -W %h:%p bastion.example.com' }),
createCallbacks()
)
await conn.connect()
const route = createRoute(50001)
finishStart(route)
await vi.waitFor(() => expect(route.onExit).toHaveBeenCalled())
const uploadSession = await conn.openFileUploadSession()
vi.mocked(route.onExit).mock.calls[0]?.[0]?.()
await uploadSession.uploadFile('/tmp/a.txt', '/remote/a.txt')
expect(vi.mocked(uploadFileViaSystemSsh).mock.calls.at(-1)?.[3]?.loopbackRoute).toBeUndefined()
})
it('disposes the route on disconnect', async () => {
const finishStart = deferRouteStart()
const conn = new SshConnection(
createTarget({ proxyCommand: 'ssh -W %h:%p bastion.example.com' }),
createCallbacks()
)
await conn.connect()
const route = createRoute(50001)
finishStart(route)
await vi.waitFor(() => expect(conn.getSystemSshBuildArgsOptions().loopbackRoute).toBeDefined())
await conn.disconnect()
expect(route.dispose).toHaveBeenCalled()
})
it('does not start a route for a connection torn down as it reported connected', async () => {
let conn: SshConnection | null = null
conn = new SshConnection(
createTarget({ proxyCommand: 'ssh -W %h:%p bastion.example.com' }),
createCallbacks({
onStateChange: (_id, state) => {
if (state.status === 'connected') {
void conn?.disconnect({ quiet: true })
}
}
})
)
await conn.connect().catch(() => undefined)
expect(startRouteMock).not.toHaveBeenCalled()
})
})
+67 -1
View File
@@ -24,6 +24,11 @@ import {
isOpenSshConfigBackedTarget,
type SystemSshBuildArgsOptions
} from '../system-transport/system-ssh-args'
import {
shouldUseSystemSshLoopbackRoute,
startSystemSshLoopbackRoute,
type SystemSshLoopbackRoute
} from '../system-transport/system-ssh-loopback-route'
import { removeControlSocketPath } from '../ssh-control-socket'
import { INITIAL_RETRY_ATTEMPTS, INITIAL_RETRY_DELAY_MS, sleep } from './ssh-connection-timing'
import {
@@ -100,6 +105,9 @@ export class SshConnection {
} | null = null
private systemSshControlMasterDisabledForSession = false
private systemSshGssapiOnlyForSession = false
private systemSshLoopbackRoute: SystemSshLoopbackRoute | null = null
private systemSshLoopbackRouteStartingGeneration: number | null = null
private systemSshLoopbackRouteAbortController: AbortController | null = null
private useSystemSshTransport = false
private credentialAbortController = new AbortController()
private readonly pendingSsh2Clients = new Set<SshClient>()
@@ -576,7 +584,10 @@ export class SshConnection {
target: this.target,
resolvedConfig: () => this.systemSshResolvedConfig,
isCurrentAttempt: () => this.isCurrentConnectAttempt(connectGeneration),
onConnected: () => this.setState('connected'),
onConnected: () => {
this.setState('connected')
this.startSystemSshLoopbackRoute(connectGeneration)
},
untrackChannel: (probeChannel) => this.systemCommandChannels.delete(probeChannel)
})
} catch (err) {
@@ -651,9 +662,59 @@ export class SshConnection {
if (this.systemSshGssapiOnlyForSession) {
options.gssapiOnly = true
}
if (this.systemSshLoopbackRoute) {
options.loopbackRoute = this.systemSshLoopbackRoute.args
}
return options
}
/** Off the connect path: commands keep the direct proxy route until the forward is proven. */
private startSystemSshLoopbackRoute(connectGeneration: number): void {
const options = this.getSystemSshBuildArgsOptions()
if (
!this.isCurrentConnectAttempt(connectGeneration) ||
!this.useSystemSshTransport ||
this.systemSshLoopbackRoute ||
this.systemSshLoopbackRouteStartingGeneration === connectGeneration ||
!shouldUseSystemSshLoopbackRoute(this.target, this.systemSshResolvedConfig, options)
) {
return
}
// Why per generation: a start a reconnect superseded can still be in flight, and must not block the new one.
this.systemSshLoopbackRouteStartingGeneration = connectGeneration
const controller = new AbortController()
this.systemSshLoopbackRouteAbortController = controller
const settle = (): void => {
if (this.systemSshLoopbackRouteAbortController === controller) {
this.systemSshLoopbackRouteStartingGeneration = null
this.systemSshLoopbackRouteAbortController = null
}
}
startSystemSshLoopbackRoute(this.target, options, process.platform, controller.signal).then(
(route) => {
settle()
if (
controller.signal.aborted ||
!this.isCurrentConnectAttempt(connectGeneration) ||
!this.useSystemSshTransport
) {
route.dispose()
return
}
this.systemSshLoopbackRoute = route
route.onExit(() => {
if (this.systemSshLoopbackRoute === route) {
this.systemSshLoopbackRoute = null
}
})
},
() => {
// Why: per-command proxy logins still work; the route only makes them cheaper.
settle()
}
)
}
// Why: ssh2 may destroy the proxy socket on auth failure, so credential retries need a fresh proxy process and Duplex stream.
private respawnProxy(
config: ConnectConfig,
@@ -794,6 +855,11 @@ export class SshConnection {
}
private resetSystemTransport(): void {
this.systemSshLoopbackRouteAbortController?.abort()
this.systemSshLoopbackRouteAbortController = null
this.systemSshLoopbackRouteStartingGeneration = null
this.systemSshLoopbackRoute?.dispose()
this.systemSshLoopbackRoute = null
this.systemSshResolvedConfig = null
this.systemSshControlMasterDisabledForSession = false
this.systemSshGssapiOnlyForSession = false
@@ -16,7 +16,8 @@ export function fingerprintRuntimeSshTarget(target: SshTarget): string {
gssapiAuthentication: target.gssapiAuthentication,
proxyCommand: target.proxyCommand,
jumpHost: target.jumpHost,
systemSshConnectionReuse: target.systemSshConnectionReuse
systemSshConnectionReuse: target.systemSshConnectionReuse,
systemSshProxyConnectionReuse: target.systemSshProxyConnectionReuse
})
)
.digest('hex')
@@ -18,6 +18,15 @@ export type SystemSshBuildArgsOptions = {
* be read as having answered "nothing claims it". See `sshConfigMayClaimAlias`.
*/
aliasClaimedByConfig?: boolean
/** Dial Orca's held `ssh -L` forward instead of the target's proxy. See `system-ssh-loopback-route`. */
loopbackRoute?: SystemSshLoopbackRouteArgs
/** Server-alive probes for a long-lived unmultiplexed process, which no ControlMaster watches. */
keepAlive?: boolean
}
export type SystemSshLoopbackRouteArgs = {
localPort: number
hostKeyAlias: string
}
export function buildSshArgs(target: SshTarget, options?: SystemSshBuildArgsOptions): string[] {
@@ -59,6 +68,8 @@ export function buildSshArgs(target: SshTarget, options?: SystemSshBuildArgsOpti
// Why: keep master alive 300s after last command so rapid reconnects
// (e.g. on tab focus) skip re-handshake without holding a process open.
args.push('-o', 'ControlPersist=300')
}
if (options?.keepAlive || (!forceDisableControlMaster && controlPath)) {
args.push('-o', 'ServerAliveInterval=15')
args.push('-o', 'ServerAliveCountMax=3')
}
@@ -74,9 +85,19 @@ export function buildSshArgs(target: SshTarget, options?: SystemSshBuildArgsOpti
export function appendSshTargetDestinationArgs(
args: string[],
target: SshTarget,
options?: Pick<SystemSshBuildArgsOptions, 'aliasClaimedByConfig' | 'gssapiOnly'>
options?: Pick<SystemSshBuildArgsOptions, 'aliasClaimedByConfig' | 'gssapiOnly' | 'loopbackRoute'>
): void {
const useConfigHost = shouldUseOpenSshConfigHost(target)
const loopbackRoute = options?.loopbackRoute
if (loopbackRoute) {
// Why: OpenSSH keeps the first value it sees per option, so these win over ssh_config's route
// while User, IdentityFile, certificates, and known_hosts files still come from the alias.
args.push('-o', 'HostName=127.0.0.1')
args.push('-o', `Port=${loopbackRoute.localPort}`)
args.push('-o', 'ProxyCommand=none')
args.push('-o', 'ProxyJump=none')
args.push('-o', `HostKeyAlias=${loopbackRoute.hostKeyAlias}`)
}
// Why: a wildcard `Host *` block supplies ProxyCommand/ProxyJump for every alias, so an alias
// whose own Host block was renamed or deleted still looks config-backed and gets dialled bare —
@@ -87,7 +108,7 @@ export function appendSshTargetDestinationArgs(
appendUnclaimedAliasEndpoint(args, target)
}
if (!useConfigHost && target.port !== 22) {
if (!useConfigHost && !loopbackRoute && target.port !== 22) {
args.push('-p', String(target.port))
}
@@ -109,11 +130,12 @@ export function appendSshTargetDestinationArgs(
args.push('-o', 'GSSAPIAuthentication=yes')
}
if (!useConfigHost && target.jumpHost) {
// Why: `-J` after `ProxyJump=none` is a fatal duplicate in OpenSSH, so the route skips both proxies.
if (!useConfigHost && !loopbackRoute && target.jumpHost) {
args.push('-J', target.jumpHost)
}
if (!useConfigHost && target.proxyCommand) {
if (!useConfigHost && !loopbackRoute && target.proxyCommand) {
args.push('-o', `ProxyCommand=${target.proxyCommand}`)
}
@@ -136,8 +158,10 @@ export function buildForwardProcessSshArgs(
target: SshTarget,
options?: SystemSshBuildArgsOptions
): string[] {
// Why no loopbackRoute: forwards outlive commands, and the route's own forward must take the real proxy.
return buildSshArgs(target, {
...options,
loopbackRoute: undefined,
suppressOrcaControlMaster: true,
disableControlMaster: true
})
@@ -178,6 +202,9 @@ export function getSystemSshBuildArgsFromOperationOptions(
if (options?.aliasClaimedByConfig === false) {
buildArgsOptions.aliasClaimedByConfig = false
}
if (options?.loopbackRoute !== undefined) {
buildArgsOptions.loopbackRoute = options.loopbackRoute
}
return Object.keys(buildArgsOptions).length === 0 ? undefined : buildArgsOptions
}
@@ -442,6 +442,52 @@ describe('system SSH forward process', () => {
await expect(pending).resolves.toBeUndefined()
})
// Why: a slow ProxyCommand outlasts the grace period, and a caller that dials the port next needs it listening.
it('waits past the grace period for a listener when a listener timeout is given', async () => {
vi.useFakeTimers()
const child = createFakeProcess()
let listening = false
connectMock.mockImplementation(() => {
const socket = createFakeSocket()
queueMicrotask(() =>
listening ? socket.emit('connect') : socket.emit('error', new Error('ECONNREFUSED'))
)
return socket
})
spawnMock.mockReturnValue(child)
const forward = await startSystemSshPortForwardProcess(createTarget(), 5173, '127.0.0.1', 3000)
let settled = false
const pending = waitForSystemSshForwardStartup(forward.process, 5173, 30_000).finally(() => {
settled = true
})
await vi.advanceTimersByTimeAsync(SYSTEM_SSH_FORWARD_STARTUP_GRACE_MS * 4)
expect(settled).toBe(false)
listening = true
await vi.advanceTimersByTimeAsync(SYSTEM_SSH_FORWARD_LISTENER_PROBE_INTERVAL_MS * 2)
await expect(pending).resolves.toBeUndefined()
})
it('rejects when no listener appears within the listener timeout', async () => {
vi.useFakeTimers()
const child = createFakeProcess()
connectMock.mockImplementation(() => {
const socket = createFakeSocket()
queueMicrotask(() => socket.emit('error', new Error('ECONNREFUSED')))
return socket
})
spawnMock.mockReturnValue(child)
const forward = await startSystemSshPortForwardProcess(createTarget(), 5173, '127.0.0.1', 3000)
const pending = waitForSystemSshForwardStartup(forward.process, 5173, 5_000)
const assertion = expect(pending).rejects.toThrow('did not listen on 127.0.0.1:5173')
await vi.advanceTimersByTimeAsync(5_000)
await assertion
})
it('sends SIGTERM then SIGKILL when the process does not exit', async () => {
vi.useFakeTimers()
const child = createFakeProcess()
@@ -1,5 +1,7 @@
import { spawn, type ChildProcess } from 'node:child_process'
import type { ChildProcessHandle as ChildProcess } from '@orca/process-host/process-spec'
import { spawnProcess } from '@orca/process-host'
import { connect } from 'node:net'
import { createSshOperationAbortError } from '../connection/ssh-exec-options'
import { buildForwardProcessSshArgs, type SystemSshBuildArgsOptions } from './system-ssh-args'
import { findSystemSsh } from './system-ssh-binary'
import { reserveLocalForwardPort } from './system-ssh-forward-port-reservation'
@@ -37,9 +39,10 @@ export function spawnSystemSshPortForward(
const args = buildSystemSshPortForwardArgs(target, localPort, remoteHost, remotePort, options)
// 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
return spawnProcess({
program: sshPath,
args,
stdio: ['ignore', 'ignore', 'pipe']
})
}
@@ -81,13 +84,16 @@ export function startSystemSshPortForwardProcess(
localPort: number,
remoteHost: string,
remotePort: number,
options?: SystemSshBuildArgsOptions
options?: SystemSshBuildArgsOptions,
signal?: AbortSignal
): Promise<SystemSshPortForwardProcess> {
signal?.throwIfAborted()
const orphanArgs =
localPort === 0
? undefined
: buildSystemSshPortForwardArgs(target, localPort, remoteHost, remotePort, options)
return reserveLocalForwardPort(localPort, orphanArgs).then((boundPort) => {
signal?.throwIfAborted()
const process = spawnSystemSshPortForward(target, boundPort, remoteHost, remotePort, options)
stopOnOwnListenFailure(process, boundPort)
return {
@@ -135,9 +141,12 @@ function stopOnOwnListenFailure(process: ChildProcess, localPort: number): void
process.once('exit', stop)
}
/** `listenerTimeoutMs` requires a listener by then instead of accepting a still-silent forward. */
export function waitForSystemSshForwardStartup(
process: ChildProcess,
localPort: number
localPort: number,
listenerTimeoutMs?: number,
signal?: AbortSignal
): Promise<void> {
return new Promise((resolve, reject) => {
let stderr = ''
@@ -151,6 +160,7 @@ export function waitForSystemSshForwardStartup(
if (graceTimer) {
clearTimeout(graceTimer)
}
signal?.removeEventListener('abort', onAbort)
process.off('error', onError)
process.off('exit', onExit)
process.stderr?.off('data', onStderr)
@@ -163,6 +173,7 @@ export function waitForSystemSshForwardStartup(
cleanup()
callback()
}
const onAbort = (): void => finish(() => reject(createSshOperationAbortError()))
const onStderr = (chunk: Buffer): void => {
stderr += chunk.toString('utf-8')
}
@@ -174,7 +185,7 @@ export function waitForSystemSshForwardStartup(
}
const scheduleProbe = (): void => {
probeTimer = setTimeout(() => {
probeLocalForward(localPort).then(
probeLocalForward(localPort, signal).then(
() => finish(resolve),
() => {
if (!settled) {
@@ -189,8 +200,23 @@ export function waitForSystemSshForwardStartup(
process.once('error', onError)
process.once('exit', onExit)
graceTimer = setTimeout(() => {
finish(resolve)
}, SYSTEM_SSH_FORWARD_STARTUP_GRACE_MS)
if (listenerTimeoutMs === undefined) {
finish(resolve)
return
}
finish(() =>
reject(
new Error(
`System SSH port forward did not listen on 127.0.0.1:${localPort} within ${listenerTimeoutMs}ms`
)
)
)
}, listenerTimeoutMs ?? SYSTEM_SSH_FORWARD_STARTUP_GRACE_MS)
signal?.addEventListener('abort', onAbort, { once: true })
if (signal?.aborted) {
onAbort()
return
}
scheduleProbe()
})
}
@@ -273,14 +299,16 @@ function bestErrorLine(stderr: string): string {
)
}
function probeLocalForward(localPort: number): Promise<void> {
function probeLocalForward(localPort: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
const socket = connect({ host: '127.0.0.1', port: localPort })
const cleanup = (): void => {
signal?.removeEventListener('abort', onAbort)
socket.off('connect', onConnect)
socket.off('error', onError)
socket.off('timeout', onTimeout)
}
const onAbort = (): void => onError(createSshOperationAbortError())
const onConnect = (): void => {
cleanup()
socket.destroy()
@@ -300,5 +328,9 @@ function probeLocalForward(localPort: number): Promise<void> {
socket.once('connect', onConnect)
socket.once('error', onError)
socket.once('timeout', onTimeout)
signal?.addEventListener('abort', onAbort, { once: true })
if (signal?.aborted) {
onAbort()
}
})
}
@@ -0,0 +1,172 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, describe, expect, it } from 'vitest'
import type { SshTarget } from '../../../shared/ssh-types'
import {
admitSystemSshLoopbackRoute,
agentListingMayHoldSecurityKey
} from './system-ssh-loopback-route-admission'
import { findSystemSsh } from './system-ssh-binary'
const target: SshTarget = {
id: 'target-1',
label: 'Dev box',
host: 'devbox',
configHost: 'devbox',
port: 22,
username: '',
source: 'ssh-config'
}
// Real OpenSSH: the drift is in how ssh_config matches, which only OpenSSH can answer.
describe.skipIf(findSystemSsh() === null)('admitSystemSshLoopbackRoute with real ssh -G', () => {
const dir = mkdtempSync(join(tmpdir(), 'orca-loopback-admission-'))
afterAll(() => rmSync(dir, { recursive: true, force: true }))
function writeConfig(name: string, body: string): string {
const path = join(dir, name)
writeFileSync(path, body)
return path
}
it('refuses a route under which a `Match host` block would log in as another user', async () => {
const configFile = writeConfig(
'match-host',
[
'Host devbox',
' HostName devbox.internal',
' ProxyJump bastion',
' IdentityAgent none',
'Match host devbox.internal',
' User route_user',
''
].join('\n')
)
await expect(admitSystemSshLoopbackRoute(target, { configFile })).resolves.toEqual({
admitted: false,
reason: 'route changes user'
})
})
it.each([
['a %h token, which a routed login expands to 127.0.0.1', '~/.ssh/id_%h'],
['an environment reference ssh -G leaves unexpanded', '${ORCA_TEST_KEY_DIR}/id_sk']
])('refuses a route with %s', async (_name, identityFile) => {
const configFile = writeConfig(
`token-${identityFile.length}`,
[
'Host devbox',
' HostName devbox.internal',
' ProxyJump bastion',
' IdentityAgent none',
` IdentityFile ${identityFile}`,
''
].join('\n')
)
await expect(admitSystemSshLoopbackRoute(target, { configFile })).resolves.toMatchObject({
admitted: false,
reason: expect.stringContaining('identityfile')
})
})
it('refuses a route whose jump host signs with a security key', async () => {
const skKey = join(dir, 'bastion_sk')
writeFileSync(
`${skKey}.pub`,
'sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29t bastion\n'
)
const configFile = writeConfig(
'jump-sk',
[
'Host devbox',
' HostName devbox.internal',
' ProxyJump bastion',
' IdentityAgent none',
'Host bastion',
` IdentityFile ${skKey}`,
' IdentitiesOnly yes',
' IdentityAgent none',
''
].join('\n')
)
await expect(admitSystemSshLoopbackRoute(target, { configFile })).resolves.toEqual({
admitted: false,
reason: 'jump host bastion may use a security key'
})
})
// Why: OpenSSH still applies a manual host's own Host block, so the forward must use what it resolved.
it("takes a manual target's sshd and known_hosts name from its Host block", async () => {
const configFile = writeConfig(
'manual-remap',
[
'Host myhost',
' HostName 10.0.0.5',
' Port 2200',
' IdentityAgent none',
'Host bastion',
' IdentityFile /nonexistent/orca-admission-key',
' IdentityAgent none',
''
].join('\n')
)
const manual: SshTarget = {
...target,
host: 'myhost',
configHost: 'myhost',
source: 'manual',
jumpHost: 'bastion'
}
await expect(admitSystemSshLoopbackRoute(manual, { configFile })).resolves.toEqual({
admitted: true,
sshdPort: 2200,
hostKeyAlias: '[10.0.0.5]:2200'
})
})
it('admits a route that changes only how the host is reached', async () => {
const configFile = writeConfig(
'plain',
[
'Host devbox',
' HostName devbox.internal',
' User route_user',
' ProxyJump bastion',
' IdentityAgent none',
'Host bastion',
' IdentityFile /nonexistent/orca-admission-key',
' IdentityAgent none',
''
].join('\n')
)
await expect(admitSystemSshLoopbackRoute(target, { configFile })).resolves.toEqual({
admitted: true,
sshdPort: 22,
hostKeyAlias: 'devbox.internal'
})
})
})
describe('agentListingMayHoldSecurityKey', () => {
const listing = (code: number | null, stdout = '', timedOut = false) => ({
code,
stdout,
timedOut
})
it.each([
['an sk- key in the agent', listing(0, 'sk-ssh-ed25519@openssh.com AAAA k\n'), false, true],
['only ordinary agent keys', listing(0, 'ssh-ed25519 AAAA k\n'), false, false],
['an agent with no identities', listing(1), false, false],
['no default agent to sign with', listing(2), false, false],
['an unreachable configured IdentityAgent', listing(2), true, true],
['an agent query that timed out', listing(null, '', true), false, true]
])('%s → %s', (_name, result, agentConfigured, expected) => {
expect(agentListingMayHoldSecurityKey(result, agentConfigured)).toBe(expected)
})
})
@@ -0,0 +1,294 @@
/**
* Whether a proxied target may switch to the loopback route. Fails closed: the route is only a
* speedup, so any doubt keeps the direct proxy route.
*/
import { dirname, join } from 'node:path'
import { runProcess } from '@orca/process-host'
import type { SshTarget } from '../../../shared/ssh-types'
import { parseSshGOutput } from '../config/ssh-g-config-resolution'
import { requiresSystemSshForSecurityKey } from '../ssh-transport-selection'
import { buildSshArgs, type SystemSshBuildArgsOptions } from './system-ssh-args'
import { findSystemSsh } from './system-ssh-binary'
const LOOPBACK_ROUTE_ADMISSION_TIMEOUT_MS = 5_000
// Why: these are exactly what the route rewrites; any other difference means ssh_config matched
// differently once HostName became 127.0.0.1 (e.g. `Match host`), so commands would change identity.
// ControlPath only matters for multiplexing, which Windows OpenSSH cannot do, and `%C` differs by host.
const ROUTE_REWRITTEN_SSH_G_KEYS = new Set([
'hostname',
'port',
'hostkeyalias',
'proxycommand',
'proxyjump',
'controlpath'
])
const IDENTITY_SSH_G_KEYS = ['identityfile', 'certificatefile']
// Why bounded: a hop's own ProxyJump is followed, and ssh_config can loop.
const MAX_JUMP_HOPS = 8
/** On admission, where the forward must land and the known_hosts name OpenSSH uses for it. */
export type LoopbackRouteAdmission =
| { admitted: true; sshdPort: number; hostKeyAlias: string }
| { admitted: false; reason: string }
export async function admitSystemSshLoopbackRoute(
target: SshTarget,
options: SystemSshBuildArgsOptions
): Promise<LoopbackRouteAdmission> {
const sshPath = findSystemSsh()
if (!sshPath) {
return refuse('no system ssh binary')
}
const direct = await readEffectiveSshConfig(
sshPath,
buildSshArgs(target, { ...options, loopbackRoute: undefined })
)
const endpoint = direct ? readDialledEndpoint(direct) : null
if (!direct || !endpoint) {
return refuse('ssh -G failed')
}
// Why port 1: the real local port is not bound yet, and the port is a rewritten key anyway.
const routed = await readEffectiveSshConfig(
sshPath,
buildSshArgs(target, {
...options,
loopbackRoute: { localPort: 1, hostKeyAlias: endpoint.hostKeyAlias }
})
)
if (!routed || !isRewrittenToLoopback(routed)) {
return refuse('route options did not take effect')
}
const drifted = findConfigDrift(direct, routed)
if (drifted.length > 0) {
return refuse(`route changes ${drifted.join(', ')}`)
}
// Why: every security-key login asks for a touch, so the forward and probe would add unprompted ones.
if (await mayAskForSecurityKey(sshPath, direct)) {
return refuse('may use a security key')
}
const hopRefusal = await checkJumpHops(sshPath, direct, options)
return hopRefusal ? refuse(hopRefusal) : { admitted: true, ...endpoint }
}
function refuse(reason: string): LoopbackRouteAdmission {
return { admitted: false, reason }
}
/** The sshd the direct route reaches, as OpenSSH resolved it — the forward must land on the same one. */
export function readDialledEndpoint(
direct: Map<string, string[]>
): { sshdPort: number; hostKeyAlias: string } | null {
const hostname = direct.get('hostname')?.[0]
const sshdPort = Number(direct.get('port')?.[0] ?? '22')
if (!hostname || !Number.isInteger(sshdPort) || sshdPort <= 0) {
return null
}
const alias = direct.get('hostkeyalias')?.[0]
const hostKeyAlias =
alias && alias.toLowerCase() !== 'none'
? alias
: sshdPort === 22
? hostname
: `[${hostname}]:${sshdPort}`
return { sshdPort, hostKeyAlias }
}
function isRewrittenToLoopback(routed: Map<string, string[]>): boolean {
const proxyCommand = routed.get('proxycommand')?.[0]?.toLowerCase()
const proxyJump = routed.get('proxyjump')?.[0]?.toLowerCase()
return (
routed.get('hostname')?.[0] === '127.0.0.1' &&
(proxyCommand === undefined || proxyCommand === 'none') &&
(proxyJump === undefined || proxyJump === 'none')
)
}
/** Identity files (with `%` tokens unresolvable here) or agent keys that could need a touch. */
async function mayAskForSecurityKey(
sshPath: string,
config: Map<string, string[]>
): Promise<boolean> {
const identityValues = IDENTITY_SSH_G_KEYS.flatMap((key) => config.get(key) ?? [])
if (identityValues.some(hasUnexpandedToken)) {
return true
}
const resolved = parseSshGOutput(serializeSshGLines(config))
// Why config-backed: then the check reads exactly the IdentityFile list `ssh -G` reported.
const configBacked: SshTarget = {
id: 'loopback-admission',
label: 'loopback-admission',
host: 'loopback-admission',
configHost: 'loopback-admission',
port: 22,
username: '',
source: 'ssh-config'
}
return (
(await requiresSystemSshForSecurityKey(configBacked, resolved)) ||
(await agentMayHoldSecurityKey(sshPath, config))
)
}
/** The forward also logs in to every ProxyJump hop, whose ssh does not inherit BatchMode. */
async function checkJumpHops(
sshPath: string,
direct: Map<string, string[]>,
options: SystemSshBuildArgsOptions
): Promise<string | null> {
const pending = listJumpHosts(direct)
const seen = new Set<string>()
for (let jump = pending.shift(); jump !== undefined; jump = pending.shift()) {
if (seen.has(jump)) {
continue
}
seen.add(jump)
if (seen.size > MAX_JUMP_HOPS) {
return 'jump chain too long to check'
}
const hop = await readEffectiveSshConfig(sshPath, buildJumpHostSshGArgs(jump, options))
if (!hop) {
return `ssh -G failed for jump host ${jump}`
}
if (await mayAskForSecurityKey(sshPath, hop)) {
return `jump host ${jump} may use a security key`
}
pending.push(...listJumpHosts(hop))
}
return null
}
/** The ProxyJump chain a route takes, as OpenSSH resolved it (including a manual `-J`). */
export function listJumpHosts(config: Map<string, string[]>): string[] {
const configured = config.get('proxyjump')?.[0]
if (!configured || configured.toLowerCase() === 'none') {
return []
}
return configured
.split(',')
.map((jump) => jump.trim())
.filter(Boolean)
}
/** `[user@]host[:port]` or `ssh://[user@]host[:port]` as `ssh -G` destination args. */
export function buildJumpHostSshGArgs(jump: string, options: SystemSshBuildArgsOptions): string[] {
const spec = jump.replace(/^ssh:\/\//i, '')
const at = spec.lastIndexOf('@')
const user = at === -1 ? '' : spec.slice(0, at)
const hostPort = spec.slice(at + 1)
const match = /^\[(.+)\](?::(\d+))?$/.exec(hostPort) ?? /^([^:]+)(?::(\d+))?$/.exec(hostPort)
const host = match?.[1] ?? hostPort
const port = match?.[2]
return [
...(options.configFile ? ['-F', options.configFile] : []),
...(port ? ['-p', port] : []),
'--',
user ? `${user}@${host}` : host
]
}
function serializeSshGLines(config: Map<string, string[]>): string {
return [...config].flatMap(([key, values]) => values.map((value) => `${key} ${value}`)).join('\n')
}
/** Each `ssh -G` line keyed by option; repeated options (IdentityFile, SendEnv…) keep every value. */
function parseSshGLines(stdout: string): Map<string, string[]> {
const options = new Map<string, string[]>()
for (const rawLine of stdout.split(/\r?\n/)) {
const line = rawLine.trim()
const space = line.indexOf(' ')
if (space === -1) {
continue
}
const key = line.slice(0, space).toLowerCase()
options.set(key, [...(options.get(key) ?? []), line.slice(space + 1).trim()])
}
return options
}
/**
* Settings the route would change. A `%` token counts even when both outputs match: `ssh -G`
* leaves some unexpanded, and a routed login expands `%h` to 127.0.0.1.
*/
export function findConfigDrift(
direct: Map<string, string[]>,
routed: Map<string, string[]>
): string[] {
const keys = new Set([...direct.keys(), ...routed.keys()])
return [...keys]
.filter((key) => !ROUTE_REWRITTEN_SSH_G_KEYS.has(key))
.filter((key) => {
const directValue = (direct.get(key) ?? []).join('\n')
const routedValue = (routed.get(key) ?? []).join('\n')
return directValue !== routedValue || hasUnexpandedToken(directValue)
})
.sort()
}
/** `%h`-style tokens and `${VAR}` references that `ssh -G` may print unexpanded. */
function hasUnexpandedToken(value: string): boolean {
return value.replaceAll('%%', '').includes('%') || value.includes('${')
}
/**
* `ssh-add -L` lists sk- key types. An unreachable default agent can sign nothing; an unreachable
* configured IdentityAgent may only be a path ssh-add resolved differently, so it fails closed.
*/
export function agentListingMayHoldSecurityKey(
result: { code: number | null; stdout: string; timedOut: boolean },
agentConfigured: boolean
): boolean {
if (result.timedOut) {
return true
}
if (result.code === 0) {
return result.stdout.split(/\r?\n/).some((line) => line.trim().startsWith('sk-'))
}
// Why: exit 1 is "The agent has no identities"; exit 2 is "could not connect to the agent".
return !(result.code === 1 || (result.code === 2 && !agentConfigured))
}
async function agentMayHoldSecurityKey(
sshPath: string,
direct: Map<string, string[]>
): Promise<boolean> {
const identityAgent = direct.get('identityagent')?.[0]
if (identityAgent?.toLowerCase() === 'none') {
return false
}
const sshAddPath = join(
dirname(sshPath),
process.platform === 'win32' ? 'ssh-add.exe' : 'ssh-add'
)
const agentConfigured = Boolean(identityAgent && identityAgent !== 'SSH_AUTH_SOCK')
const env = agentConfigured ? { ...process.env, SSH_AUTH_SOCK: identityAgent } : process.env
try {
return agentListingMayHoldSecurityKey(
await runProcess({
program: sshAddPath,
args: ['-L'],
env,
timeoutMs: LOOPBACK_ROUTE_ADMISSION_TIMEOUT_MS
}),
agentConfigured
)
} catch {
return true
}
}
async function readEffectiveSshConfig(
sshPath: string,
args: string[]
): Promise<Map<string, string[]> | null> {
try {
const result = await runProcess({
program: sshPath,
args: ['-G', ...args],
timeoutMs: LOOPBACK_ROUTE_ADMISSION_TIMEOUT_MS
})
return result.code === 0 && !result.timedOut ? parseSshGLines(result.stdout) : null
} catch {
return null
}
}
@@ -0,0 +1,521 @@
import { EventEmitter } from 'node:events'
import { PassThrough } from 'node:stream'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { existsSyncMock, spawnMock, connectMock, createServerMock, treeKillMock, admissionMock } =
vi.hoisted(() => ({
existsSyncMock: vi.fn(),
spawnMock: vi.fn(),
connectMock: vi.fn(),
createServerMock: vi.fn(),
treeKillMock: vi.fn(),
admissionMock: vi.fn()
}))
vi.mock('./system-ssh-loopback-route-admission', () => ({
admitSystemSshLoopbackRoute: admissionMock
}))
vi.mock('@orca/process-host/process-tree-termination', async (importOriginal) => {
const actual: Record<string, unknown> = await importOriginal()
return { ...actual, signalProcessTree: treeKillMock }
})
vi.mock('./system-ssh-orphaned-forward', () => ({
stopOrphanedOrcaSshForward: vi.fn().mockResolvedValue(false)
}))
vi.mock('fs', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
return { ...actual, existsSync: existsSyncMock }
})
vi.mock('child_process', () => ({ spawn: spawnMock }))
vi.mock('net', () => ({ connect: connectMock, createServer: createServerMock }))
import type { SshTarget } from '../../../shared/ssh-types'
import type { SshResolvedConfig } from '../config/ssh-g-config-resolution'
import { buildSshArgs } from './system-ssh-args'
import { buildSystemSshPortForwardArgs } from './system-ssh-forward-process'
import {
shouldUseSystemSshLoopbackRoute,
startSystemSshLoopbackRoute
} from './system-ssh-loopback-route'
const SYSTEM_SSH_PATH =
process.platform === 'win32' ? 'C:\\Windows\\System32\\OpenSSH\\ssh.exe' : '/usr/bin/ssh'
const FREE_PORT = 49811
const ROUTE = { localPort: FREE_PORT, hostKeyAlias: 'devbox' }
function createTarget(overrides?: Partial<SshTarget>): SshTarget {
return {
id: 'target-1',
label: 'Dev box',
host: 'devbox',
configHost: 'devbox',
port: 22,
username: '',
source: 'ssh-config',
systemSshProxyConnectionReuse: true,
...overrides
}
}
function createResolved(overrides?: Partial<SshResolvedConfig>): SshResolvedConfig {
return {
hostname: 'devbox',
port: 22,
identityFile: [],
identitiesOnly: false,
forwardAgent: false,
proxyCommand: 'aws ssm start-session --target %h',
proxyUseFdpass: false,
controlMaster: 'no',
controlPersist: 'no',
userKnownHostsFiles: [],
globalKnownHostsFiles: [],
strictHostKeyChecking: 'ask',
hashKnownHosts: false,
updateHostKeys: 'no',
...overrides
}
}
type FakeProcess = EventEmitter & {
pid: number | undefined
stdin: PassThrough
stdout: PassThrough
stderr: PassThrough
kill: ReturnType<typeof vi.fn>
exitCode: number | null
signalCode: NodeJS.Signals | null
}
function createFakeProcess(): FakeProcess {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fields are assigned on the next lines, before the process is returned.
const proc = new EventEmitter() as FakeProcess
proc.pid = 4242
proc.stdin = new PassThrough()
proc.stdout = new PassThrough()
proc.stderr = new PassThrough()
proc.kill = vi.fn().mockReturnValue(true)
proc.exitCode = null
proc.signalCode = null
return proc
}
function createFakeServer() {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fields are assigned on the next lines, before the server is returned.
const server = new EventEmitter() as EventEmitter & {
listen: ReturnType<typeof vi.fn>
close: ReturnType<typeof vi.fn>
address: ReturnType<typeof vi.fn>
}
server.listen = vi.fn().mockImplementation(() => {
queueMicrotask(() => server.emit('listening'))
return server
})
server.address = vi.fn(() => ({ address: '127.0.0.1', port: FREE_PORT }))
server.close = vi.fn().mockImplementation((cb?: () => void) => cb?.())
return server
}
function createListeningSocket(): EventEmitter {
const socket = Object.assign(new EventEmitter(), { setTimeout: vi.fn(), destroy: vi.fn() })
queueMicrotask(() => socket.emit('connect'))
return socket
}
/** The probe command over the route answers like a remote shell with this exit code. */
function answerProbe(proc: FakeProcess, code: number): void {
setImmediate(() => {
if (code === 0) {
proc.stdout.write('ORCA-SYSTEM-SSH-LOOPBACK-OK\n')
}
proc.stdout.end()
proc.emit('exit', code, null)
proc.emit('close', code, null)
})
}
function spawnedArgs(call: number): string[] {
return spawnMock.mock.calls[call]?.[1] ?? []
}
describe('buildSshArgs with a loopback route', () => {
it('dials the held forward ahead of the alias so OpenSSH keeps these over ssh_config', () => {
const args = buildSshArgs(createTarget(), { loopbackRoute: ROUTE })
const routeStart = args.indexOf('HostName=127.0.0.1')
expect(routeStart).toBeGreaterThan(-1)
expect(args.slice(routeStart - 1, routeStart + 9)).toEqual([
'-o',
'HostName=127.0.0.1',
'-o',
`Port=${FREE_PORT}`,
'-o',
'ProxyCommand=none',
'-o',
'ProxyJump=none',
'-o',
'HostKeyAlias=devbox'
])
expect(args.slice(-2)).toEqual(['--', 'devbox'])
})
it("drops a manual target's own port and proxies, which would fight the route", () => {
const args = buildSshArgs(
createTarget({
source: 'manual',
configHost: undefined,
host: 'devbox.internal',
port: 2200,
username: 'dev',
jumpHost: 'bastion',
proxyCommand: 'cloudflared access ssh --hostname %h'
}),
{ loopbackRoute: ROUTE }
)
expect(args).not.toContain('-J')
expect(args).not.toContain('-p')
expect(args.filter((arg) => arg.startsWith('ProxyCommand='))).toEqual(['ProxyCommand=none'])
expect(args.slice(-2)).toEqual(['--', 'dev@devbox.internal'])
})
it('never routes a forward, whose argv must stay stable for orphan recovery', () => {
const args = buildSystemSshPortForwardArgs(createTarget(), 8080, 'localhost', 3000, {
loopbackRoute: ROUTE
})
expect(args).not.toContain('HostName=127.0.0.1')
expect(args).not.toContain(`Port=${FREE_PORT}`)
})
})
describe('shouldUseSystemSshLoopbackRoute', () => {
it.each([
['a ProxyCommand host on Windows', createTarget(), createResolved(), {}, 'win32', true],
[
'a ProxyJump host on Windows',
createTarget(),
createResolved({ proxyCommand: undefined, proxyJump: 'bastion' }),
{},
'win32',
true
],
[
'a direct host on Windows',
createTarget(),
createResolved({ proxyCommand: undefined }),
{},
'win32',
false
],
[
'a host that has not opted in',
createTarget({ systemSshProxyConnectionReuse: undefined }),
createResolved(),
{},
'win32',
false
],
['macOS, which multiplexes instead', createTarget(), createResolved(), {}, 'darwin', false],
['Linux, which multiplexes instead', createTarget(), createResolved(), {}, 'linux', false],
[
'a host that opted out of connection reuse',
createTarget({ systemSshConnectionReuse: false }),
createResolved(),
{},
'win32',
false
],
[
'a Kerberos host',
createTarget(),
createResolved({ gssapiAuthentication: true }),
{},
'win32',
false
],
[
'an alias no Host block claims',
createTarget(),
createResolved(),
{ aliasClaimedByConfig: false },
'win32',
false
],
[
'a manual host no Host block claims, which never rewrites its endpoint',
createTarget({ source: 'manual', configHost: 'box', host: 'box', jumpHost: 'bastion' }),
createResolved({ proxyCommand: undefined }),
{ aliasClaimedByConfig: false },
'win32',
true
]
] as const)('%s → %s', (_name, target, resolved, options, platform, expected) => {
expect(shouldUseSystemSshLoopbackRoute(target, resolved, options, platform)).toBe(expected)
})
})
describe('startSystemSshLoopbackRoute', () => {
beforeEach(() => {
existsSyncMock.mockReset().mockImplementation((p: string) => p === SYSTEM_SSH_PATH)
spawnMock.mockReset()
connectMock.mockReset().mockImplementation(createListeningSocket)
createServerMock.mockReset().mockImplementation(createFakeServer)
treeKillMock.mockReset().mockResolvedValue(true)
admissionMock
.mockReset()
.mockResolvedValue({ admitted: true, sshdPort: 22, hostKeyAlias: 'devbox' })
})
it('opens the forward over the real proxy, then proves a login over the route', async () => {
const forward = createFakeProcess()
const probe = createFakeProcess()
spawnMock.mockReturnValueOnce(forward).mockImplementationOnce(() => {
answerProbe(probe, 0)
return probe
})
const route = await startSystemSshLoopbackRoute(
createTarget(),
{ loopbackRoute: { localPort: 1, hostKeyAlias: 'stale' } },
'linux'
)
expect(route.args).toEqual(ROUTE)
expect(spawnedArgs(0)).toContain(`127.0.0.1:${FREE_PORT}:localhost:22`)
expect(spawnedArgs(0)).not.toContain('ProxyCommand=none')
expect(spawnedArgs(1)).toContain(`Port=${FREE_PORT}`)
expect(spawnedArgs(1)).toContain('ProxyCommand=none')
expect(spawnedArgs(1)).toContain('BatchMode=yes')
expect(forward.kill).not.toHaveBeenCalled()
})
it('lands the forward on the sshd and known_hosts name the direct route resolved', async () => {
admissionMock.mockResolvedValueOnce({
admitted: true,
sshdPort: 2200,
hostKeyAlias: '[10.0.0.5]:2200'
})
const forward = createFakeProcess()
const probe = createFakeProcess()
spawnMock.mockReturnValueOnce(forward).mockImplementationOnce(() => {
answerProbe(probe, 0)
return probe
})
const route = await startSystemSshLoopbackRoute(createTarget(), {}, 'linux')
expect(spawnedArgs(0)).toContain(`127.0.0.1:${FREE_PORT}:localhost:2200`)
expect(route.args.hostKeyAlias).toBe('[10.0.0.5]:2200')
})
// Why: no ControlMaster watches the held forward, so a silently dead proxy must end it.
it('holds the forward with server-alive probes', async () => {
const forward = createFakeProcess()
const probe = createFakeProcess()
spawnMock.mockReturnValueOnce(forward).mockImplementationOnce(() => {
answerProbe(probe, 0)
return probe
})
await startSystemSshLoopbackRoute(createTarget(), {}, 'linux')
expect(spawnedArgs(0)).toContain('ServerAliveInterval=15')
expect(spawnedArgs(0)).toContain('ServerAliveCountMax=3')
})
// Why: a password or passphrase login would otherwise raise an extra prompt right after connect.
it('never lets the background forward or probe ask for credentials', async () => {
const forward = createFakeProcess()
const probe = createFakeProcess()
spawnMock.mockReturnValueOnce(forward).mockImplementationOnce(() => {
answerProbe(probe, 0)
return probe
})
await startSystemSshLoopbackRoute(createTarget(), {}, 'win32')
for (const call of [0, 1]) {
expect(spawnedArgs(call)).toContain('BatchMode=yes')
expect(spawnedArgs(call)).not.toContain('BatchMode=no')
}
})
it('starts no forward when admission refuses the route', async () => {
admissionMock.mockResolvedValueOnce({ admitted: false, reason: 'route changes user' })
await expect(startSystemSshLoopbackRoute(createTarget(), {}, 'win32')).rejects.toThrow(
'route changes user'
)
expect(spawnMock).not.toHaveBeenCalled()
})
it('stops the forward when sshd refuses logins over it', async () => {
const forward = createFakeProcess()
const probe = createFakeProcess()
spawnMock.mockReturnValueOnce(forward).mockImplementationOnce(() => {
answerProbe(probe, 255)
return probe
})
await expect(startSystemSshLoopbackRoute(createTarget(), {}, 'linux')).rejects.toThrow(
'loopback route probe failed (exit 255)'
)
expect(forward.kill).toHaveBeenCalledWith('SIGTERM')
})
it('gives up without a probe when the forward exits before listening', async () => {
const forward = createFakeProcess()
spawnMock.mockReturnValueOnce(forward)
connectMock.mockImplementation(() => {
const socket = Object.assign(new EventEmitter(), { setTimeout: vi.fn(), destroy: vi.fn() })
queueMicrotask(() => socket.emit('error', new Error('ECONNREFUSED')))
return socket
})
const pending = startSystemSshLoopbackRoute(createTarget(), {}, 'linux')
setImmediate(() => {
forward.stderr.write('kex_exchange_identification: Connection closed by remote host\n')
forward.emit('exit', 255)
})
await expect(pending).rejects.toThrow('Connection closed by remote host')
expect(spawnMock).toHaveBeenCalledTimes(1)
})
// Why: killing ssh.exe leaves its ProxyCommand child running, still holding the proxy tunnel open.
it('stops the whole forward process tree on Windows, once', async () => {
const forward = createFakeProcess()
const probe = createFakeProcess()
spawnMock.mockReturnValueOnce(forward).mockImplementationOnce(() => {
answerProbe(probe, 0)
return probe
})
const route = await startSystemSshLoopbackRoute(createTarget(), {}, 'win32')
route.dispose()
route.dispose()
expect(treeKillMock).toHaveBeenCalledExactlyOnceWith(forward)
expect(forward.kill).not.toHaveBeenCalled()
})
it('reports a forward that already exited before anyone listened for it', async () => {
const forward = createFakeProcess()
const probe = createFakeProcess()
spawnMock.mockReturnValueOnce(forward).mockImplementationOnce(() => {
answerProbe(probe, 0)
return probe
})
const route = await startSystemSshLoopbackRoute(createTarget(), {}, 'linux')
forward.exitCode = 255
forward.emit('exit', 255)
const onExit = vi.fn()
route.onExit(onExit)
expect(onExit).toHaveBeenCalledTimes(1)
})
it.each(['exit', 'signal'] as const)('does not kill a forward after %s', async (reason) => {
const forward = createFakeProcess()
const probe = createFakeProcess()
spawnMock.mockReturnValueOnce(forward).mockImplementationOnce(() => {
answerProbe(probe, 0)
return probe
})
const route = await startSystemSshLoopbackRoute(createTarget(), {}, 'win32')
if (reason === 'exit') {
forward.exitCode = 255
} else {
forward.signalCode = 'SIGTERM'
}
forward.emit('exit', forward.exitCode, forward.signalCode)
route.dispose()
route.dispose()
expect(treeKillMock).not.toHaveBeenCalled()
expect(forward.kill).not.toHaveBeenCalled()
})
it('cancels listener polling and closes its socket without waiting for child exit', async () => {
vi.useFakeTimers()
try {
const forward = createFakeProcess()
const socket = Object.assign(new EventEmitter(), { setTimeout: vi.fn(), destroy: vi.fn() })
spawnMock.mockReturnValueOnce(forward)
connectMock.mockReturnValue(socket)
const controller = new AbortController()
const pending = startSystemSshLoopbackRoute(createTarget(), {}, 'linux', controller.signal)
const rejected = expect(pending).rejects.toMatchObject({ name: 'AbortError' })
await vi.advanceTimersByTimeAsync(50)
expect(connectMock).toHaveBeenCalledTimes(1)
controller.abort()
await rejected
expect(forward.kill).toHaveBeenCalledExactlyOnceWith('SIGTERM')
expect(socket.destroy).toHaveBeenCalledTimes(1)
expect(socket.eventNames()).toEqual([])
expect(forward.listenerCount('error')).toBe(0)
expect(vi.getTimerCount()).toBe(0)
await vi.advanceTimersByTimeAsync(60_000)
expect(connectMock).toHaveBeenCalledTimes(1)
expect(spawnMock).toHaveBeenCalledTimes(1)
} finally {
vi.useRealTimers()
}
})
it('cancels the login probe and forward without waiting for probe timeout', async () => {
vi.useFakeTimers()
try {
const forward = createFakeProcess()
const probe = createFakeProcess()
spawnMock.mockReturnValueOnce(forward).mockReturnValueOnce(probe)
const controller = new AbortController()
const pending = startSystemSshLoopbackRoute(createTarget(), {}, 'linux', controller.signal)
const rejected = expect(pending).rejects.toMatchObject({ name: 'AbortError' })
await vi.advanceTimersByTimeAsync(50)
expect(spawnMock).toHaveBeenCalledTimes(2)
controller.abort()
await rejected
expect(forward.kill).toHaveBeenCalledExactlyOnceWith('SIGTERM')
expect(probe.kill).toHaveBeenCalledExactlyOnceWith('SIGTERM')
expect(vi.getTimerCount()).toBe(0)
} finally {
vi.useRealTimers()
}
})
it('does not spawn a forward if cancelled during port reservation', async () => {
const server = createFakeServer()
createServerMock.mockReturnValueOnce(server)
server.listen.mockImplementationOnce(() => server)
const controller = new AbortController()
const pending = startSystemSshLoopbackRoute(createTarget(), {}, 'linux', controller.signal)
controller.abort()
server.emit('listening')
await expect(pending).rejects.toMatchObject({ name: 'AbortError' })
expect(spawnMock).not.toHaveBeenCalled()
})
it('closes a live login probe when its output stream errors', async () => {
vi.useFakeTimers()
try {
const forward = createFakeProcess()
const probe = createFakeProcess()
spawnMock.mockReturnValueOnce(forward).mockReturnValueOnce(probe)
const pending = startSystemSshLoopbackRoute(createTarget(), {}, 'linux')
const rejected = expect(pending).rejects.toThrow('probe output failed')
await vi.advanceTimersByTimeAsync(50)
probe.stdout.emit('error', new Error('probe output failed'))
await rejected
expect(probe.kill).toHaveBeenCalledExactlyOnceWith('SIGTERM')
expect(forward.kill).toHaveBeenCalledExactlyOnceWith('SIGTERM')
expect(vi.getTimerCount()).toBe(0)
} finally {
vi.useRealTimers()
}
})
})
@@ -0,0 +1,207 @@
/**
* Connection reuse for proxied targets where OpenSSH cannot multiplex (Windows has no ControlMaster).
* One held `ssh -N -L` pays the ProxyCommand/ProxyJump handshake once; later commands log in over
* 127.0.0.1 through it instead of re-running the proxy for every exec.
*/
import type { ChildProcessHandle as ChildProcess } from '@orca/process-host/process-spec'
import {
childProcessHasExited,
signalProcessTree
} from '@orca/process-host/process-tree-termination'
import { createSshOperationAbortError } from '../connection/ssh-exec-options'
import type { SshTarget } from '../../../shared/ssh-types'
import type { SshResolvedConfig } from '../config/ssh-g-config-resolution'
import { admitSystemSshLoopbackRoute } from './system-ssh-loopback-route-admission'
import {
isOpenSshConfigBackedTarget,
type SystemSshBuildArgsOptions,
type SystemSshLoopbackRouteArgs
} from './system-ssh-args'
import { spawnSystemSshCommand } from './system-ssh-command'
import {
startSystemSshPortForwardProcess,
waitForSystemSshForwardStartup
} from './system-ssh-forward-process'
// Why long: the forward pays the full proxy handshake, which is the slow part this route exists for.
export const SYSTEM_SSH_LOOPBACK_ROUTE_LISTEN_TIMEOUT_MS = 60_000
export const SYSTEM_SSH_LOOPBACK_ROUTE_PROBE_TIMEOUT_MS = 15_000
const LOOPBACK_ROUTE_PROBE_MARKER = 'ORCA-SYSTEM-SSH-LOOPBACK-OK'
export type SystemSshLoopbackRoute = {
args: SystemSshLoopbackRouteArgs
onExit: (callback: () => void) => void
dispose: () => void
}
export function shouldUseSystemSshLoopbackRoute(
target: SshTarget,
resolved: SshResolvedConfig | null,
options: SystemSshBuildArgsOptions,
platform: NodeJS.Platform = process.platform
): boolean {
// Why opt-in: ssh_config can key settings on the real host name, which the route rewrites.
if (
platform !== 'win32' ||
target.systemSshProxyConnectionReuse !== true ||
target.systemSshConnectionReuse === false
) {
return false
}
// Why: Kerberos derives the service principal from the dialled host name, which becomes 127.0.0.1.
if (options.gssapiOnly || target.gssapiAuthentication || resolved?.gssapiAuthentication) {
return false
}
// Why: an unclaimed alias already rewrites HostName/Port to the stored endpoint.
if (options.aliasClaimedByConfig === false && isOpenSshConfigBackedTarget(target)) {
return false
}
const configResolved = configBackedResolution(target, resolved)
return configResolved
? Boolean(configResolved.proxyCommand || configResolved.proxyJump)
: Boolean(target.proxyCommand || target.jumpHost)
}
// Why: Orca dials manual targets by their stored endpoint; `ssh -G` ran on the label, not that host.
function configBackedResolution(
target: SshTarget,
resolved: SshResolvedConfig | null
): SshResolvedConfig | null {
return resolved && isOpenSshConfigBackedTarget(target) ? resolved : null
}
export async function startSystemSshLoopbackRoute(
target: SshTarget,
options: SystemSshBuildArgsOptions,
platform: NodeJS.Platform = process.platform,
signal?: AbortSignal
): Promise<SystemSshLoopbackRoute> {
signal?.throwIfAborted()
const admission = await admitSystemSshLoopbackRoute(target, options)
if (!admission.admitted) {
throw new Error(`System SSH loopback route skipped: ${admission.reason}`)
}
const { sshdPort, hostKeyAlias } = admission
signal?.throwIfAborted()
const forward = await startSystemSshPortForwardProcess(
target,
0,
'localhost',
sshdPort,
// Why nonInteractive: this optional background login must fail silently, never ask for credentials.
{ ...options, keepAlive: true, nonInteractive: true },
signal
)
let disposed = false
const dispose = (): void => {
if (disposed) {
return
}
disposed = true
stopLoopbackForward(forward.process, forward.dispose, platform)
}
signal?.addEventListener('abort', dispose, { once: true })
try {
signal?.throwIfAborted()
await waitForSystemSshForwardStartup(
forward.process,
forward.localPort,
SYSTEM_SSH_LOOPBACK_ROUTE_LISTEN_TIMEOUT_MS,
signal
)
const args = { localPort: forward.localPort, hostKeyAlias }
// Why: a listening forward does not prove sshd allows it (AllowTcpForwarding) or that auth works over it.
await probeLoopbackRoute(target, { ...options, loopbackRoute: args }, signal)
signal?.throwIfAborted()
return {
args,
onExit: (callback) => {
// Why: the forward can exit between the probe and this registration, after 'exit' already fired.
if (childProcessHasExited(forward.process)) {
callback()
return
}
forward.process.once('exit', callback)
},
dispose
}
} catch (error) {
dispose()
throw error
} finally {
signal?.removeEventListener('abort', dispose)
}
}
function stopLoopbackForward(
forwardProcess: ChildProcess,
killForward: () => void,
platform: NodeJS.Platform
): void {
if (childProcessHasExited(forwardProcess)) {
return
}
if (platform !== 'win32') {
killForward()
return
}
// Why: Windows does not end a killed ssh's ProxyCommand child, which would keep the proxy tunnel open.
void signalProcessTree(forwardProcess).catch(killForward)
}
function probeLoopbackRoute(
target: SshTarget,
options: SystemSshBuildArgsOptions,
signal?: AbortSignal
): Promise<void> {
signal?.throwIfAborted()
const channel = spawnSystemSshCommand(target, `echo ${LOOPBACK_ROUTE_PROBE_MARKER}`, {
...options,
nonInteractive: true,
wrapCommand: false
})
return new Promise((resolve, reject) => {
let stdout = ''
let settled = false
const finish = (error?: Error, closeChannel = false): void => {
if (settled) {
return
}
settled = true
clearTimeout(timeout)
signal?.removeEventListener('abort', onAbort)
channel.off('data', onData)
channel.off('close', onClose)
if (error) {
if (closeChannel) {
channel.close()
}
reject(error)
} else {
resolve()
}
}
const onAbort = (): void => finish(createSshOperationAbortError(), true)
const onData = (data: Buffer): void => {
stdout += data.toString('utf-8')
}
const onClose = (code: number | null): void => {
finish(
code === 0 && stdout.includes(LOOPBACK_ROUTE_PROBE_MARKER)
? undefined
: new Error(`System SSH loopback route probe failed (exit ${code ?? 'unknown'})`)
)
}
const timeout = setTimeout(() => {
finish(new Error('System SSH loopback route probe timed out'), true)
}, SYSTEM_SSH_LOOPBACK_ROUTE_PROBE_TIMEOUT_MS)
channel.on('data', onData)
channel.stderr.resume()
channel.once('error', (error: Error) => finish(error, true))
channel.once('close', onClose)
signal?.addEventListener('abort', onAbort, { once: true })
if (signal?.aborted) {
onAbort()
}
})
}
@@ -16,13 +16,16 @@ export function SshHostAdvancedFields({
onOpenChange,
form,
disabled,
onFormChange
onFormChange,
offerProxyConnectionReuse = false
}: {
open: boolean
onOpenChange: (open: boolean) => void
form: EditingTarget
disabled: boolean
onFormChange: (updater: (prev: EditingTarget) => EditingTarget) => void
/** Only for hosts this Windows desktop dials itself; a paired server runs its own ssh. */
offerProxyConnectionReuse?: boolean
}): React.JSX.Element {
return (
<Collapsible open={open} onOpenChange={onOpenChange} className="col-span-2 sm:col-span-2">
@@ -108,6 +111,38 @@ export function SshHostAdvancedFields({
)}
/>
</div>
{offerProxyConnectionReuse && navigator.userAgent.includes('Windows') ? (
<div className="flex items-start justify-between gap-4 py-1 text-xs">
<div className="min-w-0 flex-1 space-y-0.5">
<Label className="text-xs font-medium">
{translate(
'auto.components.settings.SshTargetForm.proxyConnectionReuse',
'Reuse one proxy connection'
)}
</Label>
<p className="text-muted-foreground">
{translate(
'auto.components.settings.SshTargetForm.proxyConnectionReuseHelp',
"Faster connects through slow ProxyCommand or ProxyJump hosts. Only enable if your ssh_config doesn't rely on host-specific Match or %h rules."
)}
</p>
</div>
<SettingsSwitch
checked={form.systemSshProxyConnectionReuse}
disabled={disabled || !form.systemSshConnectionReuse}
onChange={() =>
onFormChange((f) => ({
...f,
systemSshProxyConnectionReuse: !f.systemSshProxyConnectionReuse
}))
}
ariaLabel={translate(
'auto.components.settings.SshTargetForm.proxyConnectionReuse',
'Reuse one proxy connection'
)}
/>
</div>
) : null}
<div className="flex items-start justify-between gap-4 py-1 text-xs">
<div className="min-w-0 flex-1 space-y-0.5">
<Label className="text-xs font-medium">
@@ -237,6 +237,7 @@ export function SshTargetForm({
</p>
</div>
<SshHostAdvancedFields
offerProxyConnectionReuse
open={advancedOpen}
onOpenChange={setAdvancedOpen}
form={form}
@@ -12,6 +12,7 @@ export type EditingTarget = {
proxyCommand: string
jumpHost: string
systemSshConnectionReuse: boolean
systemSshProxyConnectionReuse: boolean
allowRemoteCliControl: boolean
}
@@ -26,6 +27,7 @@ export const EMPTY_FORM: EditingTarget = {
proxyCommand: '',
jumpHost: '',
systemSshConnectionReuse: true,
systemSshProxyConnectionReuse: false,
allowRemoteCliControl: true
}
@@ -44,6 +46,7 @@ export function getEditingTargetForSshTarget(target: SshTarget): EditingTarget {
proxyCommand: target.proxyCommand ?? '',
jumpHost: target.jumpHost ?? '',
systemSshConnectionReuse: target.systemSshConnectionReuse !== false,
systemSshProxyConnectionReuse: target.systemSshProxyConnectionReuse === true,
allowRemoteCliControl: isRemoteCliControlAllowed(target)
}
}
@@ -168,6 +171,7 @@ export function isSshTargetFormDirty(current: EditingTarget, baseline: EditingTa
current.proxyCommand !== baseline.proxyCommand ||
current.jumpHost !== baseline.jumpHost ||
current.systemSshConnectionReuse !== baseline.systemSshConnectionReuse ||
current.systemSshProxyConnectionReuse !== baseline.systemSshProxyConnectionReuse ||
current.allowRemoteCliControl !== baseline.allowRemoteCliControl
)
}
@@ -37,6 +37,7 @@ export function buildSshTargetSavePayload(form: EditingTarget): SshTargetSavePay
const proxyCommand = form.proxyCommand.trim() || undefined
const jumpHost = form.jumpHost.trim() || undefined
const systemSshConnectionReuse = form.systemSshConnectionReuse ? undefined : false
const systemSshProxyConnectionReuse = form.systemSshProxyConnectionReuse ? true : undefined
const target: SshTargetCreateInput = {
label: form.label.trim() || (username ? `${username}@${host}` : configHost),
@@ -49,6 +50,7 @@ export function buildSshTargetSavePayload(form: EditingTarget): SshTargetSavePay
...(proxyCommand ? { proxyCommand } : {}),
...(jumpHost ? { jumpHost } : {}),
...(systemSshConnectionReuse === false ? { systemSshConnectionReuse } : {}),
...(systemSshProxyConnectionReuse ? { systemSshProxyConnectionReuse } : {}),
// Why explicit both ways: a server from before the default flip reads an absent value as off.
allowRemoteCliControl: form.allowRemoteCliControl
}
@@ -66,6 +68,7 @@ export function buildSshTargetSavePayload(form: EditingTarget): SshTargetSavePay
proxyCommand,
jumpHost,
systemSshConnectionReuse,
systemSshProxyConnectionReuse,
source: 'manual'
}
}
@@ -18,7 +18,8 @@ export function SshHostFields({
preferAdvancedOpen = false,
configIdentityAlias = null,
onFormChange,
onSubmit
onSubmit,
offerProxyConnectionReuse = false
}: {
form: EditingTarget
disabled: boolean
@@ -28,6 +29,7 @@ export function SshHostFields({
configIdentityAlias?: string | null
onFormChange: (updater: (prev: EditingTarget) => EditingTarget) => void
onSubmit: () => void
offerProxyConnectionReuse?: boolean
}) {
const [advancedOpen, setAdvancedOpen] = useState(preferAdvancedOpen)
return (
@@ -134,6 +136,7 @@ export function SshHostFields({
form={form}
disabled={disabled}
onFormChange={onFormChange}
offerProxyConnectionReuse={offerProxyConnectionReuse}
/>
</form>
)
@@ -60,6 +60,7 @@ export function AddRemoteHostSshFormPanel({
configIdentityAlias={configIdentityAlias}
onFormChange={onFormChange}
onSubmit={onSubmit}
offerProxyConnectionReuse={!serverLabel}
/>
<DialogFooter className="sm:justify-between">
@@ -74,6 +74,7 @@ export async function saveNewSshHostFromForm<T extends SavedSshHost>({
const proxyCommand = form.proxyCommand.trim() || undefined
const jumpHost = form.jumpHost.trim() || undefined
const systemSshConnectionReuse = form.systemSshConnectionReuse ? undefined : false
const systemSshProxyConnectionReuse = form.systemSshProxyConnectionReuse ? true : undefined
const target = {
label: form.label.trim() || (username ? `${username}@${host}` : configHost || host),
configHost,
@@ -85,6 +86,7 @@ export async function saveNewSshHostFromForm<T extends SavedSshHost>({
...(proxyCommand ? { proxyCommand } : {}),
...(jumpHost ? { jumpHost } : {}),
...(systemSshConnectionReuse === false ? { systemSshConnectionReuse } : {}),
...(systemSshProxyConnectionReuse ? { systemSshProxyConnectionReuse } : {}),
// Why explicit both ways: absent means on here, and off on a server from before the default flip.
allowRemoteCliControl: form.allowRemoteCliControl
}
+2
View File
@@ -9223,6 +9223,8 @@
"e9609ddca6": "Proxy, jump host, and connection reuse",
"8c922dffba": "Reuse SSH connection for faster setup",
"53e9aabfc0": "Uses OpenSSH multiplexing when available. Turn off for hosts with custom SSH restrictions.",
"proxyConnectionReuse": "Reuse one proxy connection",
"proxyConnectionReuseHelp": "Faster connects through slow ProxyCommand or ProxyJump hosts. Only enable if your ssh_config doesn't rely on host-specific Match or %h rules.",
"allowRemoteCliControl": "Allow this host's orca CLI to control Orca",
"allowRemoteCliControlHelp": "On by default for new hosts. Lets orca commands run on this host open and drive Orca browser tabs on this desktop, only in this host's workspaces and only while it is connected, and lets terminals started by an older Orca on this host run orchestration commands (check, inbox, send, ask, reply) for their own supervised work. They cannot read your files, clipboard or cookies. When off, they reach only this host's own Orca server.",
"editTitle": "Edit SSH host",
+1
View File
@@ -44,6 +44,7 @@ export const SshEditableTargetInput = z
)
.optional(),
systemSshConnectionReuse: z.boolean().optional(),
systemSshProxyConnectionReuse: z.boolean().optional(),
remoteRuntime: z.enum(SSH_REMOTE_RUNTIMES).optional(),
allowRemoteCliControl: z.boolean().optional()
})
+1
View File
@@ -23,6 +23,7 @@ export const SSH_EDITABLE_TARGET_FIELDS = [
'jumpHost',
'relayGracePeriodSeconds',
'systemSshConnectionReuse',
'systemSshProxyConnectionReuse',
'remoteRuntime',
'allowRemoteCliControl'
] as const satisfies readonly (keyof SshTarget)[]
+3
View File
@@ -89,6 +89,9 @@ export type SshTarget = {
/** Reuse a system OpenSSH connection across setup commands. Undefined means
* enabled; false is an explicit per-target compatibility opt-out. */
systemSshConnectionReuse?: boolean
/** Windows only: hold one loopback tunnel through a ProxyCommand/ProxyJump and run later
* commands over it. Opt-in; undefined means off. */
systemSshProxyConnectionReuse?: boolean
/** Relay runtime for this host; undefined means pinned-node. */
remoteRuntime?: SshRemoteRuntime
/** Main-owned ladder cache; renderer updates never set it. */