diff --git a/lib/services/route53-dns-delegation.ts b/lib/services/route53-dns-delegation.ts new file mode 100644 index 0000000..5ef078c --- /dev/null +++ b/lib/services/route53-dns-delegation.ts @@ -0,0 +1,195 @@ +/** + * D-12 DNS-delegation health check helpers. + * + * Split into a pure half (normalization + set comparison, fully unit-testable) + * and an I/O half (live public DNS lookup via a dedicated `dns.Resolver()` + * instance). The pure half never touches the network; the I/O half never + * touches the process-global resolver — see the CRITICAL comment on + * `resolveLiveNs` below (24-RESEARCH.md Pitfall 5 / threat T-24-13). + */ + +import { Resolver } from 'dns'; + +// ============================================================================ +// Pure half — NS normalization + comparison (unit-testable, no I/O) +// ============================================================================ + +/** + * Normalize an unknown input (expected to be a JSONB-sourced string array, + * e.g. route53_zones.authoritative_name_servers or a raw DNS answer) into a + * lowercase, trailing-dot-stripped, de-duplicated, sorted list of hostnames. + * + * Returns [] for null, undefined, or any non-array input rather than + * throwing — callers treat an empty list as "unjudgeable", not an error. + */ +export function normalizeNsList(input: unknown): string[] { + if (!Array.isArray(input)) return []; + const set = new Set(); + for (const entry of input) { + const normalized = String(entry).trim().toLowerCase().replace(/\.$/, ''); + if (normalized.length > 0) set.add(normalized); + } + return Array.from(set).sort(); +} + +export interface NsDelegationComparison { + mismatch: boolean; + authoritative: string[]; + live: string[]; + missingFromLive: string[]; + extraInLive: string[]; +} + +/** + * Compare Route 53's authoritative NS list against a live public DNS answer. + * + * - An empty (normalized) authoritative list is unjudgeable — a zone Pulse + * has no recorded NS data for cannot be flagged as drifted. Returns + * `mismatch: false`. + * - A non-empty authoritative list with an empty live answer IS a delegation + * problem (the domain resolved to nothing) — returns `mismatch: true`. + * - Otherwise mismatch is true iff either side has an entry the other lacks. + */ +export function compareNsDelegation(authoritative: unknown, live: unknown): NsDelegationComparison { + const authNorm = normalizeNsList(authoritative); + const liveNorm = normalizeNsList(live); + + if (authNorm.length === 0) { + return { mismatch: false, authoritative: authNorm, live: liveNorm, missingFromLive: [], extraInLive: [] }; + } + if (liveNorm.length === 0) { + return { + mismatch: true, + authoritative: authNorm, + live: liveNorm, + missingFromLive: [...authNorm], + extraInLive: [], + }; + } + + const authSet = new Set(authNorm); + const liveSet = new Set(liveNorm); + const missingFromLive = authNorm.filter((ns) => !liveSet.has(ns)); + const extraInLive = liveNorm.filter((ns) => !authSet.has(ns)); + + return { + mismatch: missingFromLive.length > 0 || extraInLive.length > 0, + authoritative: authNorm, + live: liveNorm, + missingFromLive, + extraInLive, + }; +} + +// ============================================================================ +// I/O half — live public DNS lookup (dedicated resolver instance) +// ============================================================================ + +export type ResolveLiveNsResult = + | { ok: true; nameServers: string[] } + | { ok: false; error: string }; + +/** + * Resolve a domain's live NS records against a dedicated public resolver + * (Cloudflare 1.1.1.1 + Google 8.8.8.8), NOT the container's default + * resolver (24-RESEARCH.md Pitfall 5). + * + * CRITICAL: this constructs its own `new Resolver()` instance and calls + * `setServers()` on THAT instance only. The process-global `dns.setServers()` + * is never called anywhere in this file — doing so would repoint DNS + * resolution for the entire Node process, including Postgres/Redis/AWS + * hostname resolution (T-24-13). + */ +export function resolveLiveNs(domain: string, timeoutMs = 5000): Promise { + const target = domain.trim().replace(/\.$/, ''); + const resolver = new Resolver(); + resolver.setServers(['1.1.1.1', '8.8.8.8']); + + return new Promise((resolve) => { + let settled = false; + const timer = setTimeout(() => { + if (settled) return; + settled = true; + resolver.cancel(); + resolve({ ok: false, error: `DNS lookup timed out after ${timeoutMs}ms` }); + }, timeoutMs); + + resolver.resolveNs(target, (err, addresses) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (err) { + resolve({ ok: false, error: err.message }); + return; + } + resolve({ ok: true, nameServers: addresses }); + }); + }); +} + +export interface ZoneDelegationInput { + id: string; + name: string; + authoritativeNameServers: unknown; +} + +export interface ZoneDelegationResult { + zoneId: string; + zoneName: string; + mismatch: boolean; + error?: string; + missingFromLive: string[]; + extraInLive: string[]; +} + +/** + * Check live NS delegation for a batch of zones, bounded to at most + * `opts.concurrency` (default 5) concurrent lookups so a large zone list + * does not open hundreds of concurrent UDP sockets (T-24-14). + * + * Zones whose authoritativeNameServers normalizes to an empty list are + * skipped (unjudgeable). A lookup failure yields `{ mismatch: false, error }` + * — an unreachable resolver is an infrastructure problem, not evidence of + * delegation drift, and must never be reported as a mismatch. + */ +export async function checkAllZoneDelegations( + zones: ZoneDelegationInput[], + opts?: { concurrency?: number }, +): Promise { + const concurrency = opts?.concurrency ?? 5; + const judgeable = zones.filter((z) => normalizeNsList(z.authoritativeNameServers).length > 0); + const results: ZoneDelegationResult[] = []; + + let cursor = 0; + async function worker(): Promise { + while (cursor < judgeable.length) { + const idx = cursor; + cursor += 1; + const zone = judgeable[idx]; + const live = await resolveLiveNs(zone.name); + if (!live.ok) { + results.push({ + zoneId: zone.id, + zoneName: zone.name, + mismatch: false, + error: live.error, + missingFromLive: [], + extraInLive: [], + }); + continue; + } + const comparison = compareNsDelegation(zone.authoritativeNameServers, live.nameServers); + results.push({ + zoneId: zone.id, + zoneName: zone.name, + mismatch: comparison.mismatch, + missingFromLive: comparison.missingFromLive, + extraInLive: comparison.extraInLive, + }); + } + } + + const workers = Array.from({ length: Math.min(concurrency, judgeable.length) }, () => worker()); + await Promise.all(workers); + return results; +}