fix: confirm SSH forward ownership

This commit is contained in:
2026-08-04 00:14:40 +02:00
parent 9986d9be28
commit 1943435225
2 changed files with 63 additions and 21 deletions
+21 -19
View File
@@ -160,9 +160,9 @@ export interface ConcreteDiagnosticAdapterDependencies {
directProtocol?: DirectProtocolFactory;
sshProcess?: SshProcessFactory;
databaseClient?: DatabaseDiagnosticClientFactory;
sshSpawn?: (args: readonly string[]) => { kill(signal?: NodeJS.Signals): boolean; once?(event: "error" | "exit", listener: (...args: any[]) => void): unknown };
sshSpawn?: (args: readonly string[]) => { kill(signal?: NodeJS.Signals): boolean; once?(event: "error" | "exit", listener: (...args: any[]) => void): unknown; stderr?: { on(event: "data", listener: (data: Buffer | string) => void): unknown; off?(event: "data", listener: (data: Buffer | string) => void): unknown } };
reserveLoopbackPort?: () => Promise<number>;
waitForSshReady?: (tunnel: LoopbackTunnel, signal: AbortSignal) => Promise<void>;
sshForwardConfirmed?: (tunnel: LoopbackTunnel, signal: AbortSignal) => Promise<void>;
}
/**
@@ -232,18 +232,6 @@ async function reserveLoopbackPort(): Promise<number> {
}
}
async function waitForLoopbackTunnel(tunnel: LoopbackTunnel, signal: AbortSignal): Promise<void> {
while (!signal.aborted) {
try {
await connectTcp(tunnel.host, tunnel.port, signal);
return;
} catch {
await new Promise<void>((resolve) => setTimeout(resolve, 25));
}
}
throw new Error("SSH tunnel readiness failed");
}
/**
* Concrete production adapters deliberately retain only probe metadata. Protocol failures and
* response bodies are discarded at this boundary; callers receive fixed diagnostics instead.
@@ -251,9 +239,8 @@ async function waitForLoopbackTunnel(tunnel: LoopbackTunnel, signal: AbortSignal
export function createConcreteDiagnosticAdapters(
dependencies: ConcreteDiagnosticAdapterDependencies = {},
): DiagnosticAdapters {
const spawnSsh = dependencies.sshSpawn ?? ((args: readonly string[]) => spawn("ssh", [...args], { stdio: "ignore" }));
const spawnSsh = dependencies.sshSpawn ?? ((args: readonly string[]) => spawn("ssh", [...args], { stdio: ["ignore", "ignore", "pipe"] }));
const reserveSshPort = dependencies.reserveLoopbackPort ?? reserveLoopbackPort;
const waitForSshReady = dependencies.waitForSshReady ?? waitForLoopbackTunnel;
const sshProcess = dependencies.sshProcess ?? {
async start(request: SshTunnelRequest, args: readonly string[]) {
const port = await reserveSshPort();
@@ -266,7 +253,19 @@ export function createConcreteDiagnosticAdapters(
try {
await withTimeout(request.timeoutMs, async (signal) => {
await Promise.race([
waitForSshReady(tunnel, signal),
dependencies.sshForwardConfirmed
? dependencies.sshForwardConfirmed(tunnel, signal)
: new Promise<void>((resolve, reject) => {
const confirm = (data: Buffer | string) => {
if (new RegExp(`Local forwarding listening on 127\\.0\\.0\\.1 port ${port}\\.?`).test(data.toString())) {
child.stderr?.off?.("data", confirm);
resolve();
}
};
if (!child.stderr) return reject(new Error("SSH tunnel readiness failed"));
child.stderr.on("data", confirm);
signal.addEventListener("abort", () => reject(new Error("SSH tunnel readiness failed")), { once: true });
}),
new Promise<never>((_resolve, reject) => {
child.once?.("error", () => reject(new Error("SSH tunnel readiness failed")));
child.once?.("exit", () => reject(new Error("SSH tunnel readiness failed")));
@@ -282,8 +281,11 @@ export function createConcreteDiagnosticAdapters(
tunnel,
async close() {
request.signal.removeEventListener("abort", abort);
const exited = child.once
? new Promise<void>((resolve) => child.once?.("exit", resolve))
: Promise.resolve();
child.kill("SIGTERM");
if (child.once) await withTimeout(request.timeoutMs, () => new Promise<void>((resolve) => child.once?.("exit", resolve))).catch(() => undefined);
await withTimeout(request.timeoutMs, () => exited).catch(() => undefined);
},
};
},
@@ -366,7 +368,7 @@ export function createConcreteDiagnosticAdapters(
throw new Error("SSH probe failed");
}
const args = [
"-N", "-o", "BatchMode=yes", "-o", "ExitOnForwardFailure=yes", "-o", "StrictHostKeyChecking=yes",
"-N", "-v", "-o", "BatchMode=yes", "-o", "ExitOnForwardFailure=yes", "-o", "StrictHostKeyChecking=yes",
"-o", `UserKnownHostsFile=${request.knownHostsFile}`, "-i", request.privateKeyFile,
"-p", String(request.sshPort), "-L", `127.0.0.1:0:${request.targetHost}:${request.targetPort}`,
`${request.sshUser}@${request.sshHost}`,