/** * The off-fleet vantage point for the `public_dns` check. * * Two independent third parties, deliberately: * * - a **resolver that is not the fleet's**, asked what the internet resolves * for each name. The fleet's own resolver runs split-horizon and answers * with an address that is reachable in-zone — correct for its purpose, and * not evidence about the public internet. A `public_dns` check that quietly * used it would pass forever, which is the original defect one layer up, so * `assertOffFleetResolver` refuses rather than trusting a code comment. * - an **echo service**, for what the fleet's public ingress address actually * is. Not the registrar's response: comparing what was published against * what we asked to publish is self-agreement, and Namecheap answers * `ErrCount 0` for updates it does not apply (design.md D2/D3). * * Both are configurable, and both are named in every finding they produce. */ import { Resolver } from 'node:dns/promises'; import { eq } from 'drizzle-orm'; import type { DbClient } from '../db/client'; import { systemConfig } from '../db/schema'; import type { IngressObservation, PublicDnsProbe, PublicResolution } from './audit/public-dns'; /** Cloudflare. Overridable — the requirement is that it is not the fleet's. */ export const DEFAULT_PUBLIC_RESOLVER = '1.1.1.1'; export const DEFAULT_ECHO_URL = 'https://api.ipify.org'; const PROBE_TIMEOUT_MS = 5_000; export class FleetResolverAsPublicVantageError extends Error { constructor(resolver: string, role: string) { super( `Refusing to check public DNS through ${resolver}: it is the fleet's own resolver (${role}).\nThe fleet resolver runs split-horizon and answers with an in-zone address, so a\ncheck that used it would pass whatever the internet sees — which is exactly how\ncelilo#626 stayed invisible for nine days.\n\nSet an off-fleet resolver:\n celilo system config set public_dns.resolver 1.1.1.1`, ); this.name = 'FleetResolverAsPublicVantageError'; } } /** * Reject a resolver the fleet itself uses. Pure so the gate is unit-testable * without a database — it is the assertion §5.2 asks for. */ export function assertOffFleetResolver( resolver: string, fleetResolvers: { role: string; ip: string }[], ): void { const match = fleetResolvers.find((r) => r.ip === resolver); if (match) throw new FleetResolverAsPublicVantageError(resolver, match.role); } function configValue(db: DbClient, key: string): string | undefined { const row = db.select().from(systemConfig).where(eq(systemConfig.key, key)).get(); const value = row?.value?.trim(); return value && value.length > 0 ? value : undefined; } /** * Every resolver address the fleet is configured to use for its own lookups. * * `dns.fallback` holds a COMMA-SEPARATED list (`1.0.0.1,8.8.8.8` is what * `system init` writes), so it is split rather than compared whole. Treating it * as one string made the guard below miss every fallback but a single-entry * one — a fleet forwarding to 8.8.8.8 could have been handed 8.8.8.8 as its * "off-fleet" vantage point and the check would have agreed with itself * forever, which is precisely the failure this guard exists to prevent. */ export function fleetResolvers(db: DbClient): { role: string; ip: string }[] { return parseFleetResolvers( ['dns.primary', 'dns.fallback'].map((role) => ({ role, value: configValue(db, role) })), ); } /** The parse, split from the read so the comma handling is testable on its own. */ export function parseFleetResolvers( entries: { role: string; value: string | undefined }[], ): { role: string; ip: string }[] { const resolvers: { role: string; ip: string }[] = []; for (const { role, value } of entries) { for (const ip of (value ?? '').split(',')) { const trimmed = ip.trim(); if (trimmed) resolvers.push({ role, ip: trimmed }); } } return resolvers; } export interface PublicDnsProbeSettings { resolver: string; echoUrl: string; } export function loadPublicDnsProbeSettings(db: DbClient): PublicDnsProbeSettings { const resolver = configValue(db, 'public_dns.resolver') ?? DEFAULT_PUBLIC_RESOLVER; assertOffFleetResolver(resolver, fleetResolvers(db)); return { resolver, echoUrl: configValue(db, 'public_dns.echo_url') ?? DEFAULT_ECHO_URL }; } /** * `fetch` with a bound, so an unanswered echo request cannot hang a scheduled * check (the shape celilo#622 fixed for DDNS). */ async function fetchIngress(echoUrl: string): Promise { try { const response = await fetch(echoUrl, { signal: AbortSignal.timeout(PROBE_TIMEOUT_MS), }); if (!response.ok) { return { kind: 'undetermined', reason: `HTTP ${response.status}` }; } const ip = (await response.text()).trim(); if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(ip)) { return { kind: 'undetermined', reason: `unparseable answer: ${ip.slice(0, 40)}` }; } return { kind: 'observed', ip }; } catch (error) { return { kind: 'undetermined', reason: error instanceof Error ? error.message : String(error), }; } } export function createPublicDnsProbe(settings: PublicDnsProbeSettings): PublicDnsProbe { const resolver = new Resolver({ timeout: PROBE_TIMEOUT_MS, tries: 2 }); resolver.setServers([settings.resolver]); return { resolver: settings.resolver, echoService: settings.echoUrl, observeIngress: () => fetchIngress(settings.echoUrl), async resolve(fqdn: string): Promise { try { // `ttl: true` is why this uses node:dns rather than shelling out to // dig: the TTL is what the hysteresis window is measured in. const answers = await resolver.resolve4(fqdn, { ttl: true }); const first = answers[0]; if (!first) return { kind: 'no_record' }; return { kind: 'answer', ip: first.address, ttlSeconds: first.ttl }; } catch (error) { const code = (error as NodeJS.ErrnoException).code; // NXDOMAIN / NODATA are authoritative answers: the name really has no // A record. Everything else (timeout, SERVFAIL, refused) means the // probe could not look, which is never a pass. if (code === 'ENOTFOUND' || code === 'ENODATA') return { kind: 'no_record' }; return { kind: 'undetermined', reason: code ?? (error instanceof Error ? error.message : String(error)), }; } }, }; }