fix: sync remote ssh targets
This commit is contained in:
81
src/infra/ssh-config.test.ts
Normal file
81
src/infra/ssh-config.test.ts
Normal 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
95
src/infra/ssh-config.ts
Normal 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));
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user