diff --git a/src/main/ssh/connection/ssh-connection.ts b/src/main/ssh/connection/ssh-connection.ts index 4b442390bdd..6f4e7920d87 100644 --- a/src/main/ssh/connection/ssh-connection.ts +++ b/src/main/ssh/connection/ssh-connection.ts @@ -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() @@ -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 diff --git a/src/main/ssh/system-transport/system-ssh-args.ts b/src/main/ssh/system-transport/system-ssh-args.ts index 3c72d3919a0..c2677cf44cf 100644 --- a/src/main/ssh/system-transport/system-ssh-args.ts +++ b/src/main/ssh/system-transport/system-ssh-args.ts @@ -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 } diff --git a/src/main/ssh/system-transport/system-ssh-forward-process.test.ts b/src/main/ssh/system-transport/system-ssh-forward-process.test.ts index 8c45fb8ac60..695b9eacd76 100644 --- a/src/main/ssh/system-transport/system-ssh-forward-process.test.ts +++ b/src/main/ssh/system-transport/system-ssh-forward-process.test.ts @@ -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() diff --git a/src/main/ssh/system-transport/system-ssh-forward-process.ts b/src/main/ssh/system-transport/system-ssh-forward-process.ts index 699f441e769..7a4b59877b0 100644 --- a/src/main/ssh/system-transport/system-ssh-forward-process.ts +++ b/src/main/ssh/system-transport/system-ssh-forward-process.ts @@ -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 { 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() }) } diff --git a/src/main/ssh/system-transport/system-ssh-loopback-route.test.ts b/src/main/ssh/system-transport/system-ssh-loopback-route.test.ts new file mode 100644 index 00000000000..86e42c87747 --- /dev/null +++ b/src/main/ssh/system-transport/system-ssh-loopback-route.test.ts @@ -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 + 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 { + return { + id: 'target-1', + label: 'Dev box', + host: 'devbox', + configHost: 'devbox', + port: 22, + username: '', + source: 'ssh-config', + ...overrides + } +} + +function createResolved(overrides?: Partial): 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 + 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 + close: ReturnType + address: ReturnType + } + 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')) + }) +}) diff --git a/src/main/ssh/system-transport/system-ssh-loopback-route.ts b/src/main/ssh/system-transport/system-ssh-loopback-route.ts new file mode 100644 index 00000000000..013f35690bd --- /dev/null +++ b/src/main/ssh/system-transport/system-ssh-loopback-route.ts @@ -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 { + 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 { + 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'})`)) + }) + }) +}