fix(ssh): scope tunnel port preflight to loopback (#94603) (#94607)

Merged via squash.

Prepared head SHA: 6798b718de
Co-authored-by: wangwllu <7668944+wangwllu@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
This commit is contained in:
Lu Wang
2026-06-20 01:59:58 +08:00
committed by GitHub
parent 7b94ae9944
commit 583829a342
2 changed files with 130 additions and 7 deletions
+126 -3
View File
@@ -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<void>>(),
spawn: vi.fn(),
}));
vi.mock("./ports.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./ports.js")>()),
ensurePortAvailable: mocks.ensurePortAvailable,
}));
vi.mock("node:child_process", async (importOriginal) => ({
...(await importOriginal<typeof import("node:child_process")>()),
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<void>((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<void>((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();
});
});
+4 -4
View File
@@ -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",