fix: sync remote ssh targets
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user