feat(24-04): implement NS normalization and delegation-comparison module
- normalizeNsList: lowercase, strip trailing dot, dedupe, sort, [] for non-arrays - compareNsDelegation: set-diff mismatch with unjudgeable-empty-authoritative guard - resolveLiveNs: dedicated dns.Resolver() pinned to 1.1.1.1/8.8.8.8, never touches the process-global resolver (D-12, T-24-13) - checkAllZoneDelegations: bounded-concurrency batch check, lookup errors reported separately from mismatches (T-24-14)
This commit is contained in:
parent
7396f07f2e
commit
06ebae5a5c
1 changed files with 195 additions and 0 deletions
195
lib/services/route53-dns-delegation.ts
Normal file
195
lib/services/route53-dns-delegation.ts
Normal file
|
|
@ -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<string>();
|
||||||
|
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<ResolveLiveNsResult> {
|
||||||
|
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<ZoneDelegationResult[]> {
|
||||||
|
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<void> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue