From e9ae10e569891e5e961ea2aa2e905af49dcf232c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 17 Dec 2025 17:01:10 +0100 Subject: [PATCH] Gateway: wide-area Bonjour via clawdis.internal --- README.md | 4 +- docs/bonjour.md | 72 +++++--------- docs/configuration.md | 45 +++++++++ docs/discovery.md | 3 +- src/cli/dns-cli.test.ts | 12 +++ src/cli/dns-cli.ts | 169 +++++++++++++++++++++++++++++++++ src/cli/program.ts | 2 + src/config/config.ts | 48 ++++++++++ src/gateway/server.ts | 96 +++++++++++++++++-- src/infra/tailnet.test.ts | 33 +++++++ src/infra/tailnet.ts | 52 ++++++++++ src/infra/widearea-dns.test.ts | 31 ++++++ src/infra/widearea-dns.ts | 163 +++++++++++++++++++++++++++++++ 13 files changed, 673 insertions(+), 57 deletions(-) create mode 100644 src/cli/dns-cli.test.ts create mode 100644 src/cli/dns-cli.ts create mode 100644 src/infra/tailnet.test.ts create mode 100644 src/infra/tailnet.ts create mode 100644 src/infra/widearea-dns.test.ts create mode 100644 src/infra/widearea-dns.ts diff --git a/README.md b/README.md index 300591ff6..82ebf9ebd 100644 --- a/README.md +++ b/README.md @@ -61,8 +61,9 @@ Only the Pi CLI is supported now; legacy Claude/Codex/Gemini paths have been rem - **One Gateway per host**. The Gateway is the only process allowed to own the WhatsApp Web session. - **Loopback-first**: the Gateway WebSocket listens on `ws://127.0.0.1:18789` and is not exposed on the LAN. -- **Bridge for nodes**: when enabled, the Gateway also exposes a LAN/tailnet-facing bridge on `tcp://0.0.0.0:18790` for paired nodes (Bonjour-discoverable). +- **Bridge for nodes**: when enabled, the Gateway also exposes a bridge on `tcp://0.0.0.0:18790` for paired nodes (Bonjour-discoverable). For tailnet-only setups, set `bridge.bind: "tailnet"` in `~/.clawdis/clawdis.json`. - **Remote control**: use a VPN/tailnet or an SSH tunnel (`ssh -N -L 18789:127.0.0.1:18789 user@host`). The macOS app can drive this flow. +- **Wide-Area Bonjour (optional)**: for auto-discovery across networks (Vienna ⇄ London) over Tailscale, use unicast DNS-SD on `clawdis.internal.`; see `docs/bonjour.md`. ## Codebase @@ -155,6 +156,7 @@ Optional: enable/configure clawd’s dedicated browser control (defaults are alr - [Configuration Guide](./docs/configuration.md) - [Gateway runbook](./docs/gateway.md) - [Discovery + transports](./docs/discovery.md) +- [Bonjour / mDNS + Wide-Area Bonjour](./docs/bonjour.md) - [Agent Runtime](./docs/agent.md) - [Group Chats](./docs/group-messages.md) - [Security](./docs/security.md) diff --git a/docs/bonjour.md b/docs/bonjour.md index 65f71dbf0..758987dfa 100644 --- a/docs/bonjour.md +++ b/docs/bonjour.md @@ -17,55 +17,34 @@ High level: 1) Run a DNS server on the gateway host (reachable via tailnet IP). 2) Publish DNS-SD records for `_clawdis-bridge._tcp` in a dedicated zone (example: `clawdis.internal.`). 3) Configure Tailscale **split DNS** so `clawdis.internal` resolves via that DNS server for clients (including iOS). -4) In Iris: Settings → Bridge → Advanced → set **Discovery Domain** to `clawdis.internal.` -### Example: CoreDNS on macOS (gateway host) +Clawdis standardizes on the discovery domain `clawdis.internal.` for this mode. iOS/Android nodes browse both `local.` and `clawdis.internal.` automatically (no per-device knob). -On the gateway host (macOS): +### Gateway config (recommended) + +On the gateway host (the machine running the Gateway bridge), add to `~/.clawdis/clawdis.json` (JSON5): + +```json5 +{ + bridge: { bind: "tailnet" }, // tailnet-only (recommended) + discovery: { wideArea: { enabled: true } } // enables clawdis.internal DNS-SD publishing +} +``` + +### One-time DNS server setup (gateway host) + +On the gateway host (macOS), run: ```bash -brew install coredns - -sudo mkdir -p /opt/homebrew/etc/coredns -sudo tee /opt/homebrew/etc/coredns/Corefile >/dev/null <<'EOF' -clawdis.internal:53 { - # Security: bind only to tailnet IPs so this DNS server is *not* reachable - # via LAN/Wi‑Fi/public interfaces. - # - # Replace `` / `` with this machine’s Tailscale IPs. - bind - log - errors - file /opt/homebrew/etc/coredns/clawdis.internal.db -} -EOF - -# Replace `` with the gateway machine’s tailnet IP. -sudo tee /opt/homebrew/etc/coredns/clawdis.internal.db >/dev/null <<'EOF' -$ORIGIN clawdis.internal. -$TTL 60 - -@ IN SOA ns.clawdis.internal. hostmaster.clawdis.internal. ( - 2025121701 ; serial - 60 ; refresh - 60 ; retry - 604800 ; expire - 60 ; minimum -) - -@ IN NS ns -ns IN A - -gw-london IN A - -_clawdis-bridge._tcp IN PTR ClawdisBridgeLondon._clawdis-bridge._tcp -ClawdisBridgeLondon._clawdis-bridge._tcp IN SRV 0 0 18790 gw-london -ClawdisBridgeLondon._clawdis-bridge._tcp IN TXT "displayName=Mac Studio (London)" -EOF - -sudo brew services start coredns +clawdis dns setup --apply ``` +This installs CoreDNS and configures it to: +- listen on port 53 **only** on the gateway’s Tailscale interface IPs +- serve the zone `clawdis.internal.` from the gateway-owned zone file `~/.clawdis/dns/clawdis.internal.db` + +The Gateway writes/updates that zone file when `discovery.wideArea.enabled` is true. + Validate from any tailnet-connected machine: ```bash @@ -88,7 +67,7 @@ The bridge port (default `18790`) is a plain TCP service. By default it binds to For a tailnet-only setup, bind it to the Tailscale IP instead: -- Set `CLAWDIS_BRIDGE_HOST=` on the gateway host. +- Set `bridge.bind: "tailnet"` in `~/.clawdis/clawdis.json`. - Restart the Gateway (or restart the macOS menubar app via `./scripts/restart-mac.sh` on that machine). This keeps the bridge reachable only from devices on your tailnet (unless you intentionally expose it some other way). @@ -169,9 +148,10 @@ Bonjour/DNS-SD often escapes bytes in service instance names as decimal `\\DDD` - `CLAWDIS_DISABLE_BONJOUR=1` disables advertising. - `CLAWDIS_BRIDGE_ENABLED=0` disables the bridge listener (and therefore the bridge beacon). -- `CLAWDIS_BRIDGE_HOST` / `CLAWDIS_BRIDGE_PORT` control bridge bind/port. +- `bridge.bind` / `bridge.port` in `~/.clawdis/clawdis.json` control bridge bind/port (preferred). +- `CLAWDIS_BRIDGE_HOST` / `CLAWDIS_BRIDGE_PORT` still work as a back-compat override when `bridge.bind` / `bridge.port` are not set. - `CLAWDIS_SSH_PORT` overrides the SSH port advertised in `_clawdis-master._tcp`. -- `CLAWDIS_TAILNET_DNS` publishes a `tailnetDns` hint (MagicDNS) in `_clawdis-master._tcp`. +- `CLAWDIS_TAILNET_DNS` publishes a `tailnetDns` hint (MagicDNS) in `_clawdis-master._tcp` (wide-area discovery uses `clawdis.internal.` automatically when enabled). ## Related docs diff --git a/docs/configuration.md b/docs/configuration.md index fb51a7d1f..192e5d862 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -154,6 +154,51 @@ Defaults: } ``` +### `bridge` (Iris/node bridge server) + +The Gateway can expose a simple TCP bridge for nodes (iOS/Android “Iris”), typically on port `18790`. + +Defaults: +- enabled: `true` +- port: `18790` +- bind: `lan` (binds to `0.0.0.0`) + +Bind modes: +- `lan`: `0.0.0.0` (reachable on any interface, including LAN/Wi‑Fi and Tailscale) +- `tailnet`: bind only to the machine’s Tailscale IP (recommended for Vienna ⇄ London) +- `loopback`: `127.0.0.1` (local only) +- `auto`: prefer tailnet IP if present, else `lan` + +```json5 +{ + bridge: { + enabled: true, + port: 18790, + bind: "tailnet" + } +} +``` + +### `discovery.wideArea` (Wide-Area Bonjour / unicast DNS‑SD) + +When enabled, the Gateway writes a unicast DNS-SD zone for `_clawdis-bridge._tcp` under `~/.clawdis/dns/` using the standard discovery domain `clawdis.internal.` + +To make iOS/Android discover across networks (Vienna ⇄ London), pair this with: +- a DNS server on the gateway host serving `clawdis.internal.` (CoreDNS is recommended) +- Tailscale **split DNS** so clients resolve `clawdis.internal` via that server + +One-time setup helper (gateway host): + +```bash +clawdis dns setup --apply +``` + +```json5 +{ + discovery: { wideArea: { enabled: true } } +} +``` + ## Template variables Template placeholders are expanded in `inbound.transcribeAudio.command` (and any future templated command fields). diff --git a/docs/discovery.md b/docs/discovery.md index 371c05de3..893f0b97c 100644 --- a/docs/discovery.md +++ b/docs/discovery.md @@ -60,7 +60,8 @@ Troubleshooting and beacon details: `docs/bonjour.md`. Disable/override: - `CLAWDIS_DISABLE_BONJOUR=1` disables advertising. - `CLAWDIS_BRIDGE_ENABLED=0` disables the bridge listener. -- `CLAWDIS_BRIDGE_HOST` / `CLAWDIS_BRIDGE_PORT` control bind/port. +- `bridge.bind` / `bridge.port` in `~/.clawdis/clawdis.json` control bridge bind/port (preferred). +- `CLAWDIS_BRIDGE_HOST` / `CLAWDIS_BRIDGE_PORT` still work as a back-compat override when `bridge.bind` / `bridge.port` are not set. - `CLAWDIS_SSH_PORT` overrides the SSH port advertised in the master beacon (defaults to 22). - `CLAWDIS_TAILNET_DNS` publishes a `tailnetDns` hint (MagicDNS) in the master beacon. diff --git a/src/cli/dns-cli.test.ts b/src/cli/dns-cli.test.ts new file mode 100644 index 000000000..6ad0938c6 --- /dev/null +++ b/src/cli/dns-cli.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it, vi } from "vitest"; + +const { buildProgram } = await import("./program.js"); + +describe("dns cli", () => { + it("prints setup info (no apply)", async () => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const program = buildProgram(); + await program.parseAsync(["dns", "setup"], { from: "user" }); + expect(log).toHaveBeenCalledWith(expect.stringContaining("Domain:")); + }); +}); diff --git a/src/cli/dns-cli.ts b/src/cli/dns-cli.ts new file mode 100644 index 000000000..722b8496e --- /dev/null +++ b/src/cli/dns-cli.ts @@ -0,0 +1,169 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +import type { Command } from "commander"; + +import { loadConfig } from "../config/config.js"; +import { + pickPrimaryTailnetIPv4, + pickPrimaryTailnetIPv6, +} from "../infra/tailnet.js"; +import { + getWideAreaZonePath, + WIDE_AREA_DISCOVERY_DOMAIN, +} from "../infra/widearea-dns.js"; + +type RunOpts = { allowFailure?: boolean; inherit?: boolean }; + +function run(cmd: string, args: string[], opts?: RunOpts): string { + const res = spawnSync(cmd, args, { + encoding: "utf-8", + stdio: opts?.inherit ? "inherit" : "pipe", + }); + if (res.error) throw res.error; + if (!opts?.allowFailure && res.status !== 0) { + const errText = + typeof res.stderr === "string" && res.stderr.trim() + ? res.stderr.trim() + : `exit ${res.status ?? "unknown"}`; + throw new Error(`${cmd} ${args.join(" ")} failed: ${errText}`); + } + return typeof res.stdout === "string" ? res.stdout : ""; +} + +function detectBrewPrefix(): string { + const out = run("brew", ["--prefix"]); + const prefix = out.trim(); + if (!prefix) throw new Error("failed to resolve Homebrew prefix"); + return prefix; +} + +function ensureImportLine(corefilePath: string, importGlob: string): boolean { + const existing = fs.readFileSync(corefilePath, "utf-8"); + if (existing.includes(importGlob)) return false; + const next = `${existing.replace(/\s*$/, "")}\n\nimport ${importGlob}\n`; + fs.writeFileSync(corefilePath, next, "utf-8"); + return true; +} + +export function registerDnsCli(program: Command) { + const dns = program + .command("dns") + .description("DNS helpers for wide-area discovery (Tailscale + CoreDNS)"); + + dns + .command("setup") + .description( + "Set up CoreDNS to serve clawdis.internal for unicast DNS-SD (Wide-Area Bonjour)", + ) + .option( + "--apply", + "Install/update CoreDNS config and (re)start the service (requires sudo)", + false, + ) + .action(async (opts) => { + const cfg = loadConfig(); + const tailnetIPv4 = pickPrimaryTailnetIPv4(); + const tailnetIPv6 = pickPrimaryTailnetIPv6(); + const zonePath = getWideAreaZonePath(); + + console.log(`Domain: ${WIDE_AREA_DISCOVERY_DOMAIN}`); + console.log(`Zone file (gateway-owned): ${zonePath}`); + console.log( + `Detected tailnet IP: ${tailnetIPv4 ?? "—"}${tailnetIPv6 ? ` (v6 ${tailnetIPv6})` : ""}`, + ); + console.log(""); + console.log("Recommended ~/.clawdis/clawdis.json:"); + console.log( + JSON.stringify( + { + bridge: { bind: "tailnet" }, + discovery: { wideArea: { enabled: true } }, + }, + null, + 2, + ), + ); + console.log(""); + console.log("Tailscale admin (DNS → Nameservers):"); + console.log( + `- Add nameserver: ${tailnetIPv4 ?? ""}`, + ); + console.log(`- Restrict to domain (Split DNS): clawdis.internal`); + + if (!opts.apply) { + console.log(""); + console.log("Run with --apply to install CoreDNS and configure it."); + return; + } + + if (process.platform !== "darwin") { + throw new Error("dns setup is currently supported on macOS only"); + } + if (!tailnetIPv4 && !tailnetIPv6) { + throw new Error( + "no tailnet IP detected; ensure Tailscale is running on this machine", + ); + } + + const prefix = detectBrewPrefix(); + const etcDir = path.join(prefix, "etc", "coredns"); + const corefilePath = path.join(etcDir, "Corefile"); + const confDir = path.join(etcDir, "conf.d"); + const importGlob = path.join(confDir, "*.server"); + const serverPath = path.join(confDir, "clawdis.internal.server"); + + run("brew", ["list", "coredns"], { allowFailure: true }); + run("brew", ["install", "coredns"], { + inherit: true, + allowFailure: true, + }); + + await fs.promises.mkdir(confDir, { recursive: true }); + + if (fs.existsSync(corefilePath)) { + ensureImportLine(corefilePath, importGlob); + } + + const bindArgs = [tailnetIPv4, tailnetIPv6].filter((v): v is string => + Boolean(v?.trim()), + ); + + const server = [ + `${WIDE_AREA_DISCOVERY_DOMAIN.replace(/\.$/, "")}:53 {`, + ` bind ${bindArgs.join(" ")}`, + ` file ${zonePath} {`, + ` reload 10s`, + ` }`, + ` errors`, + ` log`, + `}`, + ``, + ].join("\n"); + fs.writeFileSync(serverPath, server, "utf-8"); + + // Ensure the gateway can write its zone file path. + await fs.promises.mkdir(path.dirname(zonePath), { recursive: true }); + if (!fs.existsSync(zonePath)) { + fs.writeFileSync( + zonePath, + `; created by clawdis dns setup\n$ORIGIN ${WIDE_AREA_DISCOVERY_DOMAIN}\n$TTL 60\n`, + "utf-8", + ); + } + + console.log(""); + console.log("Starting CoreDNS (sudo)…"); + run("sudo", ["brew", "services", "restart", "coredns"], { + inherit: true, + }); + + if (cfg.discovery?.wideArea?.enabled !== true) { + console.log(""); + console.log( + "Note: enable discovery.wideArea.enabled in ~/.clawdis/clawdis.json on the gateway and restart the gateway so it writes the DNS-SD zone.", + ); + } + }); +} diff --git a/src/cli/program.ts b/src/cli/program.ts index 1dd590df1..b4d8a9c44 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -29,6 +29,7 @@ import { VERSION } from "../version.js"; import { startWebChatServer } from "../webchat/server.js"; import { registerCronCli } from "./cron-cli.js"; import { createDefaultDeps } from "./deps.js"; +import { registerDnsCli } from "./dns-cli.js"; import { registerGatewayCli } from "./gateway-cli.js"; import { registerNodesCli } from "./nodes-cli.js"; import { forceFreePort } from "./ports.js"; @@ -248,6 +249,7 @@ Examples: registerGatewayCli(program); registerNodesCli(program); registerCronCli(program); + registerDnsCli(program); program .command("status") diff --git a/src/config/config.ts b/src/config/config.ts index 74ca57e9d..5cd654096 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -75,6 +75,29 @@ export type GroupChatConfig = { historyLimit?: number; }; +export type BridgeBindMode = "auto" | "lan" | "tailnet" | "loopback"; + +export type BridgeConfig = { + enabled?: boolean; + port?: number; + /** + * Bind address policy for the Iris bridge server. + * - auto: prefer tailnet IP when present, else LAN (0.0.0.0) + * - lan: 0.0.0.0 (reachable on local network + any forwarded interfaces) + * - tailnet: bind only to the Tailscale interface IP (100.64.0.0/10) + * - loopback: 127.0.0.1 + */ + bind?: BridgeBindMode; +}; + +export type WideAreaDiscoveryConfig = { + enabled?: boolean; +}; + +export type DiscoveryConfig = { + wideArea?: WideAreaDiscoveryConfig; +}; + export type ClawdisConfig = { identity?: { name?: string; @@ -120,6 +143,8 @@ export type ClawdisConfig = { telegram?: TelegramConfig; webchat?: WebChatConfig; cron?: CronConfig; + bridge?: BridgeConfig; + discovery?: DiscoveryConfig; }; // New branding path (preferred) @@ -259,6 +284,29 @@ const ClawdisSchema = z.object({ webhookPath: z.string().optional(), }) .optional(), + bridge: z + .object({ + enabled: z.boolean().optional(), + port: z.number().int().positive().optional(), + bind: z + .union([ + z.literal("auto"), + z.literal("lan"), + z.literal("tailnet"), + z.literal("loopback"), + ]) + .optional(), + }) + .optional(), + discovery: z + .object({ + wideArea: z + .object({ + enabled: z.boolean().optional(), + }) + .optional(), + }) + .optional(), }); function escapeRegExp(text: string): string { diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 80b00c8a2..5c1405ba8 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -61,11 +61,19 @@ import { updateSystemPresence, upsertPresence, } from "../infra/system-presence.js"; +import { + pickPrimaryTailnetIPv4, + pickPrimaryTailnetIPv6, +} from "../infra/tailnet.js"; import { defaultVoiceWakeTriggers, loadVoiceWakeConfig, setVoiceWakeTriggers, } from "../infra/voicewake.js"; +import { + WIDE_AREA_DISCOVERY_DOMAIN, + writeWideAreaBridgeZone, +} from "../infra/widearea-dns.js"; import { logError, logInfo, logWarn } from "../logger.js"; import { getChildLogger, @@ -715,12 +723,51 @@ export async function startGatewayServer( } }; - const bridgeHost = process.env.CLAWDIS_BRIDGE_HOST ?? "0.0.0.0"; - const bridgePort = - process.env.CLAWDIS_BRIDGE_PORT !== undefined - ? Number.parseInt(process.env.CLAWDIS_BRIDGE_PORT, 10) - : 18790; - const bridgeEnabled = process.env.CLAWDIS_BRIDGE_ENABLED !== "0"; + const wideAreaDiscoveryEnabled = + cfgAtStart.discovery?.wideArea?.enabled === true; + + const bridgeEnabled = (() => { + if (cfgAtStart.bridge?.enabled !== undefined) + return cfgAtStart.bridge.enabled === true; + return process.env.CLAWDIS_BRIDGE_ENABLED !== "0"; + })(); + + const bridgePort = (() => { + if ( + typeof cfgAtStart.bridge?.port === "number" && + cfgAtStart.bridge.port > 0 + ) { + return cfgAtStart.bridge.port; + } + if (process.env.CLAWDIS_BRIDGE_PORT !== undefined) { + const parsed = Number.parseInt(process.env.CLAWDIS_BRIDGE_PORT, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 18790; + } + return 18790; + })(); + + const bridgeHost = (() => { + // Back-compat: allow an env var override when no bind policy is configured. + if (cfgAtStart.bridge?.bind === undefined) { + const env = process.env.CLAWDIS_BRIDGE_HOST?.trim(); + if (env) return env; + } + + const bind = + cfgAtStart.bridge?.bind ?? (wideAreaDiscoveryEnabled ? "tailnet" : "lan"); + if (bind === "loopback") return "127.0.0.1"; + if (bind === "lan") return "0.0.0.0"; + + const tailnetIPv4 = pickPrimaryTailnetIPv4(); + const tailnetIPv6 = pickPrimaryTailnetIPv6(); + if (bind === "tailnet") { + return tailnetIPv4 ?? tailnetIPv6 ?? null; + } + if (bind === "auto") { + return tailnetIPv4 ?? tailnetIPv6 ?? "0.0.0.0"; + } + return "0.0.0.0"; + })(); const bridgeSubscribe = (nodeId: string, sessionKey: string) => { const normalizedNodeId = nodeId.trim(); @@ -1241,7 +1288,7 @@ export async function startGatewayServer( const machineDisplayName = await getMachineDisplayName(); - if (bridgeEnabled && bridgePort > 0) { + if (bridgeEnabled && bridgePort > 0 && bridgeHost) { try { const started = await startNodeBridgeServer({ host: bridgeHost, @@ -1334,6 +1381,10 @@ export async function startGatewayServer( } catch (err) { logWarn(`gateway: bridge failed to start: ${String(err)}`); } + } else if (bridgeEnabled && bridgePort > 0 && !bridgeHost) { + logWarn( + "gateway: bridge bind policy requested tailnet IP, but no tailnet interface was found; refusing to start bridge", + ); } try { @@ -1345,8 +1396,11 @@ export async function startGatewayServer( : undefined; const tailnetDnsEnv = process.env.CLAWDIS_TAILNET_DNS?.trim(); - const tailnetDns = - tailnetDnsEnv && tailnetDnsEnv.length > 0 ? tailnetDnsEnv : undefined; + const tailnetDns = wideAreaDiscoveryEnabled + ? WIDE_AREA_DISCOVERY_DOMAIN + : tailnetDnsEnv && tailnetDnsEnv.length > 0 + ? tailnetDnsEnv + : undefined; const bonjour = await startGatewayBonjourAdvertiser({ instanceName: formatBonjourInstanceName(machineDisplayName), @@ -1360,6 +1414,30 @@ export async function startGatewayServer( logWarn(`gateway: bonjour advertising failed: ${String(err)}`); } + if (wideAreaDiscoveryEnabled && bridge?.port) { + const tailnetIPv4 = pickPrimaryTailnetIPv4(); + if (!tailnetIPv4) { + logWarn( + "gateway: discovery.wideArea.enabled is true, but no Tailscale IPv4 address was found; skipping unicast DNS-SD zone update", + ); + } else { + try { + const tailnetIPv6 = pickPrimaryTailnetIPv6(); + const result = await writeWideAreaBridgeZone({ + bridgePort: bridge.port, + displayName: formatBonjourInstanceName(machineDisplayName), + tailnetIPv4, + tailnetIPv6: tailnetIPv6 ?? undefined, + }); + defaultRuntime.log( + `discovery: wide-area DNS-SD ${result.changed ? "updated" : "unchanged"} (${WIDE_AREA_DISCOVERY_DOMAIN} → ${result.zonePath})`, + ); + } catch (err) { + logWarn(`gateway: wide-area discovery update failed: ${String(err)}`); + } + } + } + broadcastHealthUpdate = (snap: HealthSummary) => { broadcast("health", snap, { stateVersion: { presence: presenceVersion, health: healthVersion }, diff --git a/src/infra/tailnet.test.ts b/src/infra/tailnet.test.ts new file mode 100644 index 000000000..1068d1098 --- /dev/null +++ b/src/infra/tailnet.test.ts @@ -0,0 +1,33 @@ +import os from "node:os"; + +import { describe, expect, it, vi } from "vitest"; + +import { listTailnetAddresses } from "./tailnet.js"; + +describe("tailnet address detection", () => { + it("detects tailscale IPv4 and IPv6 addresses", () => { + vi.spyOn(os, "networkInterfaces").mockReturnValue({ + lo0: [ + { address: "127.0.0.1", family: "IPv4", internal: true, netmask: "" }, + ] as unknown as os.NetworkInterfaceInfo[], + utun9: [ + { + address: "100.123.224.76", + family: "IPv4", + internal: false, + netmask: "", + }, + { + address: "fd7a:115c:a1e0::8801:e04c", + family: "IPv6", + internal: false, + netmask: "", + }, + ] as unknown as os.NetworkInterfaceInfo[], + }); + + const out = listTailnetAddresses(); + expect(out.ipv4).toEqual(["100.123.224.76"]); + expect(out.ipv6).toEqual(["fd7a:115c:a1e0::8801:e04c"]); + }); +}); diff --git a/src/infra/tailnet.ts b/src/infra/tailnet.ts new file mode 100644 index 000000000..f5900a24b --- /dev/null +++ b/src/infra/tailnet.ts @@ -0,0 +1,52 @@ +import os from "node:os"; + +export type TailnetAddresses = { + ipv4: string[]; + ipv6: string[]; +}; + +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 + // https://tailscale.com/kb/1015/100.x-addresses + const [a, b] = octets; + return a === 100 && b >= 64 && b <= 127; +} + +function isTailnetIPv6(address: string): boolean { + // Tailscale IPv6 ULA prefix: fd7a:115c:a1e0::/48 + // (stable across tailnets; nodes get per-device suffixes) + const normalized = address.trim().toLowerCase(); + return normalized.startsWith("fd7a:115c:a1e0:"); +} + +export function listTailnetAddresses(): TailnetAddresses { + const ipv4: string[] = []; + const ipv6: string[] = []; + + const ifaces = os.networkInterfaces(); + for (const entries of Object.values(ifaces)) { + if (!entries) continue; + for (const e of entries) { + if (!e || e.internal) continue; + const address = e.address?.trim(); + if (!address) continue; + if (isTailnetIPv4(address)) ipv4.push(address); + if (isTailnetIPv6(address)) ipv6.push(address); + } + } + + return { ipv4: [...new Set(ipv4)], ipv6: [...new Set(ipv6)] }; +} + +export function pickPrimaryTailnetIPv4(): string | undefined { + return listTailnetAddresses().ipv4[0]; +} + +export function pickPrimaryTailnetIPv6(): string | undefined { + return listTailnetAddresses().ipv6[0]; +} diff --git a/src/infra/widearea-dns.test.ts b/src/infra/widearea-dns.test.ts new file mode 100644 index 000000000..47aebf135 --- /dev/null +++ b/src/infra/widearea-dns.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; + +import { + renderWideAreaBridgeZoneText, + WIDE_AREA_DISCOVERY_DOMAIN, +} from "./widearea-dns.js"; + +describe("wide-area DNS-SD zone rendering", () => { + it("renders a clawdis.internal zone with bridge PTR/SRV/TXT records", () => { + const txt = renderWideAreaBridgeZoneText({ + serial: 2025121701, + bridgePort: 18790, + displayName: "Mac Studio (Clawdis)", + tailnetIPv4: "100.123.224.76", + tailnetIPv6: "fd7a:115c:a1e0::8801:e04c", + hostLabel: "studio-london", + instanceLabel: "studio-london", + }); + + expect(txt).toContain(`$ORIGIN ${WIDE_AREA_DISCOVERY_DOMAIN}`); + expect(txt).toContain(`studio-london IN A 100.123.224.76`); + expect(txt).toContain(`studio-london IN AAAA fd7a:115c:a1e0::8801:e04c`); + expect(txt).toContain( + `_clawdis-bridge._tcp IN PTR studio-london._clawdis-bridge._tcp`, + ); + expect(txt).toContain( + `studio-london._clawdis-bridge._tcp IN SRV 0 0 18790 studio-london`, + ); + expect(txt).toContain(`displayName=Mac Studio (Clawdis)`); + }); +}); diff --git a/src/infra/widearea-dns.ts b/src/infra/widearea-dns.ts new file mode 100644 index 000000000..b5c6063fb --- /dev/null +++ b/src/infra/widearea-dns.ts @@ -0,0 +1,163 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { CONFIG_DIR, ensureDir } from "../utils.js"; + +export const WIDE_AREA_DISCOVERY_DOMAIN = "clawdis.internal."; +export const WIDE_AREA_ZONE_FILENAME = "clawdis.internal.db"; + +export function getWideAreaZonePath(): string { + return path.join(CONFIG_DIR, "dns", WIDE_AREA_ZONE_FILENAME); +} + +function dnsLabel(raw: string, fallback: string): string { + const normalized = raw + .trim() + .toLowerCase() + .replace(/[^a-z0-9-]+/g, "-") + .replace(/^-+/, "") + .replace(/-+$/, ""); + const out = normalized.length > 0 ? normalized : fallback; + return out.length <= 63 ? out : out.slice(0, 63); +} + +function txtQuote(value: string): string { + const escaped = value + .replaceAll("\\", "\\\\") + .replaceAll('"', '\\"') + .replaceAll("\n", "\\n"); + return `"${escaped}"`; +} + +function formatYyyyMmDd(date: Date): string { + const y = date.getUTCFullYear(); + const m = String(date.getUTCMonth() + 1).padStart(2, "0"); + const d = String(date.getUTCDate()).padStart(2, "0"); + return `${y}${m}${d}`; +} + +function nextSerial(existingSerial: number | null, now: Date): number { + const today = formatYyyyMmDd(now); + const base = Number.parseInt(`${today}01`, 10); + if (!existingSerial || !Number.isFinite(existingSerial)) return base; + const existing = String(existingSerial); + if (existing.startsWith(today)) return existingSerial + 1; + return base; +} + +function extractSerial(zoneText: string): number | null { + const match = zoneText.match(/^\s*@\s+IN\s+SOA\s+\S+\s+\S+\s+(\d+)\s+/m); + if (!match) return null; + const parsed = Number.parseInt(match[1], 10); + return Number.isFinite(parsed) ? parsed : null; +} + +function extractContentHash(zoneText: string): string | null { + const match = zoneText.match(/^\s*;\s*clawdis-content-hash:\s*(\S+)\s*$/m); + return match?.[1] ?? null; +} + +function computeContentHash(body: string): string { + // Cheap stable hash; avoids importing crypto (and keeps deterministic across runtimes). + let h = 2166136261; + for (let i = 0; i < body.length; i++) { + h ^= body.charCodeAt(i); + h = Math.imul(h, 16777619); + } + return (h >>> 0).toString(16).padStart(8, "0"); +} + +export type WideAreaBridgeZoneOpts = { + bridgePort: number; + displayName: string; + tailnetIPv4: string; + tailnetIPv6?: string; + instanceLabel?: string; + hostLabel?: string; +}; + +function renderZone(opts: WideAreaBridgeZoneOpts & { serial: number }): string { + const hostname = os.hostname().split(".")[0] ?? "clawdis"; + const hostLabel = dnsLabel(opts.hostLabel ?? hostname, "clawdis"); + const instanceLabel = dnsLabel( + opts.instanceLabel ?? `${hostname}-bridge`, + "clawdis-bridge", + ); + + const txt = [ + `displayName=${opts.displayName.trim() || hostname}`, + `transport=bridge`, + `bridgePort=${opts.bridgePort}`, + ]; + + const records: string[] = []; + + records.push(`$ORIGIN ${WIDE_AREA_DISCOVERY_DOMAIN}`); + records.push(`$TTL 60`); + const soaLine = `@ IN SOA ns1 hostmaster ${opts.serial} 7200 3600 1209600 60`; + records.push(soaLine); + records.push(`@ IN NS ns1`); + records.push(`ns1 IN A ${opts.tailnetIPv4}`); + records.push(`${hostLabel} IN A ${opts.tailnetIPv4}`); + if (opts.tailnetIPv6) { + records.push(`${hostLabel} IN AAAA ${opts.tailnetIPv6}`); + } + + records.push( + `_clawdis-bridge._tcp IN PTR ${instanceLabel}._clawdis-bridge._tcp`, + ); + records.push( + `${instanceLabel}._clawdis-bridge._tcp IN SRV 0 0 ${opts.bridgePort} ${hostLabel}`, + ); + records.push( + `${instanceLabel}._clawdis-bridge._tcp IN TXT ${txt.map(txtQuote).join(" ")}`, + ); + + const contentBody = `${records.join("\n")}\n`; + const hashBody = `${records + .map((line) => + line === soaLine + ? `@ IN SOA ns1 hostmaster SERIAL 7200 3600 1209600 60` + : line, + ) + .join("\n")}\n`; + const contentHash = computeContentHash(hashBody); + + return `; clawdis-content-hash: ${contentHash}\n${contentBody}`; +} + +export function renderWideAreaBridgeZoneText( + opts: WideAreaBridgeZoneOpts & { serial: number }, +): string { + return renderZone(opts); +} + +export async function writeWideAreaBridgeZone( + opts: WideAreaBridgeZoneOpts, +): Promise<{ zonePath: string; changed: boolean }> { + const zonePath = getWideAreaZonePath(); + await ensureDir(path.dirname(zonePath)); + + const existing = (() => { + try { + return fs.readFileSync(zonePath, "utf-8"); + } catch { + return null; + } + })(); + + const nextNoSerial = renderWideAreaBridgeZoneText({ ...opts, serial: 0 }); + const nextHash = extractContentHash(nextNoSerial); + const existingHash = existing ? extractContentHash(existing) : null; + + if (existing && nextHash && existingHash === nextHash) { + return { zonePath, changed: false }; + } + + const existingSerial = existing ? extractSerial(existing) : null; + const serial = nextSerial(existingSerial, new Date()); + const next = renderWideAreaBridgeZoneText({ ...opts, serial }); + fs.writeFileSync(zonePath, next, "utf-8"); + return { zonePath, changed: true }; +}