diff --git a/CHANGELOG.md b/CHANGELOG.md index c775398c5..c5ad3a3ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/cli/gateway.md b/docs/cli/gateway.md index 87f799c2a..cbe3e2e13 100644 --- a/docs/cli/gateway.md +++ b/docs/cli/gateway.md @@ -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 diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 473cd84f7..8b53ae41e 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -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})`, diff --git a/src/infra/bonjour-discovery.test.ts b/src/infra/bonjour-discovery.test.ts index d30e726b1..4b121c7f3 100644 --- a/src/infra/bonjour-discovery.test.ts +++ b/src/infra/bonjour-discovery.test.ts @@ -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[]) => { diff --git a/src/infra/bonjour-discovery.ts b/src/infra/bonjour-discovery.ts index 1dfba3492..6ec233872 100644 --- a/src/infra/bonjour-discovery.ts +++ b/src/infra/bonjour-discovery.ts @@ -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) : {}; + 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)) { + 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 { + 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( diff --git a/src/infra/widearea-dns.test.ts b/src/infra/widearea-dns.test.ts index 6f99c40c5..6b7941ef7 100644 --- a/src/infra/widearea-dns.test.ts +++ b/src/infra/widearea-dns.test.ts @@ -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", diff --git a/src/infra/widearea-dns.ts b/src/infra/widearea-dns.ts index 172802e4e..934415125 100644 --- a/src/infra/widearea-dns.ts +++ b/src/infra/widearea-dns.ts @@ -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[] = [];