Gateway: wide-area Bonjour via clawdis.internal

This commit is contained in:
Peter Steinberger
2025-12-17 17:01:10 +01:00
parent a1940418fb
commit e9ae10e569
13 changed files with 673 additions and 57 deletions

View File

@@ -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 clawds 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)

View File

@@ -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/WiFi/public interfaces.
#
# Replace `<TAILNET_IPV4>` / `<TAILNET_IPV6>` with this machines Tailscale IPs.
bind <TAILNET_IPV4> <TAILNET_IPV6>
log
errors
file /opt/homebrew/etc/coredns/clawdis.internal.db
}
EOF
# Replace `<TAILNET_IPV4>` with the gateway machines 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 <TAILNET_IPV4>
gw-london IN A <TAILNET_IPV4>
_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 gateways 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=<TAILNET_IPV4>` 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

View File

@@ -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/WiFi and Tailscale)
- `tailnet`: bind only to the machines 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 DNSSD)
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).

View File

@@ -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.

12
src/cli/dns-cli.test.ts Normal file
View File

@@ -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:"));
});
});

169
src/cli/dns-cli.ts Normal file
View File

@@ -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 ?? "<this machine's tailnet IPv4>"}`,
);
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.",
);
}
});
}

View File

@@ -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")

View File

@@ -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 {

View File

@@ -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 },

33
src/infra/tailnet.test.ts Normal file
View File

@@ -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"]);
});
});

52
src/infra/tailnet.ts Normal file
View File

@@ -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];
}

View File

@@ -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)`);
});
});

163
src/infra/widearea-dns.ts Normal file
View File

@@ -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 };
}