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

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