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
via terminateWindowsProcessTree.

Fixes #31558
This commit is contained in:
Bingxin Xu
2026-10-21 20:10:29 -04:00
parent 88aa3f27ce
commit b1cdd46d5c
6 changed files with 618 additions and 6 deletions
+45
View File
@@ -27,6 +27,11 @@ import {
type SystemSshBuildArgsOptions
} from '../system-transport/system-ssh-args'
import { spawnSystemSshCommand } from '../system-transport/system-ssh-command'
import {
shouldUseSystemSshLoopbackRoute,
startSystemSshLoopbackRoute,
type SystemSshLoopbackRoute
} from '../system-transport/system-ssh-loopback-route'
import { removeControlSocketPath } from '../ssh-control-socket'
import {
INITIAL_RETRY_ATTEMPTS,
@@ -193,6 +198,8 @@ export class SshConnection {
} | null = null
private systemSshControlMasterDisabledForSession = false
private systemSshGssapiOnlyForSession = false
private systemSshLoopbackRoute: SystemSshLoopbackRoute | null = null
private systemSshLoopbackRouteStarting = false
private useSystemSshTransport = false
private credentialAbortController = new AbortController()
private readonly pendingSsh2Clients = new Set<SshClient>()
@@ -965,6 +972,7 @@ export class SshConnection {
)
) {
this.setState('connected')
this.startSystemSshLoopbackRoute(connectGeneration)
resolve()
return
}
@@ -1088,9 +1096,44 @@ 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.systemSshLoopbackRoute ||
this.systemSshLoopbackRouteStarting ||
!shouldUseSystemSshLoopbackRoute(this.target, this.systemSshResolvedConfig, options)
) {
return
}
this.systemSshLoopbackRouteStarting = true
startSystemSshLoopbackRoute(this.target, this.systemSshResolvedConfig, options).then(
(route) => {
this.systemSshLoopbackRouteStarting = false
if (!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.
this.systemSshLoopbackRouteStarting = false
}
)
}
// 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,
@@ -1572,6 +1615,8 @@ export class SshConnection {
}
private resetSystemTransport(): void {
this.systemSshLoopbackRoute?.dispose()
this.systemSshLoopbackRoute = null
this.systemSshResolvedConfig = null
this.systemSshControlMasterDisabledForSession = false
this.systemSshGssapiOnlyForSession = false
@@ -18,6 +18,13 @@ 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
}
export type SystemSshLoopbackRouteArgs = {
localPort: number
hostKeyAlias: string
}
export function buildSshArgs(target: SshTarget, options?: SystemSshBuildArgsOptions): string[] {
@@ -64,6 +71,16 @@ export function buildSshArgs(target: SshTarget, options?: SystemSshBuildArgsOpti
}
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 —
@@ -74,7 +91,7 @@ export function buildSshArgs(target: SshTarget, options?: SystemSshBuildArgsOpti
appendUnclaimedAliasEndpoint(args, target)
}
if (!useConfigHost && target.port !== 22) {
if (!useConfigHost && !loopbackRoute && target.port !== 22) {
args.push('-p', String(target.port))
}
@@ -96,11 +113,12 @@ export function buildSshArgs(target: SshTarget, options?: SystemSshBuildArgsOpti
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}`)
}
@@ -167,6 +185,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()
@@ -135,9 +135,11 @@ 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
): Promise<void> {
return new Promise((resolve, reject) => {
let stderr = ''
@@ -189,8 +191,18 @@ 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)
scheduleProbe()
})
}
@@ -0,0 +1,340 @@
import { EventEmitter } from 'node:events'
import { PassThrough } from 'node:stream'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { existsSyncMock, spawnMock, connectMock, createServerMock, treeKillMock } = vi.hoisted(
() => ({
existsSyncMock: vi.fn(),
spawnMock: vi.fn(),
connectMock: vi.fn(),
createServerMock: vi.fn(),
treeKillMock: vi.fn()
})
)
vi.mock('../../windows-process-tree-kill', () => ({
terminateWindowsProcessTree: 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, getSystemSshBuildArgsFromOperationOptions } from './system-ssh-args'
import {
getLoopbackRouteHostKeyAlias,
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',
...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
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(): EventEmitter {
// 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('carries the route into file-transfer operations', () => {
expect(getSystemSshBuildArgsFromOperationOptions({ loopbackRoute: ROUTE })).toEqual({
loopbackRoute: ROUTE
})
})
})
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
],
['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
]
] as const)('%s → %s', (_name, target, resolved, options, platform, expected) => {
expect(shouldUseSystemSshLoopbackRoute(target, resolved, options, platform)).toBe(expected)
})
})
describe('getLoopbackRouteHostKeyAlias', () => {
it("keeps the known_hosts name OpenSSH used for the proxied route's host and port", () => {
expect(
getLoopbackRouteHostKeyAlias(createTarget(), createResolved({ hostname: 'i-0abc' }))
).toBe('i-0abc')
expect(
getLoopbackRouteHostKeyAlias(
createTarget(),
createResolved({ hostname: 'i-0abc', port: 2222 })
)
).toBe('[i-0abc]:2222')
})
it("prefers the user's own HostKeyAlias", () => {
expect(
getLoopbackRouteHostKeyAlias(createTarget(), createResolved({ hostKeyAlias: 'pinned' }))
).toBe('pinned')
})
})
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)
})
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(),
createResolved(),
{ 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('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(), createResolved(), {}, '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(), createResolved(), {}, '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, falling back to the direct kill', async () => {
const forward = createFakeProcess()
const probe = createFakeProcess()
spawnMock.mockReturnValueOnce(forward).mockImplementationOnce(() => {
answerProbe(probe, 0)
return probe
})
const route = await startSystemSshLoopbackRoute(createTarget(), createResolved(), {}, 'win32')
treeKillMock.mockResolvedValueOnce(false)
route.dispose()
expect(treeKillMock).toHaveBeenCalledWith(4242, { site: 'system-ssh-loopback-route' })
await vi.waitFor(() => expect(forward.kill).toHaveBeenCalledWith('SIGTERM'))
})
})
@@ -0,0 +1,148 @@
/**
* 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 { ChildProcess } from 'node:child_process'
import type { SshTarget } from '../../../shared/ssh-types'
import { terminateWindowsProcessTree } from '../../windows-process-tree-kill'
import type { SshResolvedConfig } from '../config/ssh-g-config-resolution'
import type { SystemSshBuildArgsOptions, 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 {
if (platform !== 'win32' || 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) {
return false
}
return Boolean(
resolved?.proxyCommand || resolved?.proxyJump || target.proxyCommand || target.jumpHost
)
}
/** Known-hosts name OpenSSH would have used for the proxied route, so trust carries over. */
export function getLoopbackRouteHostKeyAlias(
target: SshTarget,
resolved: SshResolvedConfig | null
): string {
if (resolved?.hostKeyAlias) {
return resolved.hostKeyAlias
}
const host = resolved?.hostname || target.host
const port = resolved?.port || target.port || 22
return port === 22 ? host : `[${host}]:${port}`
}
export async function startSystemSshLoopbackRoute(
target: SshTarget,
resolved: SshResolvedConfig | null,
options: SystemSshBuildArgsOptions,
platform: NodeJS.Platform = process.platform
): Promise<SystemSshLoopbackRoute> {
const remotePort = resolved?.port || target.port || 22
// Why: the forward itself must take the real proxy route, never an existing loopback one.
const forward = await startSystemSshPortForwardProcess(target, 0, 'localhost', remotePort, {
...options,
loopbackRoute: undefined
})
const dispose = (): void => stopLoopbackForward(forward.process, forward.dispose, platform)
try {
await waitForSystemSshForwardStartup(
forward.process,
forward.localPort,
SYSTEM_SSH_LOOPBACK_ROUTE_LISTEN_TIMEOUT_MS
)
const args = {
localPort: forward.localPort,
hostKeyAlias: getLoopbackRouteHostKeyAlias(target, resolved)
}
// Why: a listening forward does not prove sshd allows it (AllowTcpForwarding) or that auth works over it.
await probeLoopbackRoute(target, { ...options, loopbackRoute: args })
return {
args,
onExit: (callback) => {
forward.process.once('exit', callback)
},
dispose
}
} catch (error) {
dispose()
throw error
}
}
function stopLoopbackForward(
forwardProcess: ChildProcess,
killForward: () => void,
platform: NodeJS.Platform
): void {
const pid = forwardProcess.pid
if (platform !== 'win32' || !pid) {
killForward()
return
}
// Why: Windows does not end a killed ssh's ProxyCommand child, which would keep the proxy tunnel open.
void terminateWindowsProcessTree(pid, { site: 'system-ssh-loopback-route' }).then((stopped) => {
if (!stopped) {
killForward()
}
})
}
function probeLoopbackRoute(target: SshTarget, options: SystemSshBuildArgsOptions): Promise<void> {
const channel = spawnSystemSshCommand(target, `echo ${LOOPBACK_ROUTE_PROBE_MARKER}`, {
...options,
nonInteractive: true,
wrapCommand: false
})
return new Promise((resolve, reject) => {
let stdout = ''
const timeout = setTimeout(() => {
channel.close()
reject(new Error('System SSH loopback route probe timed out'))
}, SYSTEM_SSH_LOOPBACK_ROUTE_PROBE_TIMEOUT_MS)
channel.on('data', (data: Buffer) => {
stdout += data.toString('utf-8')
})
channel.stderr.resume()
channel.once('error', (error: Error) => {
clearTimeout(timeout)
reject(error)
})
channel.once('close', (code: number | null) => {
clearTimeout(timeout)
if (code === 0 && stdout.includes(LOOPBACK_ROUTE_PROBE_MARKER)) {
resolve()
return
}
reject(new Error(`System SSH loopback route probe failed (exit ${code ?? 'unknown'})`))
})
})
}