feat: add gateway dev config options
This commit is contained in:
202
src/infra/ssh-tunnel.ts
Normal file
202
src/infra/ssh-tunnel.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user