feat(gateway): discover on local + wide-area DNS-SD

This commit is contained in:
Peter Steinberger
2026-01-09 07:31:00 +01:00
parent 2062165cd3
commit 266643bb94
3 changed files with 243 additions and 16 deletions

View File

@@ -12,6 +12,7 @@ const forceFreePortAndWait = vi.fn(async () => ({
escalatedToSigkill: false, escalatedToSigkill: false,
})); }));
const serviceIsLoaded = vi.fn().mockResolvedValue(true); const serviceIsLoaded = vi.fn().mockResolvedValue(true);
const discoverGatewayBeacons = vi.fn(async () => []);
const runtimeLogs: string[] = []; const runtimeLogs: string[] = [];
const runtimeErrors: string[] = []; const runtimeErrors: string[] = [];
@@ -90,6 +91,10 @@ vi.mock("../daemon/program-args.js", () => ({
}), }),
})); }));
vi.mock("../infra/bonjour-discovery.js", () => ({
discoverGatewayBeacons: (opts: unknown) => discoverGatewayBeacons(opts),
}));
describe("gateway-cli coverage", () => { describe("gateway-cli coverage", () => {
it("registers call/health/status commands and routes to callGateway", async () => { it("registers call/health/status commands and routes to callGateway", async () => {
runtimeLogs.length = 0; runtimeLogs.length = 0;
@@ -110,6 +115,57 @@ describe("gateway-cli coverage", () => {
expect(runtimeLogs.join("\n")).toContain('"ok": true'); expect(runtimeLogs.join("\n")).toContain('"ok": true');
}); });
it("registers gateway discover and prints JSON", async () => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;
discoverGatewayBeacons.mockReset();
discoverGatewayBeacons.mockResolvedValueOnce([
{
instanceName: "Studio (Clawdbot)",
displayName: "Studio",
domain: "local.",
host: "studio.local",
lanHost: "studio.local",
tailnetDns: "studio.tailnet.ts.net",
gatewayPort: 18789,
bridgePort: 18790,
sshPort: 22,
},
]);
const { registerGatewayCli } = await import("./gateway-cli.js");
const program = new Command();
program.exitOverride();
registerGatewayCli(program);
await program.parseAsync(["gateway", "discover", "--json"], { from: "user" });
expect(discoverGatewayBeacons).toHaveBeenCalledTimes(1);
expect(runtimeLogs.join("\n")).toContain('"beacons"');
expect(runtimeLogs.join("\n")).toContain('"wsUrl"');
expect(runtimeLogs.join("\n")).toContain("ws://");
});
it("validates gateway discover timeout", async () => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;
discoverGatewayBeacons.mockReset();
const { registerGatewayCli } = await import("./gateway-cli.js");
const program = new Command();
program.exitOverride();
registerGatewayCli(program);
await expect(
program.parseAsync(["gateway", "discover", "--timeout", "0"], {
from: "user",
}),
).rejects.toThrow("__exit__:1");
expect(runtimeErrors.join("\n")).toContain("gateway discover failed:");
expect(discoverGatewayBeacons).not.toHaveBeenCalled();
});
it("fails gateway call on invalid params JSON", async () => { it("fails gateway call on invalid params JSON", async () => {
runtimeLogs.length = 0; runtimeLogs.length = 0;
runtimeErrors.length = 0; runtimeErrors.length = 0;

View File

@@ -0,0 +1,134 @@
import { describe, expect, it, vi } from "vitest";
import type { runCommandWithTimeout } from "../process/exec.js";
import { WIDE_AREA_DISCOVERY_DOMAIN } from "./widearea-dns.js";
import { discoverGatewayBeacons } from "./bonjour-discovery.js";
describe("bonjour-discovery", () => {
it("discovers beacons on darwin across local + wide-area domains", async () => {
const calls: Array<{ argv: string[]; timeoutMs: number }> = [];
const run = vi.fn(async (argv: string[], options: { timeoutMs: number }) => {
calls.push({ argv, timeoutMs: options.timeoutMs });
const domain = argv[3] ?? "";
if (argv[0] === "dns-sd" && argv[1] === "-B") {
if (domain === "local.") {
return {
stdout: [
"Add 2 3 local. _clawdbot-bridge._tcp. Studio Bridge",
"Add 2 3 local. _clawdbot-bridge._tcp. Laptop Bridge",
"",
].join("\n"),
stderr: "",
code: 0,
signal: null,
killed: false,
};
}
if (domain === WIDE_AREA_DISCOVERY_DOMAIN) {
return {
stdout: [
`Add 2 3 ${WIDE_AREA_DISCOVERY_DOMAIN} _clawdbot-bridge._tcp. Tailnet Bridge`,
"",
].join("\n"),
stderr: "",
code: 0,
signal: null,
killed: false,
};
}
}
if (argv[0] === "dns-sd" && argv[1] === "-L") {
const instance = argv[2] ?? "";
const host =
instance === "Studio Bridge"
? "studio.local"
: instance === "Laptop Bridge"
? "laptop.local"
: "tailnet.local";
const tailnetDns =
instance === "Tailnet Bridge" ? "studio.tailnet.ts.net" : "";
const txtParts = [
"txtvers=1",
`displayName=${instance.replace(" Bridge", "")}`,
`lanHost=${host}`,
"gatewayPort=18789",
"bridgePort=18790",
"sshPort=22",
tailnetDns ? `tailnetDns=${tailnetDns}` : null,
].filter((v): v is string => Boolean(v));
return {
stdout: [
`${instance}._clawdbot-bridge._tcp. can be reached at ${host}:18790`,
txtParts.join(" "),
"",
].join("\n"),
stderr: "",
code: 0,
signal: null,
killed: false,
};
}
throw new Error(`unexpected argv: ${argv.join(" ")}`);
});
const beacons = await discoverGatewayBeacons({
platform: "darwin",
timeoutMs: 1234,
run: run as unknown as typeof runCommandWithTimeout,
});
expect(beacons).toHaveLength(3);
expect(beacons.map((b) => b.domain)).toEqual(
expect.arrayContaining(["local.", WIDE_AREA_DISCOVERY_DOMAIN]),
);
const browseCalls = calls.filter(
(c) => c.argv[0] === "dns-sd" && c.argv[1] === "-B",
);
expect(browseCalls.map((c) => c.argv[3])).toEqual(
expect.arrayContaining(["local.", WIDE_AREA_DISCOVERY_DOMAIN]),
);
expect(browseCalls.every((c) => c.timeoutMs === 1234)).toBe(true);
});
it("normalizes domains and respects domains override", async () => {
const calls: string[][] = [];
const run = vi.fn(async (argv: string[]) => {
calls.push(argv);
return {
stdout: "",
stderr: "",
code: 0,
signal: null,
killed: false,
};
});
await discoverGatewayBeacons({
platform: "darwin",
timeoutMs: 1,
domains: ["local", "clawdbot.internal"],
run: run as unknown as typeof runCommandWithTimeout,
});
expect(calls.filter((c) => c[1] === "-B").map((c) => c[3])).toEqual(
expect.arrayContaining(["local.", "clawdbot.internal."]),
);
calls.length = 0;
await discoverGatewayBeacons({
platform: "darwin",
timeoutMs: 1,
domains: ["local."],
run: run as unknown as typeof runCommandWithTimeout,
});
expect(calls.filter((c) => c[1] === "-B")).toHaveLength(1);
expect(calls.filter((c) => c[1] === "-B")[0]?.[3]).toBe("local.");
});
});

View File

@@ -1,7 +1,9 @@
import { runCommandWithTimeout } from "../process/exec.js"; import { runCommandWithTimeout } from "../process/exec.js";
import { WIDE_AREA_DISCOVERY_DOMAIN } from "./widearea-dns.js";
export type GatewayBonjourBeacon = { export type GatewayBonjourBeacon = {
instanceName: string; instanceName: string;
domain?: string;
displayName?: string; displayName?: string;
host?: string; host?: string;
port?: number; port?: number;
@@ -16,10 +18,15 @@ export type GatewayBonjourBeacon = {
export type GatewayBonjourDiscoverOpts = { export type GatewayBonjourDiscoverOpts = {
timeoutMs?: number; timeoutMs?: number;
domains?: string[];
platform?: NodeJS.Platform;
run?: typeof runCommandWithTimeout;
}; };
const DEFAULT_TIMEOUT_MS = 2000; const DEFAULT_TIMEOUT_MS = 2000;
const DEFAULT_DOMAINS = ["local.", WIDE_AREA_DISCOVERY_DOMAIN] as const;
function parseIntOrNull(value: string | undefined): number | undefined { function parseIntOrNull(value: string | undefined): number | undefined {
if (!value) return undefined; if (!value) return undefined;
const parsed = Number.parseInt(value, 10); const parsed = Number.parseInt(value, 10);
@@ -94,21 +101,22 @@ function parseDnsSdResolve(
} }
async function discoverViaDnsSd( async function discoverViaDnsSd(
domain: string,
timeoutMs: number, timeoutMs: number,
run: typeof runCommandWithTimeout,
): Promise<GatewayBonjourBeacon[]> { ): Promise<GatewayBonjourBeacon[]> {
const browse = await runCommandWithTimeout( const browse = await run(["dns-sd", "-B", "_clawdbot-bridge._tcp", domain], {
["dns-sd", "-B", "_clawdbot-bridge._tcp", "local."], timeoutMs,
{ timeoutMs }, });
);
const instances = parseDnsSdBrowse(browse.stdout); const instances = parseDnsSdBrowse(browse.stdout);
const results: GatewayBonjourBeacon[] = []; const results: GatewayBonjourBeacon[] = [];
for (const instance of instances) { for (const instance of instances) {
const resolved = await runCommandWithTimeout( const resolved = await run(
["dns-sd", "-L", instance, "_clawdbot-bridge._tcp", "local."], ["dns-sd", "-L", instance, "_clawdbot-bridge._tcp", domain],
{ timeoutMs }, { timeoutMs },
); );
const parsed = parseDnsSdResolve(resolved.stdout, instance); const parsed = parseDnsSdResolve(resolved.stdout, instance);
if (parsed) results.push(parsed); if (parsed) results.push({ ...parsed, domain });
} }
return results; return results;
} }
@@ -168,25 +176,54 @@ function parseAvahiBrowse(stdout: string): GatewayBonjourBeacon[] {
} }
async function discoverViaAvahi( async function discoverViaAvahi(
domain: string,
timeoutMs: number, timeoutMs: number,
run: typeof runCommandWithTimeout,
): Promise<GatewayBonjourBeacon[]> { ): Promise<GatewayBonjourBeacon[]> {
const browse = await runCommandWithTimeout( const args = ["avahi-browse", "-rt", "_clawdbot-bridge._tcp"];
["avahi-browse", "-rt", "_clawdbot-bridge._tcp"], if (domain && domain !== "local.") {
{ timeoutMs }, // avahi-browse wants a plain domain (no trailing dot)
); args.push("-d", domain.replace(/\.$/, ""));
return parseAvahiBrowse(browse.stdout); }
const browse = await run(args, { timeoutMs });
return parseAvahiBrowse(browse.stdout).map((beacon) => ({
...beacon,
domain,
}));
} }
export async function discoverGatewayBeacons( export async function discoverGatewayBeacons(
opts: GatewayBonjourDiscoverOpts = {}, opts: GatewayBonjourDiscoverOpts = {},
): Promise<GatewayBonjourBeacon[]> { ): Promise<GatewayBonjourBeacon[]> {
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const platform = opts.platform ?? process.platform;
const run = opts.run ?? runCommandWithTimeout;
const domainsRaw = Array.isArray(opts.domains) ? opts.domains : [];
const domains = (domainsRaw.length > 0 ? domainsRaw : [...DEFAULT_DOMAINS])
.map((d) => String(d).trim())
.filter(Boolean)
.map((d) => (d.endsWith(".") ? d : `${d}.`));
try { try {
if (process.platform === "darwin") { if (platform === "darwin") {
return await discoverViaDnsSd(timeoutMs); const perDomain = await Promise.allSettled(
domains.map(
async (domain) => await discoverViaDnsSd(domain, timeoutMs, run),
),
);
return perDomain.flatMap((r) =>
r.status === "fulfilled" ? r.value : [],
);
} }
if (process.platform === "linux") { if (platform === "linux") {
return await discoverViaAvahi(timeoutMs); const perDomain = await Promise.allSettled(
domains.map(
async (domain) => await discoverViaAvahi(domain, timeoutMs, run),
),
);
return perDomain.flatMap((r) =>
r.status === "fulfilled" ? r.value : [],
);
} }
} catch { } catch {
return []; return [];