/** * Internal split-horizon DNS A-record ledger (designs/CELILO_DOCTOR_FLEET_DRIFT.md * Phase 4, ISS-0094 / ISS-0111). * * The dns_internal capability (technitium/knot) registers records straight * into the resolver's own DB, leaving celilo no offline record of what it * asked to be served. This ledger — the internal-DNS sibling of * `dns_registrations` — records every successful `registerRecord({type:'A'})` * so `celilo system doctor` can assert, without a live resolver probe, that * service hostnames resolve to the firewall natIp (LAN-reachable) rather than * a zone-side container IP a LAN device can't route to. * * Row lifecycle is FK cascade — records die with their provider or consumer. */ import type { DnsInternalCapability, DnsRecordRequest, ViewOverride } from '@celilo/capabilities'; import { and, eq, isNotNull } from 'drizzle-orm'; import type { DbClient } from '../db/client'; import { dnsInternalRecords } from '../db/schema'; export interface DnsInternalRecordRow { host: string; ip: string; /** In-zone split-horizon answer (caddy's zone IP), or null for plain records. */ zoneRoutableIp: string | null; providerModuleId: string; consumerModuleId: string; registeredAt: Date; } export function listDnsInternalRecords( db: DbClient, options: { providerModuleId?: string } = {}, ): DnsInternalRecordRow[] { const query = db .select({ host: dnsInternalRecords.host, ip: dnsInternalRecords.ip, zoneRoutableIp: dnsInternalRecords.zoneRoutableIp, providerModuleId: dnsInternalRecords.providerModuleId, consumerModuleId: dnsInternalRecords.consumerModuleId, registeredAt: dnsInternalRecords.registeredAt, }) .from(dnsInternalRecords); return options.providerModuleId ? query.where(eq(dnsInternalRecords.providerModuleId, options.providerModuleId)).all() : query.all(); } /** * The COMPLETE set of source-based split-horizon view overrides a provider * should serve — every ledger record that carries a zone-routable IP (ISS-0156). * This is the desired state the resolver's view config is reconciled from; the * provider's `reconcileViews` is the single writer that materializes it. */ export function listViewOverrides(db: DbClient, providerModuleId: string): ViewOverride[] { return db .select({ host: dnsInternalRecords.host, ip: dnsInternalRecords.zoneRoutableIp }) .from(dnsInternalRecords) .where( and( eq(dnsInternalRecords.providerModuleId, providerModuleId), isNotNull(dnsInternalRecords.zoneRoutableIp), ), ) .all() .map((r) => ({ host: r.host, ip: r.ip as string })); } export function recordDnsInternalRecord( db: DbClient, record: { providerModuleId: string; consumerModuleId: string; host: string; ip: string; /** In-zone split-horizon answer (caddy's zone IP); null/absent for plain records. */ zoneRoutableIp?: string | null; }, ): void { const zoneRoutableIp = record.zoneRoutableIp ?? null; db.insert(dnsInternalRecords) .values({ providerModuleId: record.providerModuleId, consumerModuleId: record.consumerModuleId, host: record.host, ip: record.ip, zoneRoutableIp, registeredAt: new Date(), }) .onConflictDoUpdate({ target: [dnsInternalRecords.providerModuleId, dnsInternalRecords.host], set: { consumerModuleId: record.consumerModuleId, ip: record.ip, zoneRoutableIp, registeredAt: new Date(), }, }) .run(); } export function removeDnsInternalRecord( db: DbClient, record: { providerModuleId: string; host: string }, ): void { db.delete(dnsInternalRecords) .where( and( eq(dnsInternalRecords.providerModuleId, record.providerModuleId), eq(dnsInternalRecords.host, record.host), ), ) .run(); } /** * Wrap a dns_internal interface so A-record register/delete calls are * mirrored into the ledger. Only A records are tracked — they're the * host→IP mapping the natIp drift check reasons about; CNAMEs/others pass * through unrecorded. A throw from the underlying call propagates before * any ledger write, so only a confirmed register/delete earns a row change. */ export function withDnsInternalLedger( iface: DnsInternalCapability, ctx: { db: DbClient; providerModuleId: string; consumerModuleId: string }, ): DnsInternalCapability { const isA = (request: DnsRecordRequest) => request.type.toUpperCase() === 'A'; // Source-based split-horizon (ISS-0156): after the ledger changes, re-materialize // the provider's view config from the COMPLETE desired set. The provider's // reconcileViews is the single writer; driving it from the ledger here (which // both the live public_web path and the deploy-time backfill flow through) // keeps live and reconcile in agreement. No-op for providers without views. const reconcileViews = async (): Promise => { if (typeof iface.reconcileViews !== 'function') return; await iface.reconcileViews(listViewOverrides(ctx.db, ctx.providerModuleId)); }; return { ...iface, async registerRecord(request: DnsRecordRequest): Promise { await iface.registerRecord(request); if (isA(request)) { recordDnsInternalRecord(ctx.db, { providerModuleId: ctx.providerModuleId, consumerModuleId: ctx.consumerModuleId, host: request.host, ip: request.value, zoneRoutableIp: request.zoneRoutableValue ?? null, }); // Only a fronted record (one carrying a zone-routable IP) changes views. if (request.zoneRoutableValue) await reconcileViews(); } }, async deleteRecord(request: DnsRecordRequest): Promise { await iface.deleteRecord(request); if (isA(request)) { removeDnsInternalRecord(ctx.db, { providerModuleId: ctx.providerModuleId, host: request.host, }); // The removed host may have been a fronted override — recompute the set. await reconcileViews(); } }, }; }