Gateway: wide-area Bonjour via clawdis.internal
This commit is contained in:
33
src/infra/tailnet.test.ts
Normal file
33
src/infra/tailnet.test.ts
Normal 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
52
src/infra/tailnet.ts
Normal 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];
|
||||
}
|
||||
31
src/infra/widearea-dns.test.ts
Normal file
31
src/infra/widearea-dns.test.ts
Normal 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
163
src/infra/widearea-dns.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user