Files

323 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 <ip>`).
### 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: "<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"`).
---
```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: <error>, 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="<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:
```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.