feat(gateway): discover on local + wide-area DNS-SD
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
134
src/infra/bonjour-discovery.test.ts
Normal file
134
src/infra/bonjour-discovery.test.ts
Normal 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.");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 [];
|
||||||
|
|||||||
Reference in New Issue
Block a user