# Package Contracts: dns-helper (002 — Server Resolution Modes) **Feature Branch**: `002-server-resolution-modes` **Date**: 2026-03-04 **Extends**: [001 Package Contracts](../../001-safety-reliability-refactor/contracts/packages.md) ## Changes from 001 1. **`resolver` package**: New types and functions for multi-mode resolution, parallel fan-out, authoritative queries. 2. **`platform` package**: New `NetworkDiscoverer` interface and cross-platform implementations. 3. **`hostfile` package**: Unchanged. 4. **`lockfile` package**: Unchanged. --- ## Package: `resolver` (extended) ### Existing Interface (unchanged) ```go // Resolver performs DNS lookups against a specific server. type Resolver interface { LookupIP(hostname, server string) ([]string, error) } ``` The existing `DNSResolver` and `LookupIP` method are unchanged. They continue to serve the explicit IP mode (`-server `). ### New Types ```go // ServerMode represents the resolution strategy. type ServerMode struct { Mode string // "default", "local", "gateway", "explicit" ExplicitAddr string // IP or IP:port when Mode is "explicit" } // QueryConfig holds per-invocation DNS query settings. type QueryConfig struct { Timeout time.Duration // Per-query timeout (default 3s) Verbose bool // Emit diagnostic trace to stderr } // NSResult holds the outcome of a single NS query during fan-out. type NSResult struct { LabelLevel string // Domain queried (e.g., "example.com") Resolver string // Resolver IP:port that was queried NSRecords []string // NS hostnames returned; nil if no NS found CNAMETarget string // Non-empty if CNAME returned instead of NS Err error // Non-nil on query failure (timeout, network error) } // AuthoritativeNS holds the selected most-specific nameserver delegation. type AuthoritativeNS struct { Zone string // Label level (e.g., "sub.example.com") Nameservers []string // Deduplicated NS hostnames for this zone } // SplitHorizonResult holds the outcome of the Stage 2.5 cross-check. type SplitHorizonResult struct { AuthoritativeIPs []string // IPs from Stage 2 authoritative query AuthoritativeSource string // NS hostname that provided the authoritative answer LocalIPs []string // IPs from local resolver (nil if NXDOMAIN/timeout/absent) LocalSource string // Local resolver IP that responded HasConflict bool // True if LocalIPs non-nil and differs from AuthoritativeIPs } ``` ### New Functions ```go // ParseServerFlag parses the -server flag value into a ServerMode. // Returns a validation error for invalid IP:port combinations. func ParseServerFlag(value string) (ServerMode, error) ``` **Contract**: - Empty string → `ServerMode{Mode: "default"}`. - `"local"` (case-insensitive) → `ServerMode{Mode: "local"}`. - `"gateway"` (case-insensitive) → `ServerMode{Mode: "gateway"}`. - Valid IP → `ServerMode{Mode: "explicit", ExplicitAddr: ""}`. - Valid IP:port → `ServerMode{Mode: "explicit", ExplicitAddr: ":"}`. Port must be 1–65535. - Invalid input → error (e.g., `"10.0.0.53:0"` → `"port must be between 1 and 65535"`). --- ```go // Resolve performs DNS resolution using the specified mode and configuration. // Returns a list of IPv4 addresses for the hostname. // discoverer provides local network info (DNS servers, gateway). func Resolve(hostname string, mode ServerMode, config QueryConfig, discoverer platform.NetworkDiscoverer) ([]string, error) ``` **Contract**: - **Default mode**: Calls `discoverer.Discover()` to get local resolvers, builds pool with bootstrap set, runs Stage 1 → 2 → 2.5 → 3 pipeline, returns IPs. Stage 2.5 cross-checks the authoritative answer against local resolvers; if they disagree, returns a conflict error with both IP sets. - **Local mode**: Calls `discoverer.Discover()` for local resolvers only, queries in priority order, returns IPs. Error if no local resolvers found or all fail. No fallback to public. No split-horizon check (user explicitly chose local). - **Gateway mode**: Calls `discoverer.Discover()` for gateway IP, sends A query to gateway, returns IPs. Error if no gateway or gateway doesn't respond. No split-horizon check. - **Explicit mode**: Calls existing `LookupIP(hostname, mode.ExplicitAddr)`, returns IPs. Behavior unchanged from 001. No split-horizon check. - All modes: CNAME chain depth limited to 10. Verbose output to stderr if `config.Verbose` is true. - All modes: Per-query timeout from `config.Timeout`. - On failure with private TLD hostname: error message includes hint (FR-025). --- ```go // ExtractLabelLevels returns all queryable domain levels from a hostname, // from most-specific to least-specific, excluding single-label TLDs. // Example: "www.example.com" → ["www.example.com", "example.com"] func ExtractLabelLevels(hostname string) []string ``` **Contract**: - Input is normalized: trailing dot stripped, lowercased. - Returns nil for single-label hostnames (e.g., `"localhost"`). - Returns one entry for two-label hostnames (e.g., `"example.com"` → `["example.com"]`). - No public suffix list — all label levels with ≥2 labels are included. --- ```go // BuildResolverPool constructs the resolver pool for the given mode. // For default mode: local resolvers + bootstrap set, deduplicated. // For local mode: local resolvers only. // For gateway mode: [gateway IP]. // For explicit mode: [explicit address]. func BuildResolverPool(mode ServerMode, info platform.NetworkInfo) []string ``` **Contract**: - Default: concatenates `info.DNSServers` + `BootstrapResolvers`, deduplicates preserving order. - Local: returns `info.DNSServers`. May be empty (caller handles). - Gateway: returns `[]string{info.Gateway}`. May be `[""]` if gateway empty (caller handles). - Explicit: returns `[]string{mode.ExplicitAddr}`. --- ```go // ParallelNSFanOut queries NS records for all label levels across all resolvers // simultaneously. Returns one NSResult per (resolver × label level) combination. func ParallelNSFanOut(ctx context.Context, resolvers []string, labelLevels []string, timeout time.Duration) []NSResult ``` **Contract**: - Launches `len(resolvers) × len(labelLevels)` goroutines. - Each goroutine sends one NS query over UDP and writes one `NSResult` to a buffered channel. - Collector reads exactly `N×M` results. No goroutine leak. - Per-query timeout derived from `ctx` + `timeout` parameter. - NXDOMAIN responses produce `NSResult{Err: nil, NSRecords: nil}` (not an error). - Connection failures produce `NSResult{Err: , NSRecords: nil}`. --- ```go // SelectAuthoritativeNS picks the most-specific NS delegation from fan-out results. // Returns nil if no NS records were found at any level. func SelectAuthoritativeNS(results []NSResult) *AuthoritativeNS ``` **Contract**: - Groups results by `LabelLevel`. - Merges and deduplicates `NSRecords` from all resolvers for each level. - Selects the level with the most labels (most specific). - Returns nil if no level has any NS records. --- ```go // QueryAuthoritative sends an A query to an authoritative nameserver with // recursion disabled. Tries each NS in order if one is unreachable. // Returns IPs, or a CNAME target if the answer is a CNAME. func QueryAuthoritative(ctx context.Context, ns *AuthoritativeNS, hostname string, resolvers []string, timeout time.Duration) (ips []string, cnameTarget string, err error) ``` **Contract**: - Resolves the first NS hostname to an IP using the resolver pool. - Sends A query with `RecursionDesired: false`. - If A records returned: returns IPs. - If CNAME returned: returns empty IPs + CNAME target. - If NS unreachable: tries next NS in `ns.Nameservers`. If all fail: returns error. - Checks `Header.Authoritative` flag for verbose logging (not a hard requirement). --- ```go // ParallelAFallback sends A queries for hostname to all resolvers simultaneously. // Returns the first successful A record result. Ignores NXDOMAIN and failures. func ParallelAFallback(ctx context.Context, resolvers []string, hostname string, timeout time.Duration) ([]string, error) ``` **Contract**: - Launches `len(resolvers)` goroutines, each sending an A query. - Returns IPs from the first successful response. - NXDOMAIN and connection failures are ignored. - If all resolvers fail or return NXDOMAIN: returns error. --- ```go // CheckSplitHorizon queries only the local resolvers for the same hostname // and compares the result against the authoritative IPs from Stage 2. // Returns a SplitHorizonResult indicating whether a conflict was detected. // Only called in default mode when local resolvers are available. func CheckSplitHorizon(ctx context.Context, localResolvers []string, hostname string, authoritativeIPs []string, authoritativeSource string, timeout time.Duration) SplitHorizonResult ``` **Contract**: - Queries each local resolver in order with a standard A query for the hostname. - Uses the first successful response from any local resolver. - Compares the local IP set against `authoritativeIPs` (sorted, deduplicated set comparison). - If local returns different IPs: `HasConflict: true`. - If local returns same IPs, NXDOMAIN, or all fail: `HasConflict: false`. - Should be fired in parallel with Stage 2 (or immediately after Stage 1) to avoid adding latency. - Per-query timeout from the `timeout` parameter. --- ### Bootstrap Resolver Set ```go // BootstrapResolvers is the hardcoded set of reliable public DNS resolvers (FR-007). var BootstrapResolvers = []string{ "1.1.1.1", // Cloudflare "8.8.8.8", // Google "1.0.0.1", // Cloudflare secondary "8.8.4.4", // Google secondary "9.9.9.9", // Quad9 "208.67.222.222", // OpenDNS/Cisco } ``` ### Private TLD Set ```go // PrivateTLDs is the set of well-known private TLDs for error message hints (FR-024). var PrivateTLDs = map[string]bool{ "local": true, "internal": true, "lan": true, "home": true, "corp": true, "private": true, } ``` --- ## Package: `platform` (extended) ### Existing Interface (unchanged) ```go func GetHostsFilePath() (string, error) ``` ### New Types ```go // NetworkInfo holds discovered local network configuration. type NetworkInfo struct { DNSServers []string // Ordered DNS server IPs from default-route adapter Gateway string // Default gateway IP (may be empty) Interface string // Adapter name holding the default route (informational) } ``` ### New Interface ```go // NetworkDiscoverer discovers local network configuration. type NetworkDiscoverer interface { Discover() (NetworkInfo, error) } ``` ### Platform Implementations Each platform file (`platform_windows.go`, `platform_linux.go`, `platform_darwin.go`) adds a `Discover()` method on a platform-specific struct implementing `NetworkDiscoverer`. | Platform | Commands Used | Key Parsing | |----------|--------------|-------------| | Windows | `netsh interface ipv4 show route`, `netsh interface ipv4 show interfaces`, `netsh interface ipv4 show dnsservers name=""` | IPv4 regex on route/DNS output; index→name mapping | | Linux | `ip route show default`, `/etc/resolv.conf`, (conditional) `resolvectl status ` | `via`/`dev` keywords; `nameserver` lines; stub-resolver detection | | macOS | `route -n get default`, `scutil --dns` | `gateway:`/`interface:` lines; resolver block parsing with `if_index` matching | ### Behavior Contract — Discover() - Returns `NetworkInfo` with whatever was discovered. All fields may be empty. - Empty `DNSServers` is **not** an error — it means no local resolvers were found. The caller (resolver pool construction) handles this per FR-009. - Empty `Gateway` is **not** an error — it means no default route exists. - Returns an `error` only for unexpected failures (e.g., OS command execution error). Even then, `NetworkInfo` may have partial results. - **Must NOT** call `os.Exit()` or any process-terminating function. - All DNS server IPs and gateway IPs are validated with `net.ParseIP()` before inclusion. ### Testability A `FakeNetworkDiscoverer` struct is provided for unit tests: ```go // FakeNetworkDiscoverer returns predetermined NetworkInfo for testing. type FakeNetworkDiscoverer struct { Info NetworkInfo Err error } func (f *FakeNetworkDiscoverer) Discover() (NetworkInfo, error) { return f.Info, f.Err } ``` This enables deterministic testing of resolver pool construction and mode dispatch without running real OS commands. --- ## Package: `hostfile` (unchanged) No changes from 001. The hosts file read/parse/write pipeline is unaffected by this feature. --- ## Package: `lockfile` (unchanged) No changes from 001.