fix: sync remote ssh targets

This commit is contained in:
Peter Steinberger
2026-01-16 07:32:58 +00:00
parent e96b939732
commit 1ec1f6dcbf
6 changed files with 420 additions and 12 deletions

View File

@@ -0,0 +1,81 @@
import { spawn } from "node:child_process";
import { EventEmitter } from "node:events";
import { describe, expect, it, vi } from "vitest";
vi.mock("node:child_process", () => {
const spawn = vi.fn(() => {
const child = new EventEmitter() as EventEmitter & {
stdout?: EventEmitter & { setEncoding?: (enc: string) => void };
kill?: (signal?: string) => void;
};
const stdout = new EventEmitter() as EventEmitter & {
setEncoding?: (enc: string) => void;
};
stdout.setEncoding = vi.fn();
child.stdout = stdout;
child.kill = vi.fn();
process.nextTick(() => {
stdout.emit(
"data",
[
"user steipete",
"hostname peters-mac-studio-1.sheep-coho.ts.net",
"port 2222",
"identityfile none",
"identityfile /tmp/id_ed25519",
"",
].join("\n"),
);
child.emit("exit", 0);
});
return child;
});
return { spawn };
});
const spawnMock = vi.mocked(spawn);
describe("ssh-config", () => {
it("parses ssh -G output", async () => {
const { parseSshConfigOutput } = await import("./ssh-config.js");
const parsed = parseSshConfigOutput(
"user bob\nhostname example.com\nport 2222\nidentityfile none\nidentityfile /tmp/id\n",
);
expect(parsed.user).toBe("bob");
expect(parsed.host).toBe("example.com");
expect(parsed.port).toBe(2222);
expect(parsed.identityFiles).toEqual(["/tmp/id"]);
});
it("resolves ssh config via ssh -G", async () => {
const { resolveSshConfig } = await import("./ssh-config.js");
const config = await resolveSshConfig({ user: "me", host: "alias", port: 22 });
expect(config?.user).toBe("steipete");
expect(config?.host).toBe("peters-mac-studio-1.sheep-coho.ts.net");
expect(config?.port).toBe(2222);
expect(config?.identityFiles).toEqual(["/tmp/id_ed25519"]);
});
it("returns null when ssh -G fails", async () => {
spawnMock.mockImplementationOnce(() => {
const child = new EventEmitter() as EventEmitter & {
stdout?: EventEmitter & { setEncoding?: (enc: string) => void };
kill?: (signal?: string) => void;
};
const stdout = new EventEmitter() as EventEmitter & {
setEncoding?: (enc: string) => void;
};
stdout.setEncoding = vi.fn();
child.stdout = stdout;
child.kill = vi.fn();
process.nextTick(() => {
child.emit("exit", 1);
});
return child;
});
const { resolveSshConfig } = await import("./ssh-config.js");
const config = await resolveSshConfig({ user: "me", host: "bad-host", port: 22 });
expect(config).toBeNull();
});
});

95
src/infra/ssh-config.ts Normal file
View File

@@ -0,0 +1,95 @@
import { spawn } from "node:child_process";
import type { SshParsedTarget } from "./ssh-tunnel.js";
export type SshResolvedConfig = {
user?: string;
host?: string;
port?: number;
identityFiles: string[];
};
function parsePort(value: string | undefined): number | undefined {
if (!value) return undefined;
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed <= 0) return undefined;
return parsed;
}
export function parseSshConfigOutput(output: string): SshResolvedConfig {
const result: SshResolvedConfig = { identityFiles: [] };
const lines = output.split("\n");
for (const raw of lines) {
const line = raw.trim();
if (!line) continue;
const [key, ...rest] = line.split(/\s+/);
const value = rest.join(" ").trim();
if (!key || !value) continue;
switch (key) {
case "user":
result.user = value;
break;
case "hostname":
result.host = value;
break;
case "port":
result.port = parsePort(value);
break;
case "identityfile":
if (value !== "none") result.identityFiles.push(value);
break;
default:
break;
}
}
return result;
}
export async function resolveSshConfig(
target: SshParsedTarget,
opts: { identity?: string; timeoutMs?: number } = {},
): Promise<SshResolvedConfig | null> {
const sshPath = "/usr/bin/ssh";
const args = ["-G"];
if (target.port > 0 && target.port !== 22) {
args.push("-p", String(target.port));
}
if (opts.identity?.trim()) {
args.push("-i", opts.identity.trim());
}
const userHost = target.user ? `${target.user}@${target.host}` : target.host;
args.push(userHost);
return await new Promise<SshResolvedConfig | null>((resolve) => {
const child = spawn(sshPath, args, {
stdio: ["ignore", "pipe", "ignore"],
});
let stdout = "";
child.stdout?.setEncoding("utf8");
child.stdout?.on("data", (chunk) => {
stdout += String(chunk);
});
const timeoutMs = Math.max(200, opts.timeoutMs ?? 800);
const timer = setTimeout(() => {
try {
child.kill("SIGKILL");
} finally {
resolve(null);
}
}, timeoutMs);
child.once("error", () => {
clearTimeout(timer);
resolve(null);
});
child.once("exit", (code) => {
clearTimeout(timer);
if (code !== 0 || !stdout.trim()) {
resolve(null);
return;
}
resolve(parseSshConfigOutput(stdout));
});
});
}