feat(gateway): improve wide-area discovery
This commit is contained in:
@@ -90,6 +90,7 @@
|
||||
- Gateway/CLI: add `clawdbot gateway discover` (Bonjour scan on `local.` + `clawdbot.internal.`) with `--timeout` and `--json`. — thanks @steipete
|
||||
- Gateway/CLI: make `clawdbot gateway status` human-readable by default, add `--json`, and probe localhost + configured remote (warn on multiple gateways). — thanks @steipete
|
||||
- Gateway/CLI: support remote loopback gateways via SSH tunnel in `clawdbot gateway status` (`--ssh` / `--ssh-auto`). — thanks @steipete
|
||||
- Gateway/Discovery: include `gatewayPort`/`sshPort`/`cliPath` in wide-area Bonjour records, and add a tailnet DNS fallback for `gateway discover` when split DNS isn’t configured. — thanks @steipete
|
||||
- CLI: add global `--no-color` (and respect `NO_COLOR=1`) to disable ANSI output. — thanks @steipete
|
||||
- CLI: centralize lobster palette + apply it to onboarding/config prompts. — thanks @steipete
|
||||
- Gateway/CLI: add `clawdbot gateway --dev/--reset` to auto-create a dev config/workspace with a robot identity (no BOOTSTRAP.md). — thanks @steipete
|
||||
|
||||
@@ -121,6 +121,12 @@ clawdbot gateway call logs.tail --params '{"sinceMs": 60000}'
|
||||
|
||||
Only gateways with the **bridge enabled** will advertise the discovery beacon.
|
||||
|
||||
Wide-Area discovery records include (TXT):
|
||||
- `gatewayPort` (WebSocket port, usually `18789`)
|
||||
- `sshPort` (SSH port; defaults to `22` if not present)
|
||||
- `tailnetDns` (MagicDNS hostname, when available)
|
||||
- `cliPath` (optional hint for remote installs)
|
||||
|
||||
### `gateway discover`
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1135,10 +1135,13 @@ export async function startGatewayServer(
|
||||
const tailnetIPv6 = pickPrimaryTailnetIPv6();
|
||||
const result = await writeWideAreaBridgeZone({
|
||||
bridgePort: bridge.port,
|
||||
gatewayPort: port,
|
||||
displayName: formatBonjourInstanceName(machineDisplayName),
|
||||
tailnetIPv4,
|
||||
tailnetIPv6: tailnetIPv6 ?? undefined,
|
||||
tailnetDns,
|
||||
sshPort,
|
||||
cliPath: resolveBonjourCliPath(),
|
||||
});
|
||||
logDiscovery.info(
|
||||
`wide-area DNS-SD ${result.changed ? "updated" : "unchanged"} (${WIDE_AREA_DISCOVERY_DOMAIN} → ${result.zonePath})`,
|
||||
|
||||
@@ -98,6 +98,132 @@ describe("bonjour-discovery", () => {
|
||||
expect(browseCalls.every((c) => c.timeoutMs === 1234)).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to tailnet DNS probing for wide-area when split DNS is not configured", 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 cmd = argv[0];
|
||||
|
||||
if (cmd === "dns-sd" && argv[1] === "-B") {
|
||||
return {
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
code: 0,
|
||||
signal: null,
|
||||
killed: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
cmd === "tailscale" &&
|
||||
argv[1] === "status" &&
|
||||
argv[2] === "--json"
|
||||
) {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
Self: { TailscaleIPs: ["100.69.232.64"] },
|
||||
Peer: {
|
||||
"peer-1": { TailscaleIPs: ["100.123.224.76"] },
|
||||
},
|
||||
}),
|
||||
stderr: "",
|
||||
code: 0,
|
||||
signal: null,
|
||||
killed: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (cmd === "dig") {
|
||||
const at = argv.find((a) => a.startsWith("@")) ?? "";
|
||||
const server = at.replace(/^@/, "");
|
||||
const qname = argv[argv.length - 2] ?? "";
|
||||
const qtype = argv[argv.length - 1] ?? "";
|
||||
|
||||
if (
|
||||
server === "100.123.224.76" &&
|
||||
qtype === "PTR" &&
|
||||
qname === "_clawdbot-bridge._tcp.clawdbot.internal"
|
||||
) {
|
||||
return {
|
||||
stdout: `studio-bridge._clawdbot-bridge._tcp.clawdbot.internal.\n`,
|
||||
stderr: "",
|
||||
code: 0,
|
||||
signal: null,
|
||||
killed: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
server === "100.123.224.76" &&
|
||||
qtype === "SRV" &&
|
||||
qname === "studio-bridge._clawdbot-bridge._tcp.clawdbot.internal"
|
||||
) {
|
||||
return {
|
||||
stdout: `0 0 18790 studio.clawdbot.internal.\n`,
|
||||
stderr: "",
|
||||
code: 0,
|
||||
signal: null,
|
||||
killed: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
server === "100.123.224.76" &&
|
||||
qtype === "TXT" &&
|
||||
qname === "studio-bridge._clawdbot-bridge._tcp.clawdbot.internal"
|
||||
) {
|
||||
return {
|
||||
stdout: [
|
||||
`"displayName=Studio"`,
|
||||
`"transport=bridge"`,
|
||||
`"bridgePort=18790"`,
|
||||
`"gatewayPort=18789"`,
|
||||
`"sshPort=22"`,
|
||||
`"tailnetDns=peters-mac-studio-1.sheep-coho.ts.net"`,
|
||||
`"cliPath=/opt/homebrew/bin/clawdbot"`,
|
||||
"",
|
||||
].join(" "),
|
||||
stderr: "",
|
||||
code: 0,
|
||||
signal: null,
|
||||
killed: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`unexpected argv: ${argv.join(" ")}`);
|
||||
},
|
||||
);
|
||||
|
||||
const beacons = await discoverGatewayBeacons({
|
||||
platform: "darwin",
|
||||
timeoutMs: 1200,
|
||||
domains: [WIDE_AREA_DISCOVERY_DOMAIN],
|
||||
run: run as unknown as typeof runCommandWithTimeout,
|
||||
});
|
||||
|
||||
expect(beacons).toEqual([
|
||||
expect.objectContaining({
|
||||
domain: WIDE_AREA_DISCOVERY_DOMAIN,
|
||||
instanceName: "studio-bridge",
|
||||
displayName: "Studio",
|
||||
host: "studio.clawdbot.internal",
|
||||
port: 18790,
|
||||
tailnetDns: "peters-mac-studio-1.sheep-coho.ts.net",
|
||||
gatewayPort: 18789,
|
||||
sshPort: 22,
|
||||
cliPath: "/opt/homebrew/bin/clawdbot",
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(
|
||||
calls.some((c) => c.argv[0] === "tailscale" && c.argv[1] === "status"),
|
||||
).toBe(true);
|
||||
expect(calls.some((c) => c.argv[0] === "dig")).toBe(true);
|
||||
});
|
||||
|
||||
it("normalizes domains and respects domains override", async () => {
|
||||
const calls: string[][] = [];
|
||||
const run = vi.fn(async (argv: string[]) => {
|
||||
|
||||
@@ -27,6 +27,86 @@ const DEFAULT_TIMEOUT_MS = 2000;
|
||||
|
||||
const DEFAULT_DOMAINS = ["local.", WIDE_AREA_DISCOVERY_DOMAIN] as const;
|
||||
|
||||
function isTailnetIPv4(address: string): boolean {
|
||||
const parts = address.split(".");
|
||||
if (parts.length !== 4) return false;
|
||||
const octets = parts.map((p) => Number.parseInt(p, 10));
|
||||
if (octets.some((n) => !Number.isFinite(n) || n < 0 || n > 255)) return false;
|
||||
// Tailscale IPv4 range: 100.64.0.0/10
|
||||
const [a, b] = octets;
|
||||
return a === 100 && b >= 64 && b <= 127;
|
||||
}
|
||||
|
||||
function parseDigShortLines(stdout: string): string[] {
|
||||
return stdout
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function parseDigTxt(stdout: string): string[] {
|
||||
// dig +short TXT prints one or more lines of quoted strings:
|
||||
// "k=v" "k2=v2"
|
||||
const tokens: string[] = [];
|
||||
for (const raw of stdout.split("\n")) {
|
||||
const line = raw.trim();
|
||||
if (!line) continue;
|
||||
const matches = Array.from(line.matchAll(/"([^"]*)"/g), (m) => m[1] ?? "");
|
||||
for (const m of matches) {
|
||||
const unescaped = m
|
||||
.replaceAll("\\\\", "\\")
|
||||
.replaceAll('\\"', '"')
|
||||
.replaceAll("\\n", "\n");
|
||||
tokens.push(unescaped);
|
||||
}
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function parseDigSrv(stdout: string): { host: string; port: number } | null {
|
||||
// dig +short SRV: "0 0 18790 host.domain."
|
||||
const line = stdout
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.find(Boolean);
|
||||
if (!line) return null;
|
||||
const parts = line.split(/\s+/).filter(Boolean);
|
||||
if (parts.length < 4) return null;
|
||||
const port = Number.parseInt(parts[2] ?? "", 10);
|
||||
const hostRaw = parts[3] ?? "";
|
||||
if (!Number.isFinite(port) || port <= 0) return null;
|
||||
const host = hostRaw.replace(/\.$/, "");
|
||||
if (!host) return null;
|
||||
return { host, port };
|
||||
}
|
||||
|
||||
function parseTailscaleStatusIPv4s(stdout: string): string[] {
|
||||
const parsed = stdout ? (JSON.parse(stdout) as Record<string, unknown>) : {};
|
||||
const out: string[] = [];
|
||||
|
||||
const addIps = (value: unknown) => {
|
||||
if (!value || typeof value !== "object") return;
|
||||
const ips = (value as { TailscaleIPs?: unknown }).TailscaleIPs;
|
||||
if (!Array.isArray(ips)) return;
|
||||
for (const ip of ips) {
|
||||
if (typeof ip !== "string") continue;
|
||||
const trimmed = ip.trim();
|
||||
if (trimmed && isTailnetIPv4(trimmed)) out.push(trimmed);
|
||||
}
|
||||
};
|
||||
|
||||
addIps((parsed as { Self?: unknown }).Self);
|
||||
|
||||
const peerObj = (parsed as { Peer?: unknown }).Peer;
|
||||
if (peerObj && typeof peerObj === "object") {
|
||||
for (const peer of Object.values(peerObj as Record<string, unknown>)) {
|
||||
addIps(peer);
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(out)];
|
||||
}
|
||||
|
||||
function parseIntOrNull(value: string | undefined): number | undefined {
|
||||
if (!value) return undefined;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
@@ -121,6 +201,146 @@ async function discoverViaDnsSd(
|
||||
return results;
|
||||
}
|
||||
|
||||
async function discoverWideAreaViaTailnetDns(
|
||||
domain: string,
|
||||
timeoutMs: number,
|
||||
run: typeof runCommandWithTimeout,
|
||||
): Promise<GatewayBonjourBeacon[]> {
|
||||
if (domain !== WIDE_AREA_DISCOVERY_DOMAIN) return [];
|
||||
const startedAt = Date.now();
|
||||
const remainingMs = () => timeoutMs - (Date.now() - startedAt);
|
||||
|
||||
const tailscaleCandidates = [
|
||||
"tailscale",
|
||||
"/Applications/Tailscale.app/Contents/MacOS/Tailscale",
|
||||
];
|
||||
let ips: string[] = [];
|
||||
for (const candidate of tailscaleCandidates) {
|
||||
try {
|
||||
const res = await run([candidate, "status", "--json"], {
|
||||
timeoutMs: Math.max(1, Math.min(700, remainingMs())),
|
||||
});
|
||||
ips = parseTailscaleStatusIPv4s(res.stdout);
|
||||
if (ips.length > 0) break;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
if (ips.length === 0) return [];
|
||||
if (remainingMs() <= 0) return [];
|
||||
|
||||
// Keep scans bounded: this is a fallback and should not block long.
|
||||
ips = ips.slice(0, 40);
|
||||
|
||||
const probeName = `_clawdbot-bridge._tcp.${domain.replace(/\.$/, "")}`;
|
||||
|
||||
const concurrency = 6;
|
||||
let nextIndex = 0;
|
||||
let nameserver: string | null = null;
|
||||
let ptrs: string[] = [];
|
||||
|
||||
const worker = async () => {
|
||||
while (nameserver === null) {
|
||||
const budget = remainingMs();
|
||||
if (budget <= 0) return;
|
||||
const i = nextIndex;
|
||||
nextIndex += 1;
|
||||
if (i >= ips.length) return;
|
||||
const ip = ips[i] ?? "";
|
||||
if (!ip) continue;
|
||||
try {
|
||||
const probe = await run(
|
||||
["dig", "+short", "+time=1", "+tries=1", `@${ip}`, probeName, "PTR"],
|
||||
{ timeoutMs: Math.max(1, Math.min(250, budget)) },
|
||||
);
|
||||
const lines = parseDigShortLines(probe.stdout);
|
||||
if (lines.length === 0) continue;
|
||||
nameserver = ip;
|
||||
ptrs = lines;
|
||||
return;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(concurrency, ips.length) }, () => worker()),
|
||||
);
|
||||
|
||||
if (!nameserver || ptrs.length === 0) return [];
|
||||
if (remainingMs() <= 0) return [];
|
||||
|
||||
const results: GatewayBonjourBeacon[] = [];
|
||||
for (const ptr of ptrs) {
|
||||
const budget = remainingMs();
|
||||
if (budget <= 0) break;
|
||||
const ptrName = ptr.trim().replace(/\.$/, "");
|
||||
if (!ptrName) continue;
|
||||
const instanceName = ptrName.replace(/\.?_clawdbot-bridge\._tcp\..*$/, "");
|
||||
|
||||
const srv = await run(
|
||||
[
|
||||
"dig",
|
||||
"+short",
|
||||
"+time=1",
|
||||
"+tries=1",
|
||||
`@${nameserver}`,
|
||||
ptrName,
|
||||
"SRV",
|
||||
],
|
||||
{ timeoutMs: Math.max(1, Math.min(350, budget)) },
|
||||
).catch(() => null);
|
||||
const srvParsed = srv ? parseDigSrv(srv.stdout) : null;
|
||||
if (!srvParsed) continue;
|
||||
|
||||
const txtBudget = remainingMs();
|
||||
if (txtBudget <= 0) {
|
||||
results.push({
|
||||
instanceName: instanceName || ptrName,
|
||||
displayName: instanceName || ptrName,
|
||||
domain,
|
||||
host: srvParsed.host,
|
||||
port: srvParsed.port,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const txt = await run(
|
||||
[
|
||||
"dig",
|
||||
"+short",
|
||||
"+time=1",
|
||||
"+tries=1",
|
||||
`@${nameserver}`,
|
||||
ptrName,
|
||||
"TXT",
|
||||
],
|
||||
{ timeoutMs: Math.max(1, Math.min(350, txtBudget)) },
|
||||
).catch(() => null);
|
||||
const txtTokens = txt ? parseDigTxt(txt.stdout) : [];
|
||||
const txtMap = txtTokens.length > 0 ? parseTxtTokens(txtTokens) : {};
|
||||
|
||||
const beacon: GatewayBonjourBeacon = {
|
||||
instanceName: instanceName || ptrName,
|
||||
displayName: txtMap.displayName || instanceName || ptrName,
|
||||
domain,
|
||||
host: srvParsed.host,
|
||||
port: srvParsed.port,
|
||||
txt: Object.keys(txtMap).length ? txtMap : undefined,
|
||||
bridgePort: parseIntOrNull(txtMap.bridgePort),
|
||||
gatewayPort: parseIntOrNull(txtMap.gatewayPort),
|
||||
sshPort: parseIntOrNull(txtMap.sshPort),
|
||||
tailnetDns: txtMap.tailnetDns || undefined,
|
||||
cliPath: txtMap.cliPath || undefined,
|
||||
};
|
||||
|
||||
results.push(beacon);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function parseAvahiBrowse(stdout: string): GatewayBonjourBeacon[] {
|
||||
const results: GatewayBonjourBeacon[] = [];
|
||||
let current: GatewayBonjourBeacon | null = null;
|
||||
@@ -211,9 +431,25 @@ export async function discoverGatewayBeacons(
|
||||
async (domain) => await discoverViaDnsSd(domain, timeoutMs, run),
|
||||
),
|
||||
);
|
||||
return perDomain.flatMap((r) =>
|
||||
const discovered = perDomain.flatMap((r) =>
|
||||
r.status === "fulfilled" ? r.value : [],
|
||||
);
|
||||
|
||||
const wantsWideArea = domains.includes(WIDE_AREA_DISCOVERY_DOMAIN);
|
||||
const hasWideArea = discovered.some(
|
||||
(b) => b.domain === WIDE_AREA_DISCOVERY_DOMAIN,
|
||||
);
|
||||
|
||||
if (wantsWideArea && !hasWideArea) {
|
||||
const fallback = await discoverWideAreaViaTailnetDns(
|
||||
WIDE_AREA_DISCOVERY_DOMAIN,
|
||||
timeoutMs,
|
||||
run,
|
||||
).catch(() => []);
|
||||
return [...discovered, ...fallback];
|
||||
}
|
||||
|
||||
return discovered;
|
||||
}
|
||||
if (platform === "linux") {
|
||||
const perDomain = await Promise.allSettled(
|
||||
|
||||
@@ -10,11 +10,14 @@ describe("wide-area DNS-SD zone rendering", () => {
|
||||
const txt = renderWideAreaBridgeZoneText({
|
||||
serial: 2025121701,
|
||||
bridgePort: 18790,
|
||||
gatewayPort: 18789,
|
||||
displayName: "Mac Studio (Clawdbot)",
|
||||
tailnetIPv4: "100.123.224.76",
|
||||
tailnetIPv6: "fd7a:115c:a1e0::8801:e04c",
|
||||
hostLabel: "studio-london",
|
||||
instanceLabel: "studio-london",
|
||||
sshPort: 22,
|
||||
cliPath: "/opt/homebrew/bin/clawdbot",
|
||||
});
|
||||
|
||||
expect(txt).toContain(`$ORIGIN ${WIDE_AREA_DISCOVERY_DOMAIN}`);
|
||||
@@ -27,12 +30,16 @@ describe("wide-area DNS-SD zone rendering", () => {
|
||||
`studio-london._clawdbot-bridge._tcp IN SRV 0 0 18790 studio-london`,
|
||||
);
|
||||
expect(txt).toContain(`displayName=Mac Studio (Clawdbot)`);
|
||||
expect(txt).toContain(`gatewayPort=18789`);
|
||||
expect(txt).toContain(`sshPort=22`);
|
||||
expect(txt).toContain(`cliPath=/opt/homebrew/bin/clawdbot`);
|
||||
});
|
||||
|
||||
it("includes tailnetDns when provided", () => {
|
||||
const txt = renderWideAreaBridgeZoneText({
|
||||
serial: 2025121701,
|
||||
bridgePort: 18790,
|
||||
gatewayPort: 18789,
|
||||
displayName: "Mac Studio (Clawdbot)",
|
||||
tailnetIPv4: "100.123.224.76",
|
||||
tailnetDns: "peters-mac-studio-1.sheep-coho.ts.net",
|
||||
|
||||
@@ -70,12 +70,15 @@ function computeContentHash(body: string): string {
|
||||
|
||||
export type WideAreaBridgeZoneOpts = {
|
||||
bridgePort: number;
|
||||
gatewayPort?: number;
|
||||
displayName: string;
|
||||
tailnetIPv4: string;
|
||||
tailnetIPv6?: string;
|
||||
instanceLabel?: string;
|
||||
hostLabel?: string;
|
||||
tailnetDns?: string;
|
||||
sshPort?: number;
|
||||
cliPath?: string;
|
||||
};
|
||||
|
||||
function renderZone(opts: WideAreaBridgeZoneOpts & { serial: number }): string {
|
||||
@@ -91,9 +94,18 @@ function renderZone(opts: WideAreaBridgeZoneOpts & { serial: number }): string {
|
||||
`transport=bridge`,
|
||||
`bridgePort=${opts.bridgePort}`,
|
||||
];
|
||||
if (typeof opts.gatewayPort === "number" && opts.gatewayPort > 0) {
|
||||
txt.push(`gatewayPort=${opts.gatewayPort}`);
|
||||
}
|
||||
if (opts.tailnetDns?.trim()) {
|
||||
txt.push(`tailnetDns=${opts.tailnetDns.trim()}`);
|
||||
}
|
||||
if (typeof opts.sshPort === "number" && opts.sshPort > 0) {
|
||||
txt.push(`sshPort=${opts.sshPort}`);
|
||||
}
|
||||
if (opts.cliPath?.trim()) {
|
||||
txt.push(`cliPath=${opts.cliPath.trim()}`);
|
||||
}
|
||||
|
||||
const records: string[] = [];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user