mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
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:
@@ -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'})`))
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user