194 lines
5.1 KiB
TypeScript
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,
|
|
};
|
|
}
|