12 KiB
12 KiB
Package Contracts: dns-helper (002 — Server Resolution Modes)
Feature Branch: 002-server-resolution-modes
Date: 2026-03-04
Extends: 001 Package Contracts
Changes from 001
resolverpackage: New types and functions for multi-mode resolution, parallel fan-out, authoritative queries.platformpackage: NewNetworkDiscovererinterface and cross-platform implementations.hostfilepackage: Unchanged.lockfilepackage: Unchanged.
Package: resolver (extended)
Existing Interface (unchanged)
// 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 <ip>).
New Types
// 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
// 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: "<ip>"}. - Valid IP:port →
ServerMode{Mode: "explicit", ExplicitAddr: "<ip>:<port>"}. Port must be 1–65535. - Invalid input → error (e.g.,
"10.0.0.53:0"→"port must be between 1 and 65535").
// 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.Verboseis true. - All modes: Per-query timeout from
config.Timeout. - On failure with private TLD hostname: error message includes hint (FR-025).
// 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.
// 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}.
// 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
NSResultto a buffered channel. - Collector reads exactly
N×Mresults. No goroutine leak. - Per-query timeout derived from
ctx+timeoutparameter. - NXDOMAIN responses produce
NSResult{Err: nil, NSRecords: nil}(not an error). - Connection failures produce
NSResult{Err: <error>, NSRecords: nil}.
// 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
NSRecordsfrom all resolvers for each level. - Selects the level with the most labels (most specific).
- Returns nil if no level has any NS records.
// 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.Authoritativeflag for verbose logging (not a hard requirement).
// 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.
// 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
timeoutparameter.
Bootstrap Resolver Set
// 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
// 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)
func GetHostsFilePath() (string, error)
New Types
// 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
// 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="<name>" |
IPv4 regex on route/DNS output; index→name mapping |
| Linux | ip route show default, /etc/resolv.conf, (conditional) resolvectl status <iface> |
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
NetworkInfowith whatever was discovered. All fields may be empty. - Empty
DNSServersis not an error — it means no local resolvers were found. The caller (resolver pool construction) handles this per FR-009. - Empty
Gatewayis not an error — it means no default route exists. - Returns an
erroronly for unexpected failures (e.g., OS command execution error). Even then,NetworkInfomay 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:
// 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.