From 266643bb9484fed86c408b173aadf31ff8306c3f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 07:31:00 +0100 Subject: [PATCH] feat(gateway): discover on local + wide-area DNS-SD --- src/cli/gateway-cli.coverage.test.ts | 56 +++++++++++ src/infra/bonjour-discovery.test.ts | 134 +++++++++++++++++++++++++++ src/infra/bonjour-discovery.ts | 69 ++++++++++---- 3 files changed, 243 insertions(+), 16 deletions(-) create mode 100644 src/infra/bonjour-discovery.test.ts diff --git a/src/cli/gateway-cli.coverage.test.ts b/src/cli/gateway-cli.coverage.test.ts index c7b28d6a1..98d52db62 100644 --- a/src/cli/gateway-cli.coverage.test.ts +++ b/src/cli/gateway-cli.coverage.test.ts @@ -12,6 +12,7 @@ const forceFreePortAndWait = vi.fn(async () => ({ escalatedToSigkill: false, })); const serviceIsLoaded = vi.fn().mockResolvedValue(true); +const discoverGatewayBeacons = vi.fn(async () => []); const runtimeLogs: 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", () => { it("registers call/health/status commands and routes to callGateway", async () => { runtimeLogs.length = 0; @@ -110,6 +115,57 @@ describe("gateway-cli coverage", () => { 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 () => { runtimeLogs.length = 0; runtimeErrors.length = 0; diff --git a/src/infra/bonjour-discovery.test.ts b/src/infra/bonjour-discovery.test.ts new file mode 100644 index 000000000..353a0dab4 --- /dev/null +++ b/src/infra/bonjour-discovery.test.ts @@ -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."); + }); +}); diff --git a/src/infra/bonjour-discovery.ts b/src/infra/bonjour-discovery.ts index a6ae02f41..1dfba3492 100644 --- a/src/infra/bonjour-discovery.ts +++ b/src/infra/bonjour-discovery.ts @@ -1,7 +1,9 @@ import { runCommandWithTimeout } from "../process/exec.js"; +import { WIDE_AREA_DISCOVERY_DOMAIN } from "./widearea-dns.js"; export type GatewayBonjourBeacon = { instanceName: string; + domain?: string; displayName?: string; host?: string; port?: number; @@ -16,10 +18,15 @@ export type GatewayBonjourBeacon = { export type GatewayBonjourDiscoverOpts = { timeoutMs?: number; + domains?: string[]; + platform?: NodeJS.Platform; + run?: typeof runCommandWithTimeout; }; const DEFAULT_TIMEOUT_MS = 2000; +const DEFAULT_DOMAINS = ["local.", WIDE_AREA_DISCOVERY_DOMAIN] as const; + function parseIntOrNull(value: string | undefined): number | undefined { if (!value) return undefined; const parsed = Number.parseInt(value, 10); @@ -94,21 +101,22 @@ function parseDnsSdResolve( } async function discoverViaDnsSd( + domain: string, timeoutMs: number, + run: typeof runCommandWithTimeout, ): Promise { - const browse = await runCommandWithTimeout( - ["dns-sd", "-B", "_clawdbot-bridge._tcp", "local."], - { timeoutMs }, - ); + const browse = await run(["dns-sd", "-B", "_clawdbot-bridge._tcp", domain], { + timeoutMs, + }); const instances = parseDnsSdBrowse(browse.stdout); const results: GatewayBonjourBeacon[] = []; for (const instance of instances) { - const resolved = await runCommandWithTimeout( - ["dns-sd", "-L", instance, "_clawdbot-bridge._tcp", "local."], + const resolved = await run( + ["dns-sd", "-L", instance, "_clawdbot-bridge._tcp", domain], { timeoutMs }, ); const parsed = parseDnsSdResolve(resolved.stdout, instance); - if (parsed) results.push(parsed); + if (parsed) results.push({ ...parsed, domain }); } return results; } @@ -168,25 +176,54 @@ function parseAvahiBrowse(stdout: string): GatewayBonjourBeacon[] { } async function discoverViaAvahi( + domain: string, timeoutMs: number, + run: typeof runCommandWithTimeout, ): Promise { - const browse = await runCommandWithTimeout( - ["avahi-browse", "-rt", "_clawdbot-bridge._tcp"], - { timeoutMs }, - ); - return parseAvahiBrowse(browse.stdout); + const args = ["avahi-browse", "-rt", "_clawdbot-bridge._tcp"]; + if (domain && domain !== "local.") { + // avahi-browse wants a plain domain (no trailing dot) + args.push("-d", domain.replace(/\.$/, "")); + } + const browse = await run(args, { timeoutMs }); + return parseAvahiBrowse(browse.stdout).map((beacon) => ({ + ...beacon, + domain, + })); } export async function discoverGatewayBeacons( opts: GatewayBonjourDiscoverOpts = {}, ): Promise { 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 { - if (process.platform === "darwin") { - return await discoverViaDnsSd(timeoutMs); + if (platform === "darwin") { + 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") { - return await discoverViaAvahi(timeoutMs); + if (platform === "linux") { + const perDomain = await Promise.allSettled( + domains.map( + async (domain) => await discoverViaAvahi(domain, timeoutMs, run), + ), + ); + return perDomain.flatMap((r) => + r.status === "fulfilled" ? r.value : [], + ); } } catch { return [];