Files
clawdbot/src/infra/ssh-tunnel.ts
Peter Steinberger c379191f80 chore: migrate to oxlint and oxfmt
Co-authored-by: Christoph Nakazawa <christoph.pojer@gmail.com>
2026-01-14 15:02:19 +00:00

194 lines
5.1 KiB
TypeScript

import { spawn } from "node:child_process";
import net from "node:net";
import { ensurePortAvailable } from "./ports.js";
export type SshParsedTarget = {
user?: string;
host: string;
port: number;
};
export type SshTunnel = {
parsedTarget: SshParsedTarget;
localPort: number;
remotePort: number;
pid: number | null;
stderr: string[];
stop: () => Promise<void>;
};
function isErrno(err: unknown): err is NodeJS.ErrnoException {
return Boolean(err && typeof err === "object" && "code" in err);
}
export function parseSshTarget(raw: string): SshParsedTarget | null {
const trimmed = raw.trim().replace(/^ssh\s+/, "");
if (!trimmed) return null;
const [userPart, hostPart] = trimmed.includes("@")
? ((): [string | undefined, string] => {
const idx = trimmed.indexOf("@");
const user = trimmed.slice(0, idx).trim();
const host = trimmed.slice(idx + 1).trim();
return [user || undefined, host];
})()
: [undefined, trimmed];
const colonIdx = hostPart.lastIndexOf(":");
if (colonIdx > 0 && colonIdx < hostPart.length - 1) {
const host = hostPart.slice(0, colonIdx).trim();
const portRaw = hostPart.slice(colonIdx + 1).trim();
const port = Number.parseInt(portRaw, 10);
if (!host || !Number.isFinite(port) || port <= 0) return null;
return { user: userPart, host, port };
}
if (!hostPart) return null;
return { user: userPart, host: hostPart, port: 22 };
}
async function pickEphemeralPort(): Promise<number> {
return await new Promise<number>((resolve, reject) => {
const server = net.createServer();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const addr = server.address();
server.close(() => {
if (!addr || typeof addr === "string") {
reject(new Error("failed to allocate a local port"));
return;
}
resolve(addr.port);
});
});
});
}
async function canConnectLocal(port: number): Promise<boolean> {
return await new Promise<boolean>((resolve) => {
const socket = net.connect({ host: "127.0.0.1", port });
const done = (ok: boolean) => {
socket.removeAllListeners();
socket.destroy();
resolve(ok);
};
socket.once("connect", () => done(true));
socket.once("error", () => done(false));
socket.setTimeout(250, () => done(false));
});
}
async function waitForLocalListener(port: number, timeoutMs: number): Promise<void> {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (await canConnectLocal(port)) return;
await new Promise((r) => setTimeout(r, 50));
}
throw new Error(`ssh tunnel did not start listening on localhost:${port}`);
}
export async function startSshPortForward(opts: {
target: string;
identity?: string;
localPortPreferred: number;
remotePort: number;
timeoutMs: number;
}): Promise<SshTunnel> {
const parsed = parseSshTarget(opts.target);
if (!parsed) throw new Error(`invalid SSH target: ${opts.target}`);
let localPort = opts.localPortPreferred;
try {
await ensurePortAvailable(localPort);
} catch (err) {
if (isErrno(err) && err.code === "EADDRINUSE") {
localPort = await pickEphemeralPort();
} else {
throw err;
}
}
const userHost = parsed.user ? `${parsed.user}@${parsed.host}` : parsed.host;
const args = [
"-N",
"-L",
`${localPort}:127.0.0.1:${opts.remotePort}`,
"-p",
String(parsed.port),
"-o",
"ExitOnForwardFailure=yes",
"-o",
"BatchMode=yes",
"-o",
"StrictHostKeyChecking=accept-new",
"-o",
"UpdateHostKeys=yes",
"-o",
"ConnectTimeout=5",
"-o",
"ServerAliveInterval=15",
"-o",
"ServerAliveCountMax=3",
];
if (opts.identity?.trim()) {
args.push("-i", opts.identity.trim());
}
args.push(userHost);
const stderr: string[] = [];
const child = spawn("/usr/bin/ssh", args, {
stdio: ["ignore", "ignore", "pipe"],
});
child.stderr?.setEncoding("utf8");
child.stderr?.on("data", (chunk) => {
const lines = String(chunk)
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
stderr.push(...lines);
});
const stop = async () => {
if (child.killed) return;
child.kill("SIGTERM");
await new Promise<void>((resolve) => {
const t = setTimeout(() => {
try {
child.kill("SIGKILL");
} finally {
resolve();
}
}, 1500);
child.once("exit", () => {
clearTimeout(t);
resolve();
});
});
};
try {
await Promise.race([
waitForLocalListener(localPort, Math.max(250, opts.timeoutMs)),
new Promise<void>((_, reject) => {
child.once("exit", (code, signal) => {
reject(new Error(`ssh exited (${code ?? "null"}${signal ? `/${signal}` : ""})`));
});
}),
]);
} catch (err) {
await stop();
const suffix = stderr.length > 0 ? `\n${stderr.join("\n")}` : "";
throw new Error(`${err instanceof Error ? err.message : String(err)}${suffix}`);
}
return {
parsedTarget: parsed,
localPort,
remotePort: opts.remotePort,
pid: typeof child.pid === "number" ? child.pid : null,
stderr,
stop,
};
}