fix(ssh): a managed tunnel whose forward can't start takes the stdio bridge, and reclaims a port an old ControlMaster holds (#31062)

* fix(ssh): recover a tunnel port a ControlMaster still holds

A forward made through a ControlMaster leaves its local listener on the
master: the `-N` client exits at once and the master keeps the port. No
process of Orca's holds it then, so the orphan sweep finds no argv to
match and a managed tunnel's persisted port stays bound - every later
connect fails with `Local port 127.0.0.1:<port> is not available:
EADDRINUSE`, until the user finds and stops that master by hand.

Ask the master to drop just this forward (`ssh -O cancel -L`) when the
orphan sweep comes up empty, then rebind. OpenSSH refuses a spec no
master registered, so another forward on the port, and a port held by
anything but a master, are left alone - as are the master's own
sessions and its other forwards.

* fix(ssh): read a refused forward cancel off stderr, not the exit code

`ssh -O cancel` exits 0 whether or not a master held the spec, reporting
a refusal only as `master cancel forward request failed` on stderr. So a
spec no master registered read as a release that freed the port: the
recovery logged a cancel it never made and let the caller rebind a port
still held, turning a clear refusal into a second bind failure.

Treat a request failure on stderr as nothing released, so a port held by
anything but a matching master keeps reporting the original bind error.

* docs(ssh): document the port reservation contract and its recovery limits

State on each entry point what it returns and what it will not do: that
reservation only ever recovers a port already in use and only from what
the caller supplies, that the bind reports a busy port through the
error cause, and that a forward on port 0 carries no recovery at all
because the port it lands on was just free.

* fix(ssh): any managed-tunnel forward that fails to start falls back to the stdio bridge

* fix(ssh): skip the master cancel on Windows; note the forward's bridge fallback

* test(ssh): an abandoned forward's late exit uses a real close reason

* fix(ssh): cancel only Orca's forward on the master, and bridge a forward that never listens

Codex: -O cancel also cancels every forward the loaded ssh_config declares, so the
cancel now reads the control socket from ssh -G and sends the request with no config.
Opus: a forward still waiting on its own login never binds; that now takes the bridge.

* fix(ssh): pass the master's ControlPath through -S so a path with spaces stays whole

* fix(ssh): keep ssh's own exit reason when the forward also never listened

* test(ssh): the real sshd fixture waits for its processes before removing its temp dir

---------

Co-authored-by: zzzpyyy <167217540+zzzpyyy@users.noreply.github.com>
Co-authored-by: zhangpengyun <zhangpengyun@zpydeMacBook-Pro-2.local>
This commit is contained in:
Neil
2026-10-20 02:26:46 -07:00
committed by GitHub
co-authored by zzzpyyy zhangpengyun
parent 43b2d95af9
commit e3ad4acaa3
16 changed files with 1063 additions and 250 deletions
@@ -1,17 +1,30 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { SshConnection } from '../../ssh/connection/ssh-connection'
import type { PortForwardStartOptions } from '../../ssh/port-forwarding/ssh-port-forward-provider'
import type * as ForwardProcess from '../../ssh/system-transport/system-ssh-forward-process'
import type * as PortReservation from '../../ssh/system-transport/system-ssh-forward-port-reservation'
const { reserveLocalForwardPort } = vi.hoisted(() => ({
reserveLocalForwardPort: vi.fn(async (port: number, _orphanArgs?: readonly string[]) => port)
const { reserveLocalForwardPort, cancelMuxedLocalForward } = vi.hoisted(() => ({
reserveLocalForwardPort: vi.fn(
async (
port: number,
_orphanArgs?: readonly string[],
_cancelMuxedForward?: () => Promise<boolean>
) => port
),
cancelMuxedLocalForward: vi.fn(async () => true)
}))
vi.mock('../../ssh/system-transport/system-ssh-forward-process', async (importOriginal) => ({
...(await importOriginal<typeof ForwardProcess>()),
reserveLocalForwardPort
vi.mock(
'../../ssh/system-transport/system-ssh-forward-port-reservation',
async (importOriginal) => ({
...(await importOriginal<typeof PortReservation>()),
reserveLocalForwardPort
})
)
vi.mock('../../ssh/system-transport/system-ssh-muxed-forward-cancel', () => ({
cancelMuxedLocalForward
}))
import { reserveBridgeLocalPort } from './orcad-managed-tunnel-transport'
import { reserveManagedTunnelLocalPort } from './orcad-managed-tunnel-transport'
const options: PortForwardStartOptions = {
id: 'pf-1',
@@ -22,6 +35,7 @@ const options: PortForwardStartOptions = {
remotePort: 6768
}
/** A connection stubbed down to what the reservation reads: its transport and its ssh target. */
function connection(systemSsh: boolean): SshConnection {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the reservation reads only these three.
return {
@@ -31,18 +45,35 @@ function connection(systemSsh: boolean): SshConnection {
} as unknown as SshConnection
}
beforeEach(() => reserveLocalForwardPort.mockClear())
beforeEach(() => {
reserveLocalForwardPort.mockClear()
cancelMuxedLocalForward.mockClear()
})
describe('the stdio bridge’s port reservation', () => {
describe('the managed tunnel’s port reservation', () => {
it('lets a system-SSH host clear an `ssh -L` an earlier Orca left on the port', async () => {
await reserveBridgeLocalPort(connection(true), options)
await reserveManagedTunnelLocalPort(connection(true), options)
const orphanArgs = reserveLocalForwardPort.mock.calls[0]?.[1]
expect(orphanArgs).toEqual(expect.arrayContaining(['-L', '127.0.0.1:47123:127.0.0.1:6768']))
expect(orphanArgs).toContain('LogLevel=INFO')
})
// Why: a forward made through a ControlMaster is left on the master, with no process to stop.
it('lets a system-SSH host have that same forward cancelled on its ControlMaster', async () => {
await reserveManagedTunnelLocalPort(connection(true), options)
await reserveLocalForwardPort.mock.calls[0]?.[2]?.()
expect(cancelMuxedLocalForward).toHaveBeenCalledWith(
expect.objectContaining({ host: 'devbox' }),
47123,
'127.0.0.1',
6768,
expect.anything()
)
})
it('never offers a kill on an ssh2 host, which never spawned one', async () => {
await reserveBridgeLocalPort(connection(false), options)
await reserveManagedTunnelLocalPort(connection(false), options)
expect(reserveLocalForwardPort).toHaveBeenCalledWith(47123)
})
})
@@ -0,0 +1,190 @@
/**
* Real OpenSSH: the managed tunnel reaches orcad wherever the connection's exec works, through a
* user ControlMaster whose login a standalone forward cannot repeat, and on a persisted port an
* older Orca's muxed forward still holds. Opt-in (needs sshd and ssh on PATH):
* ORCA_REAL_OPENSSH_TEST=1 pnpm test src/main/managed-server/lifecycle/orcad-managed-tunnel-transport-real-openssh.test.ts
*/
import { execFileSync } from 'node:child_process'
import { mkdirSync, symlinkSync } from 'node:fs'
import { connect, createServer, type Server, type Socket } from 'node:net'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import type { SshConnection } from '../../ssh/connection/ssh-connection'
import type { SshExecOptions } from '../../ssh/connection/ssh-exec-options'
import { knownOrcadTunnelTransport } from '../../ssh/orcad-tunnel-transport-memo'
import type { PortForwardStartOptions } from '../../ssh/port-forwarding/ssh-port-forward-provider'
import { spawnSystemSshCommand } from '../../ssh/system-transport/system-ssh-command'
import {
REAL_OPENSSH_TEST_ENABLED,
dial,
freePort,
startRealOpenSshFixture,
type RealOpenSshFixture
} from '../../ssh/system-transport/real-openssh-sshd-fixture'
import { OrcadManagedTunnelTransportProvider } from './orcad-managed-tunnel-transport'
const GREETING = 'orcad-fixture'
/** First line orcad's stand-in sends, read through the tunnel's local listener. */
function readGreeting(port: number): Promise<string> {
return new Promise((resolve, reject) => {
const socket = connect({ host: '127.0.0.1', port })
const timer = setTimeout(() => {
socket.destroy()
reject(new Error('no greeting through the tunnel'))
}, 15_000)
let received = ''
socket.on('data', (chunk: Buffer) => {
received += chunk.toString('utf8')
if (received.includes('\n')) {
clearTimeout(timer)
socket.destroy()
resolve(received.split('\n')[0] ?? '')
}
})
socket.on('error', (error) => {
clearTimeout(timer)
reject(error)
})
})
}
describe.skipIf(!REAL_OPENSSH_TEST_ENABLED)(
'the managed tunnel through a user ControlMaster (real OpenSSH)',
() => {
let fixture: RealOpenSshFixture
let orcad: Server
const orcadSockets = new Set<Socket>()
let orcadPort = 0
// The user's own LocalForward in their ssh_config, which the master opened and must keep.
let configuredForwardPort = 0
let conn: SshConnection
const options = (localPort: number): PortForwardStartOptions => ({
id: 'pf-real',
connectionId: 'real-openssh-tunnel',
localHost: '127.0.0.1',
localPort,
remoteHost: '127.0.0.1',
remotePort: orcadPort
})
const mux = (request: 'forward' | 'cancel', localPort: number, remotePort: number) =>
fixture.ssh(['-O', request, '-L', `127.0.0.1:${localPort}:127.0.0.1:${remotePort}`])
beforeEach(async () => {
orcad = createServer((socket) => {
orcadSockets.add(socket)
socket.on('close', () => orcadSockets.delete(socket))
socket.on('error', () => {})
socket.write(`${GREETING}\n`)
})
await new Promise<void>((resolve) => orcad.listen(0, '127.0.0.1', resolve))
const address = orcad.address()
orcadPort = typeof address === 'object' && address ? address.port : 0
configuredForwardPort = await freePort()
fixture = await startRealOpenSshFixture({
client: [`LocalForward 127.0.0.1:${configuredForwardPort} 127.0.0.1:${orcadPort}`]
})
// The host's pinned Node, where the stdio bridge looks for it under the session's HOME.
const nodeBin = join(fixture.remoteHome, '.orca-remote', 'runtimes', 'node-fixture', 'bin')
mkdirSync(nodeBin, { recursive: true })
symlinkSync(
execFileSync('node', ['-p', 'process.execPath'], { encoding: 'utf8' }).trim(),
join(nodeBin, 'node')
)
const stub = {
getTarget: () => fixture.target,
getSystemSshBuildArgsOptions: fixture.buildOptions,
usesSystemSshTransport: () => true,
getClient: () => null,
getConnectGeneration: () => 1,
// The connection's own exec: through the user's master, as every remote command goes.
exec: async (command: string, execOptions?: SshExecOptions) =>
spawnSystemSshCommand(fixture.target, command, {
...fixture.buildOptions(),
...execOptions
})
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the transport, the system SSH forward and the stdio bridge read only these members.
conn = stub as unknown as SshConnection
}, 30_000)
afterEach(async () => {
// Why: sshd may hold its end of a forwarded socket past the forward's own exit.
for (const socket of orcadSockets) {
socket.destroy()
}
await new Promise((resolve) => orcad.close(resolve))
await fixture.close()
})
it('forwards when a fresh login works', async () => {
const tunnel = await new OrcadManagedTunnelTransportProvider().start(conn, options(0))
try {
expect(knownOrcadTunnelTransport('real-openssh-tunnel')).toBe('tcp_forward')
expect(await readGreeting(tunnel.entry.localPort)).toBe(GREETING)
} finally {
await tunnel.close()
}
}, 30_000)
// Why: an MFA or hardware-key login lives on in the master, but a standalone forward must log in again.
it('reaches orcad through the bridge when only the master can log in', async () => {
fixture.refuseNewLogins()
const tunnel = await new OrcadManagedTunnelTransportProvider().start(conn, options(0))
try {
expect(knownOrcadTunnelTransport('real-openssh-tunnel')).toBe('stdio_bridge')
expect(await readGreeting(tunnel.entry.localPort)).toBe(GREETING)
} finally {
await tunnel.close()
}
}, 30_000)
it('takes back a persisted port an older Orca’s forward left on the master', async () => {
const persisted = await freePort()
mux('forward', persisted, orcadPort)
expect(await dial(persisted)).toBe(true)
const tunnel = await new OrcadManagedTunnelTransportProvider().start(conn, options(persisted))
try {
expect(tunnel.entry.localPort).toBe(persisted)
expect(knownOrcadTunnelTransport('real-openssh-tunnel')).toBe('tcp_forward')
expect(await readGreeting(persisted)).toBe(GREETING)
} finally {
await tunnel.close()
}
// The master and the user's own forward on it stay up; only Orca's stale forward went.
expect(() => fixture.ssh(['-O', 'check'])).not.toThrow()
expect(await readGreeting(configuredForwardPort)).toBe(GREETING)
expect(await dial(persisted)).toBe(false)
}, 30_000)
it('takes back that port and bridges when only the master can log in', async () => {
const persisted = await freePort()
mux('forward', persisted, orcadPort)
fixture.refuseNewLogins()
const tunnel = await new OrcadManagedTunnelTransportProvider().start(conn, options(persisted))
try {
expect(tunnel.entry.localPort).toBe(persisted)
expect(knownOrcadTunnelTransport('real-openssh-tunnel')).toBe('stdio_bridge')
expect(await readGreeting(persisted)).toBe(GREETING)
} finally {
await tunnel.close()
}
expect(await readGreeting(configuredForwardPort)).toBe(GREETING)
}, 30_000)
it('leaves a master’s forward to anywhere else alone', async () => {
const persisted = await freePort()
const elsewhere = await freePort()
mux('forward', persisted, elsewhere)
await expect(
new OrcadManagedTunnelTransportProvider().start(conn, options(persisted))
).rejects.toThrow(/is not available/)
expect(await dial(persisted)).toBe(true)
mux('cancel', persisted, elsewhere)
}, 30_000)
}
)
@@ -7,7 +7,10 @@ import type {
SshPortForwardProvider,
StartedPortForward
} from '../../ssh/port-forwarding/ssh-port-forward-provider'
import type { TcpForwardingVerdict } from '../../ssh/ssh-tcp-forwarding-probe'
import type {
ForwardChannelVerdict,
TcpForwardingVerdict
} from '../../ssh/ssh-tcp-forwarding-probe'
let generation = 1
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the selector reads only the transport generation.
@@ -36,7 +39,7 @@ function provider(name: string, canHandle = true) {
function select(
verdict: TcpForwardingVerdict,
channel: TcpForwardingVerdict = 'unverifiable',
channel: ForwardChannelVerdict = 'unverifiable',
stdioCanHandle = true
) {
const forward = provider('forward')
@@ -91,10 +94,10 @@ describe('the managed tunnel’s transport', () => {
)
expect(chosen.stdio.start).toHaveBeenCalledWith(conn, options)
// The closed forward's ssh must have released the persisted port before the bridge binds it.
expect(chosen.reserveLocalPort.mock.invocationCallOrder[0]).toBeGreaterThan(
expect(chosen.reserveLocalPort.mock.invocationCallOrder.at(-1)).toBeGreaterThan(
chosen.forward.close.mock.invocationCallOrder[0]
)
expect(chosen.reserveLocalPort.mock.invocationCallOrder[0]).toBeLessThan(
expect(chosen.reserveLocalPort.mock.invocationCallOrder.at(-1)).toBeLessThan(
chosen.stdio.start.mock.invocationCallOrder[0]
)
expect(knownOrcadTunnelTransport('ssh-1')).toBe('stdio_bridge')
@@ -102,7 +105,9 @@ describe('the managed tunnel’s transport', () => {
it('fails loudly, never with the dead forward, when a refused host has no bridge route', async () => {
const chosen = select('unverifiable', 'refused', false)
await expect(chosen.transport.start(conn, options)).rejects.toThrow(/refuses TCP forwarding/)
await expect(chosen.transport.start(conn, options)).rejects.toThrow(
/has no route: sshd refused/
)
expect(chosen.forward.close).toHaveBeenCalledOnce()
})
@@ -131,17 +136,85 @@ describe('the managed tunnel’s transport', () => {
expect(chosen.forward.start).toHaveBeenCalledTimes(2)
})
it('fails, rather than hand back a dead forward, when the forward exits during its check', async () => {
it('takes the bridge, never the dead forward, when the forward exits during its check', async () => {
const chosen = select('unverifiable')
const onUnexpectedClose = vi.fn()
chosen.checkForward.mockImplementation(async () => {
const entry = { ...options, localPort: options.localPort }
chosen.forward.start.mock.calls[0]?.[1].onUnexpectedClose?.(entry, {
chosen.forward.start.mock.calls[0]?.[1].onUnexpectedClose?.(options, {
kind: 'unexpected-exit',
detail: 'System SSH port forward failed (exit 255)'
detail: 'System SSH port forward failed (exit 255): Permission denied (publickey).'
})
return 'unverifiable'
})
await expect(chosen.transport.start(conn, options)).rejects.toThrow(/exit 255/)
await expect(
chosen.transport.start(conn, { ...options, onUnexpectedClose })
).resolves.toMatchObject({ entry: { id: 'stdio' } })
expect(chosen.forward.close).toHaveBeenCalledOnce()
expect(knownOrcadTunnelTransport('ssh-1')).toBe('stdio_bridge')
// A late exit of the abandoned forward must not read as the bridge closing.
chosen.forward.start.mock.calls[0]?.[1].onUnexpectedClose?.(options, {
kind: 'unexpected-exit'
})
expect(onUnexpectedClose).toHaveBeenCalledOnce()
})
it('reports ssh’s own exit reason over a listener that never came up', async () => {
const chosen = select('unverifiable', 'not-listening', false)
chosen.checkForward.mockImplementation(async () => {
chosen.forward.start.mock.calls[0]?.[1].onUnexpectedClose?.(options, {
kind: 'unexpected-exit',
detail: 'Permission denied (publickey).'
})
return 'not-listening'
})
await expect(chosen.transport.start(conn, options)).rejects.toThrow(/Permission denied/)
})
// Why: a ControlMaster can hold a login (MFA, a hardware key) the forward cannot repeat alone.
it('takes the bridge when the forward fails to start at all', async () => {
const chosen = select('unverifiable')
chosen.forward.start.mockRejectedValueOnce(
new Error('System SSH port forward failed (exit 255): Permission denied (publickey).')
)
await expect(chosen.transport.start(conn, options)).resolves.toMatchObject({
entry: { id: 'stdio' }
})
expect(chosen.stdio.start).toHaveBeenCalledWith(conn, options)
expect(chosen.checkForward).not.toHaveBeenCalled()
expect(knownOrcadTunnelTransport('ssh-1')).toBe('stdio_bridge')
await chosen.transport.start(conn, options)
expect(chosen.forward.start).toHaveBeenCalledOnce()
})
// Why: a forward still waiting on a key touch or a prompt never binds, yet has not exited.
it('takes the bridge when the forward never listened', async () => {
const chosen = select('unverifiable', 'not-listening')
await expect(chosen.transport.start(conn, options)).resolves.toMatchObject({
entry: { id: 'stdio' }
})
expect(chosen.forward.close).toHaveBeenCalledOnce()
})
it('frees the persisted port before the forward binds it, not only before the bridge', async () => {
const chosen = select('unverifiable', 'allowed')
await chosen.transport.start(conn, options)
expect(chosen.reserveLocalPort).toHaveBeenCalledWith(conn, options)
expect(chosen.reserveLocalPort.mock.invocationCallOrder[0]).toBeLessThan(
chosen.forward.start.mock.invocationCallOrder[0]
)
})
it('reserves nothing for a port OpenSSH picks itself', async () => {
const chosen = select('unverifiable', 'refused')
await chosen.transport.start(conn, { ...options, localPort: 0 })
expect(chosen.reserveLocalPort).not.toHaveBeenCalled()
})
it('surfaces the bridge’s own failure when neither route starts', async () => {
const chosen = select('unverifiable')
chosen.forward.start.mockRejectedValueOnce(new Error('forward login refused'))
chosen.stdio.start.mockRejectedValueOnce(new Error('bridge unavailable'))
await expect(chosen.transport.start(conn, options)).rejects.toThrow('bridge unavailable')
})
it('never records a refusal answered before a reconnect against the new transport', async () => {
@@ -1,6 +1,8 @@
/**
* The managed tunnel's transport choice, made each time a tunnel starts: the SSH local forward
* wherever the host allows it, and the stdio bridge where its sshd refuses forwarding.
* wherever it starts, and the stdio bridge wherever it does not — sshd refuses forwarding, or the
* forward's own login fails where the connection's exec still works (a ControlMaster holding an
* MFA or hardware-key login it cannot repeat).
*/
import { OrcadStdioBridgePortForwardProvider } from './orcad-stdio-bridge-provider'
import { rememberOrcadTunnelTransport } from '../../ssh/orcad-tunnel-transport-memo'
@@ -13,6 +15,7 @@ import type {
import {
checkForwardChannel,
probeTcpForwarding,
type ForwardChannelVerdict,
type TcpForwardingVerdict
} from '../../ssh/ssh-tcp-forwarding-probe'
import { Ssh2PortForwardProvider } from '../../ssh/port-forwarding/ssh2-port-forward-provider'
@@ -20,23 +23,22 @@ import {
SystemSshPortForwardProvider,
systemSshForwardBuildArgsOptions
} from '../../ssh/port-forwarding/system-ssh-port-forward-provider'
import {
buildSystemSshPortForwardArgs,
reserveLocalForwardPort
} from '../../ssh/system-transport/system-ssh-forward-process'
import { buildSystemSshPortForwardArgs } from '../../ssh/system-transport/system-ssh-forward-process'
import { reserveLocalForwardPort } from '../../ssh/system-transport/system-ssh-forward-port-reservation'
import { cancelMuxedLocalForward } from '../../ssh/system-transport/system-ssh-muxed-forward-cancel'
type OrcadManagedTunnelTransportDependencies = {
forwards: SshPortForwardProvider[]
stdio: SshPortForwardProvider
probe: (conn: SshConnection, port: number) => Promise<TcpForwardingVerdict>
checkForward: (forward: StartedPortForward) => Promise<TcpForwardingVerdict>
/** Frees the persisted local port (an orphaned `ssh -L` may hold it) before the bridge binds. */
checkForward: (forward: StartedPortForward) => Promise<ForwardChannelVerdict>
/** Frees the persisted local port from a forward an earlier Orca left on it, before either binds. */
reserveLocalPort: (conn: SshConnection, options: PortForwardStartOptions) => Promise<unknown>
}
export class OrcadManagedTunnelTransportProvider implements SshPortForwardProvider {
// Why per transport generation: a reconnect may route through a different sshd.
private readonly refusedGenerations = new WeakMap<SshConnection, number>()
// Why per transport generation: a reconnect may route through a different sshd, or log in anew.
private readonly bridgedGenerations = new WeakMap<SshConnection, number>()
constructor(
private readonly dependencies: OrcadManagedTunnelTransportDependencies = {
@@ -44,7 +46,7 @@ export class OrcadManagedTunnelTransportProvider implements SshPortForwardProvid
stdio: new OrcadStdioBridgePortForwardProvider(),
probe: probeTcpForwarding,
checkForward: checkForwardChannel,
reserveLocalPort: reserveBridgeLocalPort
reserveLocalPort: reserveManagedTunnelLocalPort
}
) {}
@@ -53,10 +55,10 @@ export class OrcadManagedTunnelTransportProvider implements SshPortForwardProvid
}
async start(conn: SshConnection, options: PortForwardStartOptions): Promise<StartedPortForward> {
const { forwards, probe, checkForward } = this.dependencies
const { forwards, probe } = this.dependencies
// Why captured: a refusal answered by a transport that reconnected meanwhile is not the new one's.
const generation = conn.getConnectGeneration()
if (this.refusedGenerations.get(conn) === generation) {
if (this.bridgedGenerations.get(conn) === generation) {
return this.startBridge(conn, options, generation)
}
// Why only on refusal: an unverifiable answer keeps the forward, as before this transport.
@@ -67,63 +69,117 @@ export class OrcadManagedTunnelTransportProvider implements SshPortForwardProvid
if (!forward) {
throw new Error('SSH connection is not established')
}
// Why: the manager only hears an exit once it holds the forward, which it does not mid-check.
await this.reserveFixedLocalPort(conn, options)
const outcome = await this.startForward(forward, conn, options)
if (!('failure' in outcome)) {
rememberOrcadTunnelTransport(options.connectionId, 'tcp_forward')
return outcome
}
console.warn(`[ssh] Managed Orca server tunnel takes the stdio bridge: ${outcome.failure}`)
return this.startBridge(conn, options, generation, outcome.failure)
}
/** The one place a forward is judged started; every way it is not leads to the same bridge. */
private async startForward(
forward: SshPortForwardProvider,
conn: SshConnection,
options: PortForwardStartOptions
): Promise<StartedPortForward | { failure: string }> {
// Why: the manager only hears an exit once it holds the forward, which it does not mid-check,
// and an abandoned forward's late exit must not read as the bridge's.
const exits: string[] = []
const started = await forward.start(conn, {
...options,
onUnexpectedClose: (entry, reason) => {
exits.push(reason.kind === 'unexpected-exit' ? (reason.detail ?? 'exited') : 'closed')
options.onUnexpectedClose?.(entry, reason)
}
})
let abandoned = false
let started: StartedPortForward
try {
started = await forward.start(conn, {
...options,
onUnexpectedClose: (entry, reason) => {
if (abandoned) {
return
}
exits.push(reason.kind === 'unexpected-exit' ? (reason.detail ?? 'exited') : 'closed')
options.onUnexpectedClose?.(entry, reason)
}
})
} catch (error) {
abandoned = true
return { failure: error instanceof Error ? error.message : String(error) }
}
// Why: system SSH's listener is up before sshd is asked for a channel, so it proves nothing.
const verdict = await checkForward(started)
if (verdict === 'refused') {
await started.close()
return this.startBridge(conn, options, generation)
const verdict = await this.dependencies.checkForward(started)
// Why an exit counts, and first: a forward that dies before it is handed over cannot be the
// route, and its exit carries ssh's own reason (a refused login) where not-listening has none.
const failure =
verdict === 'refused'
? 'sshd refused the forward channel'
: (exits[0] ??
(verdict === 'not-listening'
? 'the forward never listened (its own login did not finish)'
: null))
if (failure === null) {
return started
}
if (exits.length > 0) {
throw new Error(`The managed Orca server tunnel closed while it was checked: ${exits[0]}`)
}
rememberOrcadTunnelTransport(options.connectionId, 'tcp_forward')
return started
abandoned = true
await started.close()
return { failure }
}
private async startBridge(
conn: SshConnection,
options: PortForwardStartOptions,
generation: number
generation: number,
forwardFailure = 'this SSH host refuses TCP forwarding'
): Promise<StartedPortForward> {
this.refusedGenerations.set(conn, generation)
// Why for the whole generation: a login the forward could not repeat stays unrepeatable.
this.bridgedGenerations.set(conn, generation)
if (!this.dependencies.stdio.canHandle(conn)) {
throw new Error(
'This SSH host refuses TCP forwarding, and the managed Orca server tunnel has no other route.'
`The managed Orca server tunnel has no route: ${forwardFailure}, and there is no stdio bridge.`
)
}
// Why: a closed `ssh -L` (ours, or one an earlier Orca orphaned) may still hold the port.
if (options.localPort !== 0) {
await this.dependencies.reserveLocalPort(conn, options)
}
await this.reserveFixedLocalPort(conn, options)
const started = await this.dependencies.stdio.start(conn, options)
rememberOrcadTunnelTransport(options.connectionId, 'stdio_bridge')
return started
}
private async reserveFixedLocalPort(
conn: SshConnection,
options: PortForwardStartOptions
): Promise<void> {
if (options.localPort !== 0) {
await this.dependencies.reserveLocalPort(conn, options)
}
}
}
/** Only system SSH ever spawned an `ssh -L`, so only there can one be left holding the port. */
export function reserveBridgeLocalPort(
/**
* Only system SSH ever forwarded this port: an orphaned `ssh -L`, or the ControlMaster an older
* Orca's forward went through. That master cancels only this spec, the port Orca persisted to orcad.
*/
export function reserveManagedTunnelLocalPort(
conn: SshConnection,
options: PortForwardStartOptions
): Promise<number> {
if (!conn.usesSystemSshTransport()) {
return reserveLocalForwardPort(options.localPort)
}
const buildArgsOptions = systemSshForwardBuildArgsOptions(conn)
const orphanArgs = buildSystemSshPortForwardArgs(
conn.getTarget(),
options.localPort,
options.remoteHost,
options.remotePort,
systemSshForwardBuildArgsOptions(conn)
buildArgsOptions
)
return reserveLocalForwardPort(options.localPort, orphanArgs, () =>
cancelMuxedLocalForward(
conn.getTarget(),
options.localPort,
options.remoteHost,
options.remotePort,
buildArgsOptions
)
)
return reserveLocalForwardPort(options.localPort, orphanArgs)
}
@@ -42,7 +42,7 @@ export class OrcadStdioBridgePortForwardProvider implements SshPortForwardProvid
) {}
// System SSH runs each bridge socket as an exec over its ControlMaster, or as its own login
// when multiplexing is off; the transport only picks it once forwarding was refused.
// when multiplexing is off; the transport only picks it once a forward did not start.
canHandle(conn: SshConnection): boolean {
return conn.getClient() !== null || conn.usesSystemSshTransport()
}
@@ -117,6 +117,13 @@ describe('forward channel check (system SSH)', () => {
await expect(checkForwardChannel({ entry: dropped.entry })).resolves.toBe('unverifiable')
})
// Why: a forward still waiting on its own login (a key touch, a prompt) never binds its listener.
it('reports a listener that never came up as not listening', async () => {
const closed = await forwardAnswering(() => {})
await new Promise((resolve) => servers.pop()?.close(resolve))
await expect(checkForwardChannel(closed, 300)).resolves.toBe('not-listening')
})
it('reads the last channel failure OpenSSH logged', () => {
expect(lastChannelOpenFailure('Warning: Permanently added host\n')).toBeNull()
expect(
+10 -3
View File
@@ -9,6 +9,8 @@ import type { SshConnection } from './connection/ssh-connection'
import type { StartedPortForward } from './port-forwarding/ssh-port-forward-provider'
export type TcpForwardingVerdict = 'allowed' | 'refused' | 'unverifiable'
/** `not-listening`: no dial ever connected, so the forward never got as far as its listener. */
export type ForwardChannelVerdict = TcpForwardingVerdict | 'not-listening'
/** RFC 4254 open-failure codes: sshd policy, versus a port with nothing listening. */
const ADMINISTRATIVELY_PROHIBITED = 1
@@ -81,7 +83,7 @@ export function probeTcpForwarding(
export function checkForwardChannel(
forward: Pick<StartedPortForward, 'entry' | 'lastChannelOpenFailure'>,
timeoutMs = FORWARD_CHANNEL_CHECK_TIMEOUT_MS
): Promise<TcpForwardingVerdict> {
): Promise<ForwardChannelVerdict> {
const readFailure = forward.lastChannelOpenFailure
if (!readFailure) {
return Promise.resolve('unverifiable')
@@ -99,7 +101,8 @@ export function checkForwardChannel(
let poll: ReturnType<typeof setInterval> | undefined
let redial: ReturnType<typeof setTimeout> | undefined
let socket: Socket | undefined
const settle = (verdict: TcpForwardingVerdict): void => {
let everConnected = false
const settle = (verdict: ForwardChannelVerdict): void => {
if (settled) {
return
}
@@ -110,7 +113,10 @@ export function checkForwardChannel(
socket?.destroy()
resolve(verdict)
}
const timer = setTimeout(() => settle(verdictOf() ?? 'unverifiable'), timeoutMs)
const timer = setTimeout(
() => settle(verdictOf() ?? (everConnected ? 'unverifiable' : 'not-listening')),
timeoutMs
)
const awaitFailureLog = (): void => {
if (settled || poll) {
return
@@ -131,6 +137,7 @@ export function checkForwardChannel(
socket = current
current.once('connect', () => {
connected = true
everConnected = true
current.write(FORWARD_CHANNEL_CHECK_BYTES)
})
current.once('data', () => settle('allowed'))
@@ -0,0 +1,179 @@
/**
* An isolated real sshd for opt-in OpenSSH tests (ORCA_REAL_OPENSSH_TEST=1): its own host and
* client keys, ssh_config and ControlPath under one temp dir, so nothing reads or writes `~/.ssh`.
* The client config multiplexes through a ControlMaster, as a user's own ssh_config would.
*/
import { execFileSync, spawn, type ChildProcess } from 'node:child_process'
import {
chmodSync,
copyFileSync,
existsSync,
mkdirSync,
mkdtempSync,
rmSync,
writeFileSync
} from 'node:fs'
import { connect, createServer } from 'node:net'
import { tmpdir, userInfo } from 'node:os'
import { join } from 'node:path'
import type { SshTarget } from '../../../shared/ssh-types'
import type { SystemSshBuildArgsOptions } from './system-ssh-args'
const SSHD = '/usr/sbin/sshd'
export const REAL_OPENSSH_TEST_ENABLED =
process.env.ORCA_REAL_OPENSSH_TEST === '1' && process.platform !== 'win32' && existsSync(SSHD)
export function freePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = createServer()
server.once('error', reject)
server.listen(0, '127.0.0.1', () => {
const address = server.address()
const port = typeof address === 'object' && address ? address.port : 0
server.close(() => resolve(port))
})
})
}
export function dial(port: number): Promise<boolean> {
return new Promise((resolve) => {
const socket = connect({ host: '127.0.0.1', port })
socket.once('connect', () => {
socket.destroy()
resolve(true)
})
socket.once('error', () => resolve(false))
})
}
export async function waitFor(check: () => Promise<boolean>, ms = 5_000): Promise<void> {
for (const deadline = Date.now() + ms; Date.now() < deadline;) {
if (await check()) {
return
}
await new Promise((resolve) => setTimeout(resolve, 50))
}
throw new Error('timed out')
}
export type RealOpenSshFixture = {
dir: string
/** The HOME every session on this sshd sees, never the user's real one. */
remoteHome: string
configFile: string
target: SshTarget
buildOptions: () => SystemSshBuildArgsOptions
/** Runs the client `ssh` against the fixture's config (`-O forward`, `-O cancel`, `-O check`). */
ssh: (args: readonly string[]) => string
/** New logins fail from here on; the open master keeps working, like a login it cannot repeat. */
refuseNewLogins: () => void
close: () => Promise<void>
}
export async function startRealOpenSshFixture(
extra: { sshd?: readonly string[]; client?: readonly string[] } = {}
): Promise<RealOpenSshFixture> {
// Why /tmp: a ControlPath under macOS's long TMPDIR overflows the unix socket path limit.
const dir = mkdtempSync(join(process.platform === 'darwin' ? '/tmp' : tmpdir(), 'orca-ros-'))
execFileSync('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-f', join(dir, 'hostkey')])
execFileSync('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-f', join(dir, 'clientkey')])
const authorizedKeys = join(dir, 'authorized_keys')
copyFileSync(join(dir, 'clientkey.pub'), authorizedKeys)
chmodSync(authorizedKeys, 0o600)
const sshdPort = await freePort()
const remoteHome = join(dir, 'home')
mkdirSync(remoteHome)
writeFileSync(
join(dir, 'sshd_config'),
[
`Port ${sshdPort}`,
'ListenAddress 127.0.0.1',
`HostKey ${join(dir, 'hostkey')}`,
`AuthorizedKeysFile ${authorizedKeys}`,
'PasswordAuthentication no',
'KbdInteractiveAuthentication no',
'UsePAM no',
'StrictModes no',
`PidFile ${join(dir, 'sshd.pid')}`,
`SetEnv HOME=${remoteHome}`,
...(extra.sshd ?? [])
].join('\n')
)
// Why a space: a quoted ControlPath with one is valid, and recovery must keep it whole.
mkdirSync(join(dir, 'control sockets'))
const controlPath = join(dir, 'control sockets', 'cm-%C')
const configFile = join(dir, 'ssh_config')
const target: SshTarget = {
id: 'real-openssh',
label: 'real-openssh',
configHost: 'orca-real-openssh',
host: 'orca-real-openssh',
port: 22,
username: '',
source: 'ssh-config'
}
const alias = 'orca-real-openssh'
writeFileSync(
configFile,
[
`Host ${alias}`,
' HostName 127.0.0.1',
` Port ${sshdPort}`,
` User ${userInfo().username}`,
` IdentityFile ${join(dir, 'clientkey')}`,
' IdentitiesOnly yes',
' UserKnownHostsFile /dev/null',
' StrictHostKeyChecking no',
' ControlMaster auto',
` ControlPath "${controlPath}"`,
' ControlPersist 600',
...(extra.client ?? []).map((line) => ` ${line}`)
].join('\n')
)
const sshd: ChildProcess = spawn(SSHD, ['-D', '-e', '-f', join(dir, 'sshd_config')], {
stdio: 'ignore'
})
const ssh = (args: readonly string[]): string =>
execFileSync('ssh', ['-F', configFile, ...args, alias], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe']
})
await waitFor(() => dial(sshdPort))
// The user's own master, as `ssh <host>` with ControlMaster auto would leave behind.
ssh(['-o', 'BatchMode=yes', '-fN'])
return {
dir,
remoteHome,
configFile,
target,
buildOptions: () => ({
configFile,
resolvedConfig: {
hostname: '127.0.0.1',
port: sshdPort,
identityFile: [],
forwardAgent: false,
identitiesOnly: true,
proxyUseFdpass: false,
controlMaster: 'auto',
controlPath,
controlPersist: '600'
}
}),
ssh,
refuseNewLogins: () => writeFileSync(authorizedKeys, ''),
close: async () => {
try {
ssh(['-O', 'exit'])
} catch {
/* no master */
}
const exited = new Promise((resolve) => sshd.once('exit', resolve))
sshd.kill('SIGTERM')
await Promise.race([exited, new Promise((resolve) => setTimeout(resolve, 2_000))])
// Why the wait: the master and sshd's session children finish exiting after these return.
await new Promise((resolve) => setTimeout(resolve, 500))
rmSync(dir, { recursive: true, force: true, maxRetries: 3 })
}
}
}
@@ -118,7 +118,8 @@ export function buildSshArgs(target: SshTarget, options?: SystemSshBuildArgsOpti
* user's ssh_config) the master owns the listener and its refusals, so killing the child would
* neither close the forward nor report why sshd refused it. So a forward logs in on its own, even
* where a live master holds a login it cannot repeat: through that master its `-N` mux client
* exits at once and the port stays on the master, out of Orca's reach.
* exits at once and the port stays on the master, out of Orca's reach. A managed tunnel whose
* forward cannot log in takes the stdio bridge over the master's exec instead.
*/
export function buildForwardProcessSshArgs(
target: SshTarget,
@@ -0,0 +1,71 @@
import { createServer, type Server } from 'node:net'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { stopOrphanMock } = vi.hoisted(() => ({ stopOrphanMock: vi.fn() }))
vi.mock('./system-ssh-orphaned-forward', () => ({ stopOrphanedOrcaSshForward: stopOrphanMock }))
import { reserveLocalForwardPort } from './system-ssh-forward-port-reservation'
let holder: Server | null = null
async function holdPort(): Promise<number> {
const server = createServer()
holder = server
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
const address = server.address()
return typeof address === 'object' && address ? address.port : 0
}
function release(): Promise<boolean> {
return new Promise((resolve) => {
const server = holder
holder = null
if (!server) {
resolve(false)
return
}
server.close(() => resolve(true))
})
}
beforeEach(() => {
stopOrphanMock.mockReset().mockResolvedValue(false)
})
afterEach(async () => {
await release()
})
describe('local forward port reservation', () => {
it('asks the master only after the orphan sweep found nothing, then rebinds', async () => {
const port = await holdPort()
const cancel = vi.fn(release)
await expect(reserveLocalForwardPort(port, ['ssh'], cancel)).resolves.toBe(port)
expect(stopOrphanMock).toHaveBeenCalledWith(port, ['ssh'])
expect(stopOrphanMock.mock.invocationCallOrder[0]).toBeLessThan(
cancel.mock.invocationCallOrder[0]
)
})
it('asks no master once the orphan sweep freed the port', async () => {
const port = await holdPort()
stopOrphanMock.mockImplementation(release)
const cancel = vi.fn(async () => true)
await expect(reserveLocalForwardPort(port, ['ssh'], cancel)).resolves.toBe(port)
expect(cancel).not.toHaveBeenCalled()
})
it('keeps the bind error when neither recovery released the port', async () => {
const port = await holdPort()
await expect(reserveLocalForwardPort(port, ['ssh'], async () => false)).rejects.toThrow(
`Local port 127.0.0.1:${port} is not available`
)
})
it('tries no recovery for a port it cannot bind for any reason but use', async () => {
const cancel = vi.fn(async () => true)
await expect(reserveLocalForwardPort(-1, ['ssh'], cancel)).rejects.toThrow()
expect(stopOrphanMock).not.toHaveBeenCalled()
expect(cancel).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,66 @@
/**
* The port a system SSH forward will listen on. OpenSSH binds `-L 0` to a port it never reports,
* so port 0 is resolved to a free one here first, and a fixed port an earlier Orca left bound is
* recovered here before the caller gives up on it.
*/
import { createServer } from 'node:net'
import { stopOrphanedOrcaSshForward } from './system-ssh-orphaned-forward'
/**
* The port the forward should be spawned on: `localPort`, or a free one when it is 0. Recovery is
* only ever attempted on a port already in use, and only from what the caller supplies — a forward
* process to stop, a master to ask — so a port held by anything else keeps reporting the bind error.
*/
export async function reserveLocalForwardPort(
localPort: number,
orphanArgs?: readonly string[],
cancelMuxedForward?: () => Promise<boolean>
): Promise<number> {
try {
return await bindLocalForwardPort(localPort)
} catch (error) {
const inUse = error instanceof Error && error.cause === 'EADDRINUSE'
if (!inUse || !(await releaseLocalForwardPort(localPort, orphanArgs, cancelMuxedForward))) {
throw error
}
return bindLocalForwardPort(localPort)
}
}
/** True when a forward an earlier Orca left on `localPort` was released, by process or by master. */
async function releaseLocalForwardPort(
localPort: number,
orphanArgs?: readonly string[],
cancelMuxedForward?: () => Promise<boolean>
): Promise<boolean> {
// Why: a fixed port (a managed tunnel's) may be held by the same forward an earlier Orca
// orphaned; `orphanArgs` is that forward's argv, the only process recovery may stop.
if (orphanArgs && (await stopOrphanedOrcaSshForward(localPort, orphanArgs))) {
return true
}
// Why second: a muxed forward outlives its process, so no argv on the table can match it.
return cancelMuxedForward ? cancelMuxedForward() : false
}
/**
* The port a listener could be opened on, closed again before it is returned. Rejects with
* `EADDRINUSE` as the error's `cause`, which is what tells a busy port from an unusable one.
*/
function bindLocalForwardPort(localPort: number): Promise<number> {
return new Promise((resolve, reject) => {
const server = createServer()
server.once('error', (err: NodeJS.ErrnoException) => {
reject(
new Error(`Local port 127.0.0.1:${localPort} is not available: ${err.message}`, {
cause: err.code
})
)
})
server.once('listening', () => {
const address = server.address()
const port = typeof address === 'object' && address ? address.port : localPort
server.close(() => resolve(port))
})
server.listen(localPort, '127.0.0.1')
})
}
@@ -3,157 +3,39 @@
* user's ssh_config multiplexes through a ControlMaster. Opt-in (needs sshd and ssh on PATH):
* ORCA_REAL_OPENSSH_TEST=1 pnpm test src/main/ssh/system-transport/system-ssh-forward-process-real-openssh.test.ts
*/
import { execFileSync, spawn, type ChildProcess } from 'node:child_process'
import { existsSync, mkdtempSync, rmSync, writeFileSync, chmodSync, copyFileSync } from 'node:fs'
import { connect, createServer } from 'node:net'
import { tmpdir, userInfo } from 'node:os'
import { join } from 'node:path'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import type { SshTarget } from '../../../shared/ssh-types'
import type { SshConnection } from '../connection/ssh-connection'
import { SystemSshPortForwardProvider } from '../port-forwarding/system-ssh-port-forward-provider'
import { checkForwardChannel } from '../ssh-tcp-forwarding-probe'
import {
REAL_OPENSSH_TEST_ENABLED,
dial,
freePort,
startRealOpenSshFixture,
type RealOpenSshFixture
} from './real-openssh-sshd-fixture'
import { startSystemSshPortForwardProcess } from './system-ssh-forward-process'
const SSHD = '/usr/sbin/sshd'
const enabled =
process.env.ORCA_REAL_OPENSSH_TEST === '1' && process.platform !== 'win32' && existsSync(SSHD)
function freePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = createServer()
server.once('error', reject)
server.listen(0, '127.0.0.1', () => {
const address = server.address()
const port = typeof address === 'object' && address ? address.port : 0
server.close(() => resolve(port))
})
})
}
function dial(port: number): Promise<boolean> {
return new Promise((resolve) => {
const socket = connect({ host: '127.0.0.1', port })
socket.once('connect', () => {
socket.destroy()
resolve(true)
})
socket.once('error', () => resolve(false))
})
}
async function waitFor(check: () => Promise<boolean>, ms = 5_000): Promise<void> {
for (const deadline = Date.now() + ms; Date.now() < deadline;) {
if (await check()) {
return
}
await new Promise((resolve) => setTimeout(resolve, 50))
}
throw new Error('timed out')
}
describe.skipIf(!enabled)(
describe.skipIf(!REAL_OPENSSH_TEST_ENABLED)(
'system SSH forward process under a user ControlMaster (real OpenSSH)',
() => {
let dir = ''
let configFile = ''
let controlPath = ''
let sshd: ChildProcess | null = null
let fixture: RealOpenSshFixture
// A port sshd's PermitOpen does not allow, so opening a channel to it is administratively refused.
let prohibitedPort = 0
const target: SshTarget = {
id: 'real-openssh',
label: 'real-openssh',
configHost: 'orca-real-openssh',
host: 'orca-real-openssh',
port: 22,
username: '',
source: 'ssh-config'
}
const buildOptions = () => ({
configFile,
logChannelFailures: true,
resolvedConfig: {
hostname: '127.0.0.1',
port: 22,
identityFile: [],
forwardAgent: false,
identitiesOnly: true,
proxyUseFdpass: false,
controlMaster: 'auto',
controlPath,
controlPersist: '600'
}
})
const buildOptions = () => ({ ...fixture.buildOptions(), logChannelFailures: true })
beforeAll(async () => {
// Why /tmp: a ControlPath under macOS's long TMPDIR overflows the unix socket path limit.
dir = mkdtempSync(join(process.platform === 'darwin' ? '/tmp' : tmpdir(), 'orca-ros-'))
execFileSync('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-f', join(dir, 'hostkey')])
execFileSync('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-f', join(dir, 'clientkey')])
copyFileSync(join(dir, 'clientkey.pub'), join(dir, 'authorized_keys'))
chmodSync(join(dir, 'authorized_keys'), 0o600)
const sshdPort = await freePort()
prohibitedPort = await freePort()
writeFileSync(
join(dir, 'sshd_config'),
[
`Port ${sshdPort}`,
'ListenAddress 127.0.0.1',
`HostKey ${join(dir, 'hostkey')}`,
`AuthorizedKeysFile ${join(dir, 'authorized_keys')}`,
'PasswordAuthentication no',
'KbdInteractiveAuthentication no',
'UsePAM no',
'StrictModes no',
`PidFile ${join(dir, 'sshd.pid')}`,
'PermitOpen 127.0.0.1:1'
].join('\n')
)
controlPath = join(dir, 'cm-%C')
configFile = join(dir, 'ssh_config')
writeFileSync(
configFile,
[
`Host ${target.configHost}`,
' HostName 127.0.0.1',
` Port ${sshdPort}`,
` User ${userInfo().username}`,
` IdentityFile ${join(dir, 'clientkey')}`,
' IdentitiesOnly yes',
` UserKnownHostsFile ${join(dir, 'known_hosts')}`,
' StrictHostKeyChecking no',
' ControlMaster auto',
` ControlPath ${controlPath}`,
' ControlPersist 600'
].join('\n')
)
sshd = spawn(SSHD, ['-D', '-e', '-f', join(dir, 'sshd_config')], { stdio: 'ignore' })
await waitFor(() => dial(sshdPort))
// The user's own master, as `ssh <host>` with ControlMaster auto would leave behind.
execFileSync('ssh', ['-F', configFile, '-o', 'BatchMode=yes', '-fN', target.configHost!], {
stdio: 'ignore'
})
fixture = await startRealOpenSshFixture({ sshd: ['PermitOpen 127.0.0.1:1'] })
}, 30_000)
afterAll(() => {
try {
execFileSync('ssh', ['-F', configFile, '-O', 'exit', target.configHost!], {
stdio: 'ignore'
})
} catch {
/* no master */
}
sshd?.kill('SIGTERM')
if (dir) {
rmSync(dir, { recursive: true, force: true })
}
afterAll(async () => {
await fixture?.close()
})
it('closing the forward releases its listener', async () => {
const forward = await startSystemSshPortForwardProcess(
target,
fixture.target,
0,
'127.0.0.1',
prohibitedPort,
@@ -165,16 +47,38 @@ describe.skipIf(!enabled)(
expect(await dial(forward.localPort)).toBe(false)
}, 20_000)
// Why: any fixed-port forward starts here, and a master's forward may be the user's own; only
// the managed tunnel asks a master to give its persisted port back.
it("never takes a fixed port from a master's forward", async () => {
const heldPort = await freePort()
const spec = `127.0.0.1:${heldPort}:127.0.0.1:${prohibitedPort}`
fixture.ssh(['-O', 'forward', '-L', spec])
try {
await expect(
startSystemSshPortForwardProcess(
fixture.target,
heldPort,
'127.0.0.1',
prohibitedPort,
buildOptions()
)
).rejects.toThrow(/is not available/)
expect(await dial(heldPort)).toBe(true)
} finally {
fixture.ssh(['-O', 'cancel', '-L', spec])
}
}, 20_000)
it("sees sshd's administrative refusal on its own stderr", async () => {
const conn = {
getTarget: () => target,
getTarget: () => fixture.target,
getSystemSshBuildArgsOptions: buildOptions
} satisfies Pick<SshConnection, 'getTarget' | 'getSystemSshBuildArgsOptions'>
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the system SSH provider's start reads only getTarget and getSystemSshBuildArgsOptions.
const connection = conn as unknown as SshConnection
const started = await new SystemSshPortForwardProvider().start(connection, {
id: 'pf-real',
connectionId: target.id,
connectionId: fixture.target.id,
localHost: '127.0.0.1',
localPort: 0,
remoteHost: '127.0.0.1',
@@ -1,20 +1,30 @@
import { EventEmitter } from 'node:events'
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
const { existsSyncMock, spawnMock, connectMock, createServerMock, stopOrphanMock } = vi.hoisted(
() => ({
existsSyncMock: vi.fn(),
spawnMock: vi.fn(),
connectMock: vi.fn(),
createServerMock: vi.fn(),
stopOrphanMock: vi.fn()
})
)
const {
existsSyncMock,
spawnMock,
connectMock,
createServerMock,
stopOrphanMock,
cancelMuxedMock
} = vi.hoisted(() => ({
existsSyncMock: vi.fn(),
spawnMock: vi.fn(),
connectMock: vi.fn(),
createServerMock: vi.fn(),
stopOrphanMock: vi.fn(),
cancelMuxedMock: vi.fn()
}))
vi.mock('./system-ssh-orphaned-forward', () => ({
stopOrphanedOrcaSshForward: stopOrphanMock
}))
vi.mock('./system-ssh-muxed-forward-cancel', () => ({
cancelMuxedLocalForward: cancelMuxedMock
}))
vi.mock('fs', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
return {
@@ -122,6 +132,7 @@ describe('system SSH forward process', () => {
connectMock.mockReset()
createServerMock.mockReset().mockImplementation(createFakeServer)
stopOrphanMock.mockReset().mockResolvedValue(false)
cancelMuxedMock.mockReset().mockResolvedValue(false)
mockSystemSshExists()
})
@@ -290,6 +301,8 @@ describe('system SSH forward process', () => {
startSystemSshPortForwardProcess(createTarget(), 5173, '127.0.0.1', 3000)
).rejects.toThrow('Local port 127.0.0.1:5173 is not available')
expect(stopOrphanMock).toHaveBeenCalledWith(5173, expect.any(Array))
// Why: any fixed-port forward reaches here, and the master's forward may be the user's own.
expect(cancelMuxedMock).not.toHaveBeenCalled()
expect(spawnMock).not.toHaveBeenCalled()
})
@@ -1,8 +1,8 @@
import { spawn, type ChildProcess } from 'node:child_process'
import { connect, createServer } from 'node:net'
import { connect } from 'node:net'
import { buildForwardProcessSshArgs, type SystemSshBuildArgsOptions } from './system-ssh-args'
import { findSystemSsh } from './system-ssh-binary'
import { stopOrphanedOrcaSshForward } from './system-ssh-orphaned-forward'
import { reserveLocalForwardPort } from './system-ssh-forward-port-reservation'
import type { SshTarget } from '../../../shared/ssh-types'
export const SYSTEM_SSH_FORWARD_STARTUP_GRACE_MS = 750
@@ -72,6 +72,10 @@ export function buildSystemSshPortForwardArgs(
return args
}
/**
* An `ssh -N` forward, already listening on the port it reports. Never asks a ControlMaster to
* cancel a forward: any fixed port reaches here, so that is the managed tunnel's call alone.
*/
export function startSystemSshPortForwardProcess(
target: SshTarget,
localPort: number,
@@ -131,46 +135,6 @@ function stopOnOwnListenFailure(process: ChildProcess, localPort: number): void
process.once('exit', stop)
}
/**
* The port a system SSH forward will listen on. OpenSSH binds `-L 0` to a port it never
* reports, so port 0 is resolved to a free one here first.
*/
export async function reserveLocalForwardPort(
localPort: number,
orphanArgs?: readonly string[]
): Promise<number> {
try {
return await bindLocalForwardPort(localPort)
} catch (error) {
// Why: a fixed port (a managed tunnel's) may be held by the same forward an earlier Orca
// orphaned; `orphanArgs` is that forward's argv, the only process recovery may stop.
const inUse = error instanceof Error && error.cause === 'EADDRINUSE'
if (!orphanArgs || !inUse || !(await stopOrphanedOrcaSshForward(localPort, orphanArgs))) {
throw error
}
return bindLocalForwardPort(localPort)
}
}
function bindLocalForwardPort(localPort: number): Promise<number> {
return new Promise((resolve, reject) => {
const server = createServer()
server.once('error', (err: NodeJS.ErrnoException) => {
reject(
new Error(`Local port 127.0.0.1:${localPort} is not available: ${err.message}`, {
cause: err.code
})
)
})
server.once('listening', () => {
const address = server.address()
const port = typeof address === 'object' && address ? address.port : localPort
server.close(() => resolve(port))
})
server.listen(localPort, '127.0.0.1')
})
}
export function waitForSystemSshForwardStartup(
process: ChildProcess,
localPort: number
@@ -0,0 +1,158 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
const { findSystemSshMock, runProcessMock } = vi.hoisted(() => ({
findSystemSshMock: vi.fn(),
runProcessMock: vi.fn()
}))
vi.mock('@orca/process-host', () => ({ runProcess: runProcessMock }))
vi.mock('./system-ssh-binary', () => ({ findSystemSsh: findSystemSshMock }))
import {
buildControlPathQueryArgs,
buildMuxedLocalForwardCancelArgs,
cancelMuxedLocalForward,
parseControlPath
} from './system-ssh-muxed-forward-cancel'
import type { SshTarget } from '../../../shared/ssh-types'
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
/** A manual target by default; `source: 'ssh-config'` makes it config-backed instead. */
function createTarget(overrides?: Partial<SshTarget>): SshTarget {
return {
id: 'target-1',
label: 'Test Server',
host: 'example.com',
port: 22,
username: 'deploy',
...overrides
}
}
const ok = (stdout = '', stderr = '') => ({ code: 0, signal: null, stdout, stderr })
const controlPathAnswer = ok('user deploy\ncontrolpath /tmp/cm-abc123\nport 22\n')
function cancelCall() {
return runProcessMock.mock.calls.find(([request]) => request.args.includes('cancel'))?.[0]
}
describe('system SSH muxed forward cancel', () => {
beforeEach(() => {
Object.defineProperty(process, 'platform', { value: 'darwin' })
findSystemSshMock.mockReset().mockReturnValue('/usr/bin/ssh')
runProcessMock
.mockReset()
.mockImplementation(async (request: { args: string[] }) =>
request.args[0] === '-G' ? controlPathAnswer : ok()
)
})
afterEach(() => {
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
})
it('reads the control socket with the target’s own args, multiplexed', () => {
const args = buildControlPathQueryArgs(
createTarget({ configHost: 'workbox', source: 'ssh-config' })
)
expect(args[0]).toBe('-G')
expect(args.at(-1)).toBe('workbox')
expect(args.some((arg, index) => arg === '-S' && args[index + 1] === 'none')).toBe(false)
})
it('takes the expanded ControlPath, and none where the target does not multiplex', () => {
expect(parseControlPath(controlPathAnswer.stdout)).toBe('/tmp/cm-abc123')
expect(parseControlPath('user deploy\ncontrolpath none\n')).toBeNull()
expect(parseControlPath('user deploy\n')).toBeNull()
})
// Why: `-O cancel` also cancels every forward a loaded ssh_config declares for the host.
it('asks that socket to cancel only this exact forward, reading no ssh_config', () => {
const args = buildMuxedLocalForwardCancelArgs(
'/tmp/my sockets/cm-50%',
59418,
'127.0.0.1',
6768
)
expect(args.slice(0, 2)).toEqual(['-F', '/dev/null'])
expect(args[args.indexOf('-S') + 1]).toBe('/tmp/my sockets/cm-50%%')
expect(args.indexOf('-O')).toBeLessThan(args.indexOf('--'))
expect(args[args.indexOf('-O') + 1]).toBe('cancel')
expect(args.filter((arg) => arg === '-L')).toHaveLength(1)
expect(args).toContain('127.0.0.1:59418:127.0.0.1:6768')
})
it('reports the port released when the master dropped the forward', async () => {
await expect(cancelMuxedLocalForward(createTarget(), 59418, '127.0.0.1', 6768)).resolves.toBe(
true
)
expect(cancelCall()?.args).toContain('/tmp/cm-abc123')
})
it('asks nothing when the target has no control socket', async () => {
runProcessMock.mockResolvedValue(ok('controlpath none\n'))
await expect(cancelMuxedLocalForward(createTarget(), 59418, '127.0.0.1', 6768)).resolves.toBe(
false
)
expect(cancelCall()).toBeUndefined()
})
// A master that never registered this spec, or no master at all: nothing was released.
it('reports nothing released when ssh refuses the request', async () => {
runProcessMock.mockImplementation(async (request: { args: string[] }) =>
request.args[0] === '-G'
? controlPathAnswer
: {
code: 255,
signal: null,
stdout: '',
stderr: 'Control socket connect(/tmp/cm): No such file or directory'
}
)
await expect(cancelMuxedLocalForward(createTarget(), 59418, '127.0.0.1', 6768)).resolves.toBe(
false
)
})
// Why: a refused cancel exits 0, so only its stderr tells the port apart from a released one.
it('reports nothing released when the master refuses the spec but still exits 0', async () => {
runProcessMock.mockImplementation(async (request: { args: string[] }) =>
request.args[0] === '-G'
? controlPathAnswer
: ok(
'',
'mux_client_forward: forwarding request failed: port not forwarded\nmuxclient: master cancel forward request failed\n'
)
)
await expect(cancelMuxedLocalForward(createTarget(), 59418, '127.0.0.1', 6768)).resolves.toBe(
false
)
})
it('reports nothing released when ssh cannot be spawned at all', async () => {
runProcessMock.mockRejectedValue(new Error('ENOENT'))
await expect(cancelMuxedLocalForward(createTarget(), 59418, '127.0.0.1', 6768)).resolves.toBe(
false
)
})
it('asks no master on Windows, whose OpenSSH cannot multiplex', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' })
await expect(cancelMuxedLocalForward(createTarget(), 59418, '127.0.0.1', 6768)).resolves.toBe(
false
)
expect(runProcessMock).not.toHaveBeenCalled()
})
it('reports nothing released without a system ssh binary', async () => {
findSystemSshMock.mockReturnValue(null)
await expect(cancelMuxedLocalForward(createTarget(), 59418, '127.0.0.1', 6768)).resolves.toBe(
false
)
expect(runProcessMock).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,93 @@
/**
* A forward made through a ControlMaster leaves its listener on that master after its `-N` client
* exits, so no process holds the port for the orphan sweep to stop; only the master can drop it.
*/
import { runProcess } from '@orca/process-host'
import { buildSshArgs, type SystemSshBuildArgsOptions } from './system-ssh-args'
import { findSystemSsh } from './system-ssh-binary'
import type { SshTarget } from '../../../shared/ssh-types'
export const SYSTEM_SSH_MUXED_FORWARD_CANCEL_TIMEOUT_MS = 5_000
/** `ssh -G` with the target's own args: the control socket its commands multiplex through. */
export function buildControlPathQueryArgs(
target: SshTarget,
options?: SystemSshBuildArgsOptions
): string[] {
return ['-G', ...buildSshArgs(target, options)]
}
/** The expanded ControlPath `ssh -G` reports, or null where the target does not multiplex. */
export function parseControlPath(stdout: string): string | null {
const path = /^controlpath (.+)$/im.exec(stdout)?.[1]?.trim()
return path && path.toLowerCase() !== 'none' ? path : null
}
/**
* No config file: `-O cancel` cancels every forward the loaded config declares as well as this
* `-L`, so reading the host's config would also drop its LocalForward/RemoteForward entries.
*/
export function buildMuxedLocalForwardCancelArgs(
controlPath: string,
localPort: number,
remoteHost: string,
remotePort: number
): string[] {
return [
'-F',
'/dev/null',
// Why -S, not -o: an -o value is split on spaces. Doubled: ssh expands `%` tokens in it again.
'-S',
controlPath.replaceAll('%', '%%'),
'-O',
'cancel',
'-L',
`127.0.0.1:${localPort}:${remoteHost}:${remotePort}`,
'--',
'orca-muxed-forward-cancel'
]
}
/** A refused `-O cancel` still exits 0, reporting `master cancel forward request failed`. */
function refusedMuxedForwardCancel(stderr: string): boolean {
return stderr.includes('request failed')
}
async function runSsh(sshPath: string, args: string[]) {
return runProcess({
program: sshPath,
args,
timeoutMs: SYSTEM_SSH_MUXED_FORWARD_CANCEL_TIMEOUT_MS
}).catch(() => null)
}
/** True when the target's master released exactly this forward; OpenSSH refuses any other spec. */
export async function cancelMuxedLocalForward(
target: SshTarget,
localPort: number,
remoteHost: string,
remotePort: number,
options?: SystemSshBuildArgsOptions
): Promise<boolean> {
// Why: Win32-OpenSSH has no multiplexing, so no master there can hold the port.
const sshPath = process.platform === 'win32' ? null : findSystemSsh()
if (!sshPath) {
return false
}
const query = await runSsh(sshPath, buildControlPathQueryArgs(target, options))
const controlPath = query?.code === 0 ? parseControlPath(query.stdout) : null
if (!controlPath) {
return false
}
const result = await runSsh(
sshPath,
buildMuxedLocalForwardCancelArgs(controlPath, localPort, remoteHost, remotePort)
)
if (result?.code !== 0 || refusedMuxedForwardCancel(result.stderr)) {
return false
}
console.warn(
`[ssh] Cancelled a forward a previous Orca left on 127.0.0.1:${localPort}, held by its ControlMaster`
)
return true
}