import { execSync } from 'node:child_process'; import { readFileSync, writeFileSync } from 'node:fs'; const ZONE_DIR = '/config'; /** * Records that are reserved e2e infrastructure and must NEVER be mutated by a * customer DDNS update — the website-sim's seeded names. A DDNS update with a * NAT'd source IP once clobbered celilo.computer's apex off the website-sim * (100.64.0.58 -> the fw-ext gateway 100.64.0.1), silently breaking install-sh * for the rest of the suite. Also a real-operator footgun: a misbehaving DDNS * client must not be able to overwrite a domain's apex/website record. * (e2e-confidence #257.) */ const PROTECTED_RECORDS: Record> = { 'celilo.computer': new Set(['@', '', 'celilo.computer', 'www']), }; /** True if (domain, host) names a protected (seeded sim) record. */ export function isProtectedRecord(domain: string, host: string): boolean { return PROTECTED_RECORDS[domain]?.has(host) ?? false; } /** * Update an A record in a Knot zone file and reload. * * If the host already has an A record, it's replaced. * If not, a new one is appended before the end of the file. * The SOA serial is incremented on every update. * * Refuses to touch protected reserved-infrastructure records (PROTECTED_RECORDS). */ export function updateZone(domain: string, host: string, ip: string): void { if (isProtectedRecord(domain, host)) { throw new Error( `Refusing DDNS update to "${host}.${domain}": it is reserved e2e infrastructure (the website-sim), not a DDNS-managed host (e2e-confidence #257).`, ); } const zoneFile = `${ZONE_DIR}/${domain}.zone`; let content = readFileSync(zoneFile, 'utf-8'); // Increment SOA serial (YYYYMMDDnn format — just increment the number) content = content.replace(/(\d{10})(\s+\d+\s+\d+\s+\d+\s+\d+\s*\))/, (_match, serial, rest) => { const newSerial = String(Number(serial) + 1); return `${newSerial}${rest}`; }); // Check if host already has an A record. Match ANY existing value (\S+), // not just dotted-decimal: a prior update may have written a malformed // value (e.g. an IPv6-mapped address), and a fresh update must REPLACE it // rather than append a duplicate `@` record — two A records for the apex // is what knot chokes on. const recordRegex = new RegExp(`^${escapeRegex(host)}\\s+IN\\s+A\\s+\\S+`, 'm'); if (recordRegex.test(content)) { // Replace existing record content = content.replace(recordRegex, `${host} IN A ${ip}`); } else { // Append new record content = `${content.trimEnd()}\n${host} IN A ${ip}\n`; } writeFileSync(zoneFile, content); // Reload the zone in Knot try { execSync(`knotc zone-reload ${domain}`, { timeout: 5000 }); } catch (err) { console.error(`Failed to reload zone ${domain}:`, err); } } function escapeRegex(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }