/** * Read the upstream DNS resolvers this box uses. * * This sits beside `network-discovery.ts` and for the same reason: celilo * records facts about the management box itself, and a module is not the right * place to read them. The network half moved first * (openspec/changes/networks-are-declared-not-written/specs/network-declaration/spec.md), * the fleet key followed (openspec/changes/hook-process-boundary, design D9b), * and DNS is the last of the three. It came from * `modules/celilo-mgmt/scripts/discovery.ts`, whose hook could only reach * celilo by spawning the CLI — which a jailed hook cannot do at all * (celilo#1225). * * Reading it here is not merely tidier, it is the only place it can be * correct. celilo-mgmt is never deployed to a remote box (ruled 2026-09-02), * so the host whose resolvers we want is always the host celilo runs on. A * hook reading `/etc/resolv.conf` reads that same file, one process further * out, for no benefit. * * Pure parsers are separated from the IO wrapper so they test without a host * (Rule 2.3). */ import { existsSync, readFileSync } from 'node:fs'; /** * Where a host publishes its real upstream resolvers, most trustworthy first. * * systemd-resolved's file comes first deliberately: on such a host * `/etc/resolv.conf` names the local 127.0.0.53 stub rather than the upstreams * behind it, so reading only the second file discovers a loopback address and * writes it into system config as the fleet's DNS. */ const RESOLVER_FILES = ['/run/systemd/resolve/resolv.conf', '/etc/resolv.conf'] as const; /** Where we land when the host offers no usable upstream of its own. */ const PUBLIC_FALLBACK = '1.1.1.1'; export interface DnsServers { primary: string; fallback: string; } /** Extract `nameserver` IPs from resolv.conf-format text, in file order. */ export function parseNameservers(resolvConf: string): string[] { return resolvConf .split('\n') .map((l) => l.trim()) .filter((l) => l.startsWith('nameserver ')) .map((l) => l.split(/\s+/)[1]) .filter(Boolean); } /** * Choose primary and fallback from discovered nameservers. * * Loopback entries are dropped rather than used. A stub resolver's address is * a real answer to "what does this host query" and the wrong answer to "what * should the fleet query", because nothing else on the network can reach it. */ export function chooseDns(nameservers: string[]): DnsServers { const upstream = nameservers.filter((ns) => !ns.startsWith('127.') && ns !== '::1'); return { primary: upstream[0] ?? PUBLIC_FALLBACK, fallback: upstream[1] ?? PUBLIC_FALLBACK, }; } /** Reads a resolver file, or returns null when it is not present. */ export type ResolverFileReader = (path: string) => string | null; const readResolverFile: ResolverFileReader = (path) => existsSync(path) ? readFileSync(path, 'utf-8') : null; /** Options for {@link discoverDns}. */ export interface DiscoverDnsOptions { /** * Addresses of resolvers celilo itself deployed, as advertised in the * capability data of providers that declare the `fleet_resolver` marker * (`server.ip`, `server.internal_ip`). Entries may carry a CIDR suffix. */ fleetResolverIps?: readonly string[]; } /** Strip a CIDR suffix: `192.168.0.151/24` → `192.168.0.151`. */ export function bareIp(address: string): string { return address.split('/')[0]; } /** * Drop nameservers that are resolvers celilo itself installed. * * A deployed dns_internal provider's base-module aspect rewrites this box's * `/etc/resolv.conf` to name that provider. Reading the file back afterwards * adopts the fleet's own resolver as the fleet's upstream and writes it into * `dns.primary` / `dns.fallback` (celilo#1239), where it reaches every LXC's * permanent birth nameserver line. A resolver answering the fleet is not an * upstream for the fleet; refusing it here is what keeps discovery honest * once the aspect has run. */ export function excludeFleetResolvers( nameservers: string[], fleetResolverIps: readonly string[] = [], ): string[] { if (fleetResolverIps.length === 0) return nameservers; const fleet = new Set(fleetResolverIps.map(bareIp)); return nameservers.filter((ns) => !fleet.has(ns)); } /** * Read this box's upstream resolvers. * * Loopback stubs and resolvers celilo itself installed ({@link DiscoverDnsOptions.fleetResolverIps}) * are refused, so a file holding only refused entries falls through to the * next one. `/etc/resolv.conf` is the last resort and whatever survives the * refusal is accepted, including the public fallback, because there is * nothing further to consult. */ export function discoverDns( read: ResolverFileReader = readResolverFile, options: DiscoverDnsOptions = {}, ): DnsServers { for (const path of RESOLVER_FILES) { const contents = read(path); if (contents === null) continue; const servers = chooseDns( excludeFleetResolvers(parseNameservers(contents), options.fleetResolverIps), ); if (servers.primary !== PUBLIC_FALLBACK || path === '/etc/resolv.conf') { return servers; } } return { primary: PUBLIC_FALLBACK, fallback: PUBLIC_FALLBACK }; }