Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user