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 = "clawdbot.internal."; export const WIDE_AREA_ZONE_FILENAME = "clawdbot.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*clawdbot-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; gatewayPort?: number; displayName: string; tailnetIPv4: string; tailnetIPv6?: string; bridgeTlsEnabled?: boolean; bridgeTlsFingerprintSha256?: string; instanceLabel?: string; hostLabel?: string; tailnetDns?: string; sshPort?: number; cliPath?: string; }; function renderZone(opts: WideAreaBridgeZoneOpts & { serial: number }): string { const hostname = os.hostname().split(".")[0] ?? "clawdbot"; const hostLabel = dnsLabel(opts.hostLabel ?? hostname, "clawdbot"); const instanceLabel = dnsLabel(opts.instanceLabel ?? `${hostname}-bridge`, "clawdbot-bridge"); const txt = [ `displayName=${opts.displayName.trim() || hostname}`, `transport=bridge`, `bridgePort=${opts.bridgePort}`, ]; if (typeof opts.gatewayPort === "number" && opts.gatewayPort > 0) { txt.push(`gatewayPort=${opts.gatewayPort}`); } if (opts.bridgeTlsEnabled) { txt.push(`bridgeTls=1`); if (opts.bridgeTlsFingerprintSha256) { txt.push(`bridgeTlsSha256=${opts.bridgeTlsFingerprintSha256}`); } } if (opts.tailnetDns?.trim()) { txt.push(`tailnetDns=${opts.tailnetDns.trim()}`); } if (typeof opts.sshPort === "number" && opts.sshPort > 0) { txt.push(`sshPort=${opts.sshPort}`); } if (opts.cliPath?.trim()) { txt.push(`cliPath=${opts.cliPath.trim()}`); } 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(`_clawdbot-bridge._tcp IN PTR ${instanceLabel}._clawdbot-bridge._tcp`); records.push(`${instanceLabel}._clawdbot-bridge._tcp IN SRV 0 0 ${opts.bridgePort} ${hostLabel}`); records.push(`${instanceLabel}._clawdbot-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 `; clawdbot-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 }; }