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

@@ -11,6 +11,7 @@ const resolveGatewayPort = vi.fn(() => 18789);
const discoverGatewayBeacons = vi.fn(async () => []);
const pickPrimaryTailnetIPv4 = vi.fn(() => "100.64.0.10");
const sshStop = vi.fn(async () => {});
const resolveSshConfig = vi.fn(async () => null);
const startSshPortForward = vi.fn(async () => ({
parsedTarget: { user: "me", host: "studio", port: 22 },
localPort: 18789,
@@ -92,8 +93,16 @@ vi.mock("../infra/tailnet.js", () => ({
pickPrimaryTailnetIPv4: () => pickPrimaryTailnetIPv4(),
}));
vi.mock("../infra/ssh-tunnel.js", () => ({
startSshPortForward: (opts: unknown) => startSshPortForward(opts),
vi.mock("../infra/ssh-tunnel.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../infra/ssh-tunnel.js")>();
return {
...actual,
startSshPortForward: (opts: unknown) => startSshPortForward(opts),
};
});
vi.mock("../infra/ssh-config.js", () => ({
resolveSshConfig: (opts: unknown) => resolveSshConfig(opts),
}));
vi.mock("../gateway/probe.js", () => ({
@@ -179,4 +188,122 @@ describe("gateway-status command", () => {
const targets = parsed.targets as Array<Record<string, unknown>>;
expect(targets.some((t) => t.kind === "sshTunnel")).toBe(true);
});
it("infers SSH target from gateway.remote.url and ssh config", async () => {
const runtimeLogs: string[] = [];
const runtime = {
log: (msg: string) => runtimeLogs.push(msg),
error: (_msg: string) => {},
exit: (code: number) => {
throw new Error(`__exit__:${code}`);
},
};
const originalUser = process.env.USER;
try {
process.env.USER = "steipete";
loadConfig.mockReturnValueOnce({
gateway: {
mode: "remote",
remote: { url: "ws://peters-mac-studio-1.sheep-coho.ts.net:18789", token: "rtok" },
},
});
resolveSshConfig.mockResolvedValueOnce({
user: "steipete",
host: "peters-mac-studio-1.sheep-coho.ts.net",
port: 2222,
identityFiles: ["/tmp/id_ed25519"],
});
startSshPortForward.mockClear();
const { gatewayStatusCommand } = await import("./gateway-status.js");
await gatewayStatusCommand(
{ timeout: "1000", json: true },
runtime as unknown as import("../runtime.js").RuntimeEnv,
);
expect(startSshPortForward).toHaveBeenCalledTimes(1);
const call = startSshPortForward.mock.calls[0]?.[0] as {
target: string;
identity?: string;
};
expect(call.target).toBe("steipete@peters-mac-studio-1.sheep-coho.ts.net:2222");
expect(call.identity).toBe("/tmp/id_ed25519");
} finally {
process.env.USER = originalUser;
}
});
it("falls back to host-only when USER is missing and ssh config is unavailable", async () => {
const runtimeLogs: string[] = [];
const runtime = {
log: (msg: string) => runtimeLogs.push(msg),
error: (_msg: string) => {},
exit: (code: number) => {
throw new Error(`__exit__:${code}`);
},
};
const originalUser = process.env.USER;
try {
process.env.USER = "";
loadConfig.mockReturnValueOnce({
gateway: {
mode: "remote",
remote: { url: "ws://studio.example:18789", token: "rtok" },
},
});
resolveSshConfig.mockResolvedValueOnce(null);
startSshPortForward.mockClear();
const { gatewayStatusCommand } = await import("./gateway-status.js");
await gatewayStatusCommand(
{ timeout: "1000", json: true },
runtime as unknown as import("../runtime.js").RuntimeEnv,
);
const call = startSshPortForward.mock.calls[0]?.[0] as {
target: string;
};
expect(call.target).toBe("studio.example");
} finally {
process.env.USER = originalUser;
}
});
it("keeps explicit SSH identity even when ssh config provides one", async () => {
const runtimeLogs: string[] = [];
const runtime = {
log: (msg: string) => runtimeLogs.push(msg),
error: (_msg: string) => {},
exit: (code: number) => {
throw new Error(`__exit__:${code}`);
},
};
loadConfig.mockReturnValueOnce({
gateway: {
mode: "remote",
remote: { url: "ws://studio.example:18789", token: "rtok" },
},
});
resolveSshConfig.mockResolvedValueOnce({
user: "me",
host: "studio.example",
port: 22,
identityFiles: ["/tmp/id_from_config"],
});
startSshPortForward.mockClear();
const { gatewayStatusCommand } = await import("./gateway-status.js");
await gatewayStatusCommand(
{ timeout: "1000", json: true, sshIdentity: "/tmp/explicit_id" },
runtime as unknown as import("../runtime.js").RuntimeEnv,
);
const call = startSshPortForward.mock.calls[0]?.[0] as {
identity?: string;
};
expect(call.identity).toBe("/tmp/explicit_id");
});
});

View File

@@ -2,7 +2,8 @@ import { withProgress } from "../cli/progress.js";
import { loadConfig, resolveGatewayPort } from "../config/config.js";
import { probeGateway } from "../gateway/probe.js";
import { discoverGatewayBeacons } from "../infra/bonjour-discovery.js";
import { startSshPortForward } from "../infra/ssh-tunnel.js";
import { resolveSshConfig } from "../infra/ssh-config.js";
import { parseSshTarget, startSshPortForward } from "../infra/ssh-tunnel.js";
import type { RuntimeEnv } from "../runtime.js";
import { colorize, isRich, theme } from "../terminal/theme.js";
@@ -47,13 +48,25 @@ export async function gatewayStatusCommand(
});
let sshTarget = sanitizeSshTarget(opts.ssh) ?? sanitizeSshTarget(cfg.gateway?.remote?.sshTarget);
const sshIdentity =
let sshIdentity =
sanitizeSshTarget(opts.sshIdentity) ?? sanitizeSshTarget(cfg.gateway?.remote?.sshIdentity);
const remotePort = resolveGatewayPort(cfg);
let sshTunnelError: string | null = null;
let sshTunnelStarted = false;
if (!sshTarget) {
sshTarget = inferSshTargetFromRemoteUrl(cfg.gateway?.remote?.url);
}
if (sshTarget) {
const resolved = await resolveSshTarget(sshTarget, sshIdentity, overallTimeoutMs);
if (resolved) {
sshTarget = resolved.target;
if (!sshIdentity && resolved.identity) sshIdentity = resolved.identity;
}
}
const { discovery, probed } = await withProgress(
{
label: "Inspecting gateways…",
@@ -314,3 +327,53 @@ export async function gatewayStatusCommand(
if (!ok) runtime.exit(1);
}
function inferSshTargetFromRemoteUrl(rawUrl?: string | null): string | null {
if (typeof rawUrl !== "string") return null;
const trimmed = rawUrl.trim();
if (!trimmed) return null;
let host: string | null = null;
try {
host = new URL(trimmed).hostname || null;
} catch {
return null;
}
if (!host) return null;
const user = process.env.USER?.trim() || "";
return user ? `${user}@${host}` : host;
}
function buildSshTarget(input: { user?: string; host?: string; port?: number }): string | null {
const host = input.host?.trim() ?? "";
if (!host) return null;
const user = input.user?.trim() ?? "";
const base = user ? `${user}@${host}` : host;
const port = input.port ?? 22;
if (port && port !== 22) return `${base}:${port}`;
return base;
}
async function resolveSshTarget(
rawTarget: string,
identity: string | null,
overallTimeoutMs: number,
): Promise<{ target: string; identity?: string } | null> {
const parsed = parseSshTarget(rawTarget);
if (!parsed) return null;
const config = await resolveSshConfig(parsed, {
identity: identity ?? undefined,
timeoutMs: Math.min(800, overallTimeoutMs),
});
if (!config) return { target: rawTarget, identity: identity ?? undefined };
const target = buildSshTarget({
user: config.user ?? parsed.user,
host: config.host ?? parsed.host,
port: config.port ?? parsed.port,
});
if (!target) return { target: rawTarget, identity: identity ?? undefined };
const identityFile =
identity ??
config.identityFiles.find((entry) => entry.trim().length > 0)?.trim() ??
undefined;
return { target, identity: identityFile };
}