diff --git a/src/infra/ssh-tunnel.test.ts b/src/infra/ssh-tunnel.test.ts index fc579437d1e9..2ceba9709827 100644 --- a/src/infra/ssh-tunnel.test.ts +++ b/src/infra/ssh-tunnel.test.ts @@ -1,6 +1,25 @@ -// Covers SSH target parsing. -import { describe, expect, it } from "vitest"; -import { parseSshTarget } from "./ssh-tunnel.js"; +// Covers SSH target parsing and tunnel startup preflight behavior. +import { EventEmitter } from "node:events"; +import net from "node:net"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + ensurePortAvailable: vi.fn<(port: number, host?: string) => Promise>(), + spawn: vi.fn(), +})); + +vi.mock("./ports.js", async (importOriginal) => ({ + ...(await importOriginal()), + ensurePortAvailable: mocks.ensurePortAvailable, +})); + +vi.mock("node:child_process", async (importOriginal) => ({ + ...(await importOriginal()), + spawn: mocks.spawn, +})); + +import { PortInUseError } from "./ports.js"; +import { parseSshTarget, startSshPortForward } from "./ssh-tunnel.js"; describe("parseSshTarget", () => { it("parses user@host:port targets", () => { @@ -30,3 +49,107 @@ describe("parseSshTarget", () => { expect(parseSshTarget("-oProxyCommand=echo")).toBeNull(); }); }); + +describe("startSshPortForward", () => { + const openServers: net.Server[] = []; + + afterEach(async () => { + while (openServers.length > 0) { + const server = openServers.pop(); + await new Promise((resolve) => { + server?.close(() => resolve()); + }); + } + mocks.ensurePortAvailable.mockReset(); + mocks.spawn.mockReset(); + }); + + // Fake ssh child that, when spawned, parses the -L forward spec and starts a + // real IPv4-loopback listener on the chosen local port so waitForLocalListener + // resolves without launching a real ssh process. + function spawnFakeSshListening() { + mocks.spawn.mockImplementation((_cmd: string, args: string[]) => { + const forwardSpec = args[args.indexOf("-L") + 1] ?? ""; + const localPort = Number(forwardSpec.split(":")[1]); + const server = net.createServer(); + server.on("error", () => {}); + openServers.push(server); + server.listen(localPort, "127.0.0.1"); + + const child = new EventEmitter() as EventEmitter & { + killed: boolean; + pid: number; + stderr: EventEmitter & { setEncoding: (enc: string) => void }; + kill: (signal?: string) => boolean; + }; + child.killed = false; + child.pid = 4242; + const stderr = new EventEmitter() as EventEmitter & { setEncoding: (enc: string) => void }; + stderr.setEncoding = () => {}; + child.stderr = stderr; + child.kill = (signal?: string) => { + child.killed = true; + queueMicrotask(() => child.emit("exit", 0, signal ?? null)); + return true; + }; + return child; + }); + } + + it("scopes the preferred-port preflight to the IPv4 loopback interface", async () => { + const sentinel = new Error("stop before spawning ssh"); + mocks.ensurePortAvailable.mockRejectedValueOnce(sentinel); + + await expect( + startSshPortForward({ + target: "me@example.com:2222", + localPortPreferred: 43210, + remotePort: 18789, + timeoutMs: 250, + }), + ).rejects.toBe(sentinel); + + expect(mocks.ensurePortAvailable).toHaveBeenCalledWith(43210, "127.0.0.1"); + }); + + it("falls back to an ephemeral port when the preferred port is in use", async () => { + // ensurePortAvailable raises the domain PortInUseError (no errno `code`), + // which the catch must treat as "busy" and route to pickEphemeralPort. + // Reserve a real port so pickEphemeralPort (listen(0)) cannot hand the same + // number back and make the assertion flaky. + const occupied = net.createServer(); + await new Promise((resolve, reject) => { + occupied.once("error", reject); + occupied.listen(0, "127.0.0.1", () => { + occupied.off("error", reject); + resolve(); + }); + }); + openServers.push(occupied); + const addr = occupied.address(); + if (!addr || typeof addr === "string") { + throw new Error("failed to reserve preferred port"); + } + const preferredPort = addr.port; + + mocks.ensurePortAvailable.mockRejectedValueOnce(new PortInUseError(preferredPort)); + spawnFakeSshListening(); + + const tunnel = await startSshPortForward({ + target: "me@example.com:2222", + localPortPreferred: preferredPort, + remotePort: 18789, + timeoutMs: 1000, + }); + + expect(tunnel.localPort).not.toBe(preferredPort); + expect(tunnel.localPort).toBeGreaterThan(0); + expect(mocks.spawn).toHaveBeenCalledWith( + "/usr/bin/ssh", + expect.arrayContaining(["-L", `127.0.0.1:${tunnel.localPort}:127.0.0.1:18789`]), + expect.anything(), + ); + + await tunnel.stop(); + }); +}); diff --git a/src/infra/ssh-tunnel.ts b/src/infra/ssh-tunnel.ts index baed92e6d42c..a6c9e945a60e 100644 --- a/src/infra/ssh-tunnel.ts +++ b/src/infra/ssh-tunnel.ts @@ -4,7 +4,7 @@ import net from "node:net"; import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization"; import { formatErrorMessage, isErrno } from "./errors.js"; import { parseStrictPositiveInteger } from "./parse-finite-number.js"; -import { ensurePortAvailable } from "./ports.js"; +import { ensurePortAvailable, PortInUseError } from "./ports.js"; export type SshParsedTarget = { user?: string; @@ -119,9 +119,9 @@ export async function startSshPortForward(opts: { let localPort = opts.localPortPreferred; try { - await ensurePortAvailable(localPort); + await ensurePortAvailable(localPort, "127.0.0.1"); } catch (err) { - if (isErrno(err) && err.code === "EADDRINUSE") { + if (err instanceof PortInUseError || (isErrno(err) && err.code === "EADDRINUSE")) { localPort = await pickEphemeralPort(); } else { throw err; @@ -132,7 +132,7 @@ export async function startSshPortForward(opts: { const args = [ "-N", "-L", - `${localPort}:127.0.0.1:${opts.remotePort}`, + `127.0.0.1:${localPort}:127.0.0.1:${opts.remotePort}`, "-p", String(parsed.port), "-o",