mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 12:26:38 -06:00
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:
@@ -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,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",
|
||||
|
||||
Reference in New Issue
Block a user