Files
dnshelper/specs/002-server-resolution-modes/contracts/packages.md
T

12 KiB
Raw Blame History

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

  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)

// 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 165535.
  • 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.Verbose is 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 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: <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 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.

// 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).

// 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 timeout parameter.

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 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:

// 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.