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. - **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. - **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. - **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 ## Codebase
@@ -155,6 +156,7 @@ Optional: enable/configure clawds dedicated browser control (defaults are alr
- [Configuration Guide](./docs/configuration.md) - [Configuration Guide](./docs/configuration.md)
- [Gateway runbook](./docs/gateway.md) - [Gateway runbook](./docs/gateway.md)
- [Discovery + transports](./docs/discovery.md) - [Discovery + transports](./docs/discovery.md)
- [Bonjour / mDNS + Wide-Area Bonjour](./docs/bonjour.md)
- [Agent Runtime](./docs/agent.md) - [Agent Runtime](./docs/agent.md)
- [Group Chats](./docs/group-messages.md) - [Group Chats](./docs/group-messages.md)
- [Security](./docs/security.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). 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.`). 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). 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 ```bash
brew install coredns clawdis dns setup --apply
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
``` ```
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: Validate from any tailnet-connected machine:
```bash ```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: 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). - 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). 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_DISABLE_BONJOUR=1` disables advertising.
- `CLAWDIS_BRIDGE_ENABLED=0` disables the bridge listener (and therefore the bridge beacon). - `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_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 ## 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 variables
Template placeholders are expanded in `inbound.transcribeAudio.command` (and any future templated command fields). 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: Disable/override:
- `CLAWDIS_DISABLE_BONJOUR=1` disables advertising. - `CLAWDIS_DISABLE_BONJOUR=1` disables advertising.
- `CLAWDIS_BRIDGE_ENABLED=0` disables the bridge listener. - `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_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. - `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 { startWebChatServer } from "../webchat/server.js";
import { registerCronCli } from "./cron-cli.js"; import { registerCronCli } from "./cron-cli.js";
import { createDefaultDeps } from "./deps.js"; import { createDefaultDeps } from "./deps.js";
import { registerDnsCli } from "./dns-cli.js";
import { registerGatewayCli } from "./gateway-cli.js"; import { registerGatewayCli } from "./gateway-cli.js";
import { registerNodesCli } from "./nodes-cli.js"; import { registerNodesCli } from "./nodes-cli.js";
import { forceFreePort } from "./ports.js"; import { forceFreePort } from "./ports.js";
@@ -248,6 +249,7 @@ Examples:
registerGatewayCli(program); registerGatewayCli(program);
registerNodesCli(program); registerNodesCli(program);
registerCronCli(program); registerCronCli(program);
registerDnsCli(program);
program program
.command("status") .command("status")

View File

@@ -75,6 +75,29 @@ export type GroupChatConfig = {
historyLimit?: number; 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 = { export type ClawdisConfig = {
identity?: { identity?: {
name?: string; name?: string;
@@ -120,6 +143,8 @@ export type ClawdisConfig = {
telegram?: TelegramConfig; telegram?: TelegramConfig;
webchat?: WebChatConfig; webchat?: WebChatConfig;
cron?: CronConfig; cron?: CronConfig;
bridge?: BridgeConfig;
discovery?: DiscoveryConfig;
}; };
// New branding path (preferred) // New branding path (preferred)
@@ -259,6 +284,29 @@ const ClawdisSchema = z.object({
webhookPath: z.string().optional(), webhookPath: z.string().optional(),
}) })
.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 { function escapeRegExp(text: string): string {

View File

@@ -61,11 +61,19 @@ import {
updateSystemPresence, updateSystemPresence,
upsertPresence, upsertPresence,
} from "../infra/system-presence.js"; } from "../infra/system-presence.js";
import {
pickPrimaryTailnetIPv4,
pickPrimaryTailnetIPv6,
} from "../infra/tailnet.js";
import { import {
defaultVoiceWakeTriggers, defaultVoiceWakeTriggers,
loadVoiceWakeConfig, loadVoiceWakeConfig,
setVoiceWakeTriggers, setVoiceWakeTriggers,
} from "../infra/voicewake.js"; } from "../infra/voicewake.js";
import {
WIDE_AREA_DISCOVERY_DOMAIN,
writeWideAreaBridgeZone,
} from "../infra/widearea-dns.js";
import { logError, logInfo, logWarn } from "../logger.js"; import { logError, logInfo, logWarn } from "../logger.js";
import { import {
getChildLogger, getChildLogger,
@@ -715,12 +723,51 @@ export async function startGatewayServer(
} }
}; };
const bridgeHost = process.env.CLAWDIS_BRIDGE_HOST ?? "0.0.0.0"; const wideAreaDiscoveryEnabled =
const bridgePort = cfgAtStart.discovery?.wideArea?.enabled === true;
process.env.CLAWDIS_BRIDGE_PORT !== undefined
? Number.parseInt(process.env.CLAWDIS_BRIDGE_PORT, 10) const bridgeEnabled = (() => {
: 18790; if (cfgAtStart.bridge?.enabled !== undefined)
const bridgeEnabled = process.env.CLAWDIS_BRIDGE_ENABLED !== "0"; 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 bridgeSubscribe = (nodeId: string, sessionKey: string) => {
const normalizedNodeId = nodeId.trim(); const normalizedNodeId = nodeId.trim();
@@ -1241,7 +1288,7 @@ export async function startGatewayServer(
const machineDisplayName = await getMachineDisplayName(); const machineDisplayName = await getMachineDisplayName();
if (bridgeEnabled && bridgePort > 0) { if (bridgeEnabled && bridgePort > 0 && bridgeHost) {
try { try {
const started = await startNodeBridgeServer({ const started = await startNodeBridgeServer({
host: bridgeHost, host: bridgeHost,
@@ -1334,6 +1381,10 @@ export async function startGatewayServer(
} catch (err) { } catch (err) {
logWarn(`gateway: bridge failed to start: ${String(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 { try {
@@ -1345,8 +1396,11 @@ export async function startGatewayServer(
: undefined; : undefined;
const tailnetDnsEnv = process.env.CLAWDIS_TAILNET_DNS?.trim(); const tailnetDnsEnv = process.env.CLAWDIS_TAILNET_DNS?.trim();
const tailnetDns = const tailnetDns = wideAreaDiscoveryEnabled
tailnetDnsEnv && tailnetDnsEnv.length > 0 ? tailnetDnsEnv : undefined; ? WIDE_AREA_DISCOVERY_DOMAIN
: tailnetDnsEnv && tailnetDnsEnv.length > 0
? tailnetDnsEnv
: undefined;
const bonjour = await startGatewayBonjourAdvertiser({ const bonjour = await startGatewayBonjourAdvertiser({
instanceName: formatBonjourInstanceName(machineDisplayName), instanceName: formatBonjourInstanceName(machineDisplayName),
@@ -1360,6 +1414,30 @@ export async function startGatewayServer(
logWarn(`gateway: bonjour advertising failed: ${String(err)}`); 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) => { broadcastHealthUpdate = (snap: HealthSummary) => {
broadcast("health", snap, { broadcast("health", snap, {
stateVersion: { presence: presenceVersion, health: healthVersion }, 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 };
}