Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
# Specification Quality Checklist: Smart DNS Server Resolution Modes
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-03-03
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- All items passed initial validation (2026-03-03)
|
||||
- Re-validated after merging auto+default modes (2026-03-04) — all items still pass
|
||||
- DNS domain terminology (NXDOMAIN, NS, A record, CNAME, TLD) retained as appropriate for the target audience (network engineers)
|
||||
@@ -0,0 +1,206 @@
|
||||
# CLI Contract: dns-helper (002 — Server Resolution Modes)
|
||||
|
||||
**Feature Branch**: `002-server-resolution-modes`
|
||||
**Date**: 2026-03-04
|
||||
**Extends**: [001 CLI Contract](../../001-safety-reliability-refactor/contracts/cli.md)
|
||||
|
||||
## Changes from 001
|
||||
|
||||
1. The `-server` flag on `add` becomes **optional** (was required).
|
||||
2. The `-server` flag gains keyword values: `local`, `gateway`.
|
||||
3. New flags: `-timeout`, `-verbose` on every command that performs DNS resolution.
|
||||
4. Error messages gain context-aware hints for private TLDs.
|
||||
5. Split-horizon conflict detection: in default mode, cross-checks authoritative answer against local resolvers.
|
||||
|
||||
---
|
||||
|
||||
## Subcommand: `add` (aliases: `a`)
|
||||
|
||||
### Synopsis
|
||||
|
||||
```
|
||||
dns-helper add -host <hostnames> [-server <mode>] [-timeout <seconds>] [-verbose]
|
||||
```
|
||||
|
||||
### Flags
|
||||
|
||||
| Flag | Required | Type | Default | Description |
|
||||
|------|----------|------|---------|-------------|
|
||||
| `-host` | Yes | string | — | Comma-separated list of hostnames to resolve and add |
|
||||
| `-server` | No | string | (smart default) | Resolution mode: omit for smart default, `local`, `gateway`, or IP/IP:port |
|
||||
| `-timeout` | No | int | 3 | Per-query DNS timeout in seconds |
|
||||
| `-verbose` | No | bool | false | Emit per-stage resolution trace to stderr |
|
||||
|
||||
### `-server` Flag Values
|
||||
|
||||
| Value | Behavior |
|
||||
|-------|----------|
|
||||
| *(omitted)* | Smart default: parallel NS fan-out across local + public resolvers, authoritative query, parallel A fallback. See FR-002. |
|
||||
| `local` | Query only locally configured DNS resolvers in priority order. No public fallback. See FR-003. |
|
||||
| `gateway` | Query the default gateway IP as DNS server. No fallback. See FR-004. |
|
||||
| `<ip>` | Query the specified IP on port 53. Existing behavior, unchanged. See FR-005. |
|
||||
| `<ip>:<port>` | Query the specified IP on the given port. Port must be 1–65535. See FR-005. |
|
||||
|
||||
### `-server` Validation
|
||||
|
||||
- `<ip>:<port>` where port is 0 or >65535: **Immediate usage error** (no DNS queries issued).
|
||||
- `<ip>` where IP is not a valid IPv4 address and not a recognized keyword: **Immediate usage error**.
|
||||
- Recognized keywords are case-insensitive: `local`, `LOCAL`, `Local` all accepted.
|
||||
|
||||
### `-timeout` Validation
|
||||
|
||||
- Must be a positive integer. Zero or negative: **Immediate usage error**.
|
||||
- Applied to every individual DNS query (NS fan-out, authoritative, fallback, local, gateway).
|
||||
|
||||
### Behavior — Smart Default (no `-server`)
|
||||
|
||||
1. Discover local DNS resolvers from OS network configuration.
|
||||
2. Build resolver pool: local resolvers + hardcoded bootstrap set (deduplicated).
|
||||
3. Extract all label levels from hostname (e.g., `www.example.com` → `["www.example.com", "example.com"]`).
|
||||
4. **Stage 1**: Parallel NS fan-out — query NS for every label level across every resolver in the pool simultaneously.
|
||||
5. Select the most-specific NS delegation (longest label level with NS records).
|
||||
6. **Stage 2**: Resolve one of the NS hostnames to an IP, then query that NS directly for the A record with recursion disabled.
|
||||
7. If Stage 2 returns a CNAME, restart from Stage 1 for the CNAME target domain (max 10 hops).
|
||||
8. **Stage 2.5**: If Stage 2 returns A records and local resolvers are available, cross-check by querying local resolvers for the same hostname. If local resolvers return a different IP → conflict error (neither IP written). If same IP, NXDOMAIN, or failure → proceed normally.
|
||||
9. If Stage 1 returns no NS records at any level: **Stage 3** — parallel A query across all resolvers, take first success.
|
||||
10. If all stages fail, report error with private TLD hint if applicable.
|
||||
|
||||
### Behavior — Local Mode (`-server local`)
|
||||
|
||||
1. Discover local DNS resolvers from OS network configuration.
|
||||
2. Query each resolver in priority order with standard A query.
|
||||
3. Return first successful result.
|
||||
4. If all fail: error. **No fallback to public resolvers** (FR-026).
|
||||
|
||||
### Behavior — Gateway Mode (`-server gateway`)
|
||||
|
||||
1. Discover default gateway IP from OS network configuration.
|
||||
2. Send DNS query to gateway.
|
||||
3. If gateway doesn't respond: error. **No fallback** (FR-027).
|
||||
|
||||
### Output — Verbose Mode (stderr only)
|
||||
|
||||
When `-verbose` is provided, emit to **stderr** (FR-030/FR-031):
|
||||
|
||||
```
|
||||
[dns] Resolver pool: [10.26.1.1, 1.1.1.1, 8.8.8.8, 1.0.0.1, 8.8.4.4, 9.9.9.9, 208.67.222.222]
|
||||
[dns] Stage 1: NS fan-out for www.example.com (2 levels × 7 resolvers = 14 queries)
|
||||
[dns] example.com NS: ns1.example.com., ns2.example.com. (via 8.8.8.8, 1.1.1.1)
|
||||
[dns] www.example.com NS: (none)
|
||||
[dns] Selected authority: example.com → ns1.example.com.
|
||||
[dns] Stage 2: Querying ns1.example.com. (93.184.216.34) for www.example.com A (RD=0)
|
||||
[dns] Result: 93.184.216.34
|
||||
```
|
||||
|
||||
Verbose output for fallback:
|
||||
|
||||
```
|
||||
[dns] Stage 1: No NS records found at any level
|
||||
[dns] Stage 3: Parallel A fallback for myservice.client.local (7 resolvers)
|
||||
[dns] 10.26.1.1 → 10.0.5.100
|
||||
[dns] 1.1.1.1 → NXDOMAIN
|
||||
[dns] 8.8.8.8 → NXDOMAIN
|
||||
[dns] Result: 10.0.5.100 (via 10.26.1.1)
|
||||
```
|
||||
|
||||
Verbose output for split-horizon cross-check:
|
||||
|
||||
```
|
||||
[dns] Stage 2.5: Split-horizon cross-check against local resolvers [10.26.1.1]
|
||||
[dns] 10.26.1.1 → 10.0.5.100 (differs from authoritative 203.0.113.50)
|
||||
[dns] CONFLICT: authoritative and local resolvers disagree
|
||||
```
|
||||
|
||||
Verbose output when cross-check passes:
|
||||
|
||||
```
|
||||
[dns] Stage 2.5: Split-horizon cross-check against local resolvers [10.26.1.1]
|
||||
[dns] 10.26.1.1 → 93.184.216.34 (matches authoritative)
|
||||
[dns] No conflict detected
|
||||
```
|
||||
|
||||
### Output — Error Messages
|
||||
|
||||
**Private TLD with total failure** (FR-025):
|
||||
|
||||
```
|
||||
Error: failed to resolve myservice.client.local: no DNS server could resolve this hostname
|
||||
Hint: the hostname uses a private TLD (.local). Try specifying an internal DNS server:
|
||||
dns-helper add -host myservice.client.local -server <internal-dns-ip>
|
||||
```
|
||||
|
||||
**Local mode, all resolvers unreachable** (FR-026):
|
||||
|
||||
```
|
||||
Error: failed to resolve myhost.example.com using local resolvers: all local DNS servers are unreachable
|
||||
Local resolvers tried: 10.26.1.1, 10.26.1.2
|
||||
```
|
||||
|
||||
**Gateway mode, no response** (FR-027):
|
||||
|
||||
```
|
||||
Error: failed to resolve myhost.example.com using gateway: gateway 192.168.1.1 did not respond to DNS query
|
||||
```
|
||||
|
||||
**CNAME loop** (FR-019):
|
||||
|
||||
```
|
||||
Error: CNAME chain depth exceeded for www.example.com (max 10 hops): probable CNAME loop or misconfigured zone
|
||||
```
|
||||
|
||||
**Invalid port**:
|
||||
|
||||
```
|
||||
Error: invalid -server value "10.0.0.53:0": port must be between 1 and 65535
|
||||
```
|
||||
|
||||
**Split-horizon conflict** (FR-034/FR-035):
|
||||
|
||||
```
|
||||
Error: conflicting DNS answers for app.acme.com
|
||||
Authoritative (ns1.acme.com): 203.0.113.50
|
||||
Local resolver (10.26.1.1): 10.0.5.100
|
||||
The hostname resolves to different IPs depending on the DNS source.
|
||||
Use -server local to trust your internal DNS, or -server <ip> to choose explicitly.
|
||||
```
|
||||
|
||||
### Exit Codes
|
||||
|
||||
Unchanged from 001:
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| 0 | All hostnames resolved and written successfully |
|
||||
| 1 | Any error: validation failure, DNS failure, file write failure, partial resolution |
|
||||
|
||||
---
|
||||
|
||||
## Subcommand: `delete`
|
||||
|
||||
No changes from 001. The `delete` subcommand does not perform DNS resolution and is unaffected by this feature.
|
||||
|
||||
---
|
||||
|
||||
## Usage Help
|
||||
|
||||
Updated to reflect new flag options:
|
||||
|
||||
```
|
||||
This utility will update the local hosts file with DNS entries obtained by the specified DNS server.
|
||||
Usage: dns-helper [add|delete] -host hostname [-server dns.example.com] [-timeout seconds] [-verbose]
|
||||
Example:
|
||||
dns-helper add -host xyz.acme.com -server dns.example.com
|
||||
This will use dns.example.com to find and add all IP addresses for xyz.acme.com to the local hosts file.
|
||||
dns-helper add -host xyz.acme.com
|
||||
This will use smart resolution to find the authoritative IP for xyz.acme.com.
|
||||
dns-helper add -host internal.corp -server local
|
||||
This will use only locally configured DNS servers to resolve internal.corp.
|
||||
dns-helper add -host www.example.com -server gateway
|
||||
This will use the default gateway as the DNS server.
|
||||
dns-helper delete -host hostname.example.com
|
||||
This will delete all entries for hostname.example.com from the local hosts file.
|
||||
dns-helper delete all
|
||||
This will delete all entries from the local hosts file that were added by this utility.
|
||||
Note: Adding a hostname will first remove all entries in the hosts file that match the same hostname.
|
||||
This utility will only remove entries from the hosts file that it added.
|
||||
```
|
||||
@@ -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.
|
||||
@@ -0,0 +1,300 @@
|
||||
# Data Model: Smart DNS Server Resolution Modes
|
||||
|
||||
**Feature Branch**: `002-server-resolution-modes`
|
||||
**Date**: 2026-03-04
|
||||
|
||||
---
|
||||
|
||||
## Entity Overview
|
||||
|
||||
```
|
||||
┌─────────────┐ selects ┌──────────────────┐
|
||||
│ ServerMode │─────────────────▸│ ResolverPool │
|
||||
└─────────────┘ │ (bootstrap + │
|
||||
│ │ local resolvers) │
|
||||
│ └────────┬───────────┘
|
||||
│ │ feeds
|
||||
│ configures ▼
|
||||
│ ┌──────────────────┐
|
||||
▼ │ ParallelNSFanOut │
|
||||
┌─────────────┐ │ (Stage 1) │
|
||||
│ QueryConfig │ └────────┬───────────┘
|
||||
│ (timeout, │ │ produces
|
||||
│ verbose) │ ▼
|
||||
└─────────────┘ ┌──────────────────┐
|
||||
│ NSResult[] │
|
||||
└────────┬───────────┘
|
||||
│ selects best
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ AuthoritativeNS │──▸ Stage 2: Query with RD=0
|
||||
└────────┬───────────┘
|
||||
│ may return
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ CNAME redirect │──▸ Restart from Stage 1
|
||||
└──────────────────┘
|
||||
│ or
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ A record IPs │
|
||||
└────────┬───────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ SplitHorizon │──▸ Stage 2.5: Cross-check
|
||||
│ CrossCheck │ local resolvers
|
||||
└────────┬───────────┘
|
||||
│
|
||||
┌────────┴───────────┐
|
||||
│ [IPs match or │──▸ Final result
|
||||
│ no local answer]│
|
||||
└────────────────────┘
|
||||
┌────────────────────┐
|
||||
│ [IPs differ] │──▸ CONFLICT ERROR
|
||||
└────────────────────┘
|
||||
|
||||
(if Stage 1 returns no NS)
|
||||
│
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ ParallelAFallback│──▸ Stage 3: First success wins
|
||||
│ (Stage 3) │
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Entities
|
||||
|
||||
### ServerMode
|
||||
|
||||
Represents the resolution strategy selected by the user via the `-server` flag.
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| Mode | `string` | One of: `"default"`, `"local"`, `"gateway"`, `"explicit"` |
|
||||
| ExplicitAddr | `string` | IP or IP:port when Mode is `"explicit"`. Empty otherwise. |
|
||||
|
||||
**Derivation rules**:
|
||||
- `-server` omitted → `Mode: "default"`, `ExplicitAddr: ""`
|
||||
- `-server local` → `Mode: "local"`, `ExplicitAddr: ""`
|
||||
- `-server gateway` → `Mode: "gateway"`, `ExplicitAddr: ""`
|
||||
- `-server 10.0.0.53` → `Mode: "explicit"`, `ExplicitAddr: "10.0.0.53"`
|
||||
- `-server 10.0.0.53:5353` → `Mode: "explicit"`, `ExplicitAddr: "10.0.0.53:5353"`
|
||||
|
||||
**Validation**:
|
||||
- When Mode is `"explicit"`, `ExplicitAddr` must be a valid IP or IP:port. Port (if provided) must be 1–65535.
|
||||
- Invalid `-server` values produce an immediate usage error before any DNS queries.
|
||||
|
||||
---
|
||||
|
||||
### QueryConfig
|
||||
|
||||
Global configuration for DNS queries derived from CLI flags.
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| Timeout | `time.Duration` | 3s | Per-query DNS timeout (from `-timeout` flag). Applied to every individual DNS query. |
|
||||
| Verbose | `bool` | false | When true, emit per-stage diagnostic trace to stderr (from `-verbose` flag). |
|
||||
|
||||
**Validation**:
|
||||
- `Timeout` must be a positive integer (seconds). Zero or negative values produce a usage error.
|
||||
|
||||
---
|
||||
|
||||
### NetworkInfo
|
||||
|
||||
Discovered local network configuration from the OS. Returned by platform-specific `Discover()` implementations.
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| DNSServers | `[]string` | Ordered list of DNS server IPs from the default-route adapter. May be empty. |
|
||||
| Gateway | `string` | Default gateway IP. May be empty if no default route exists. |
|
||||
| Interface | `string` | Name of the adapter holding the default route. Informational (used for verbose output). |
|
||||
|
||||
**Invariants**:
|
||||
- All entries in `DNSServers` are valid IPv4 addresses (validated by `net.ParseIP`).
|
||||
- `Gateway` is either empty or a valid IPv4 address.
|
||||
- Empty `DNSServers` is not an error — FR-009 handles gracefully.
|
||||
|
||||
---
|
||||
|
||||
### ResolverPool
|
||||
|
||||
The combined set of resolvers used for parallel fan-out queries in default mode.
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| Resolvers | `[]string` | Ordered list of resolver IPs (local resolvers first, then bootstrap set). |
|
||||
|
||||
**Construction rules** (by ServerMode):
|
||||
- `"default"`: `Resolvers = NetworkInfo.DNSServers + BootstrapResolvers` (deduplicated, order preserved)
|
||||
- `"local"`: `Resolvers = NetworkInfo.DNSServers` (no bootstrap)
|
||||
- `"gateway"`: `Resolvers = [NetworkInfo.Gateway]` (single entry)
|
||||
- `"explicit"`: `Resolvers = [ServerMode.ExplicitAddr]` (single entry)
|
||||
|
||||
**Bootstrap Resolver Set** (hardcoded, FR-007):
|
||||
|
||||
| Order | IP | Provider |
|
||||
|-------|----|----------|
|
||||
| 1 | 1.1.1.1 | Cloudflare |
|
||||
| 2 | 8.8.8.8 | Google |
|
||||
| 3 | 1.0.0.1 | Cloudflare secondary |
|
||||
| 4 | 8.8.4.4 | Google secondary |
|
||||
| 5 | 9.9.9.9 | Quad9 |
|
||||
| 6 | 208.67.222.222 | OpenDNS/Cisco |
|
||||
|
||||
---
|
||||
|
||||
### NSResult
|
||||
|
||||
The result of a single NS query during Stage 1 fan-out. One per (resolver × label level) combination.
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| LabelLevel | `string` | The domain queried for NS records (e.g., `"example.com"`) |
|
||||
| Resolver | `string` | The resolver IP:port that was queried |
|
||||
| NSRecords | `[]string` | NS hostnames returned (e.g., `["ns1.example.com.", "ns2.example.com."]`). Nil if query failed or returned NXDOMAIN. |
|
||||
| CNAMETarget | `string` | Non-empty if a CNAME was returned instead of NS records. |
|
||||
| Err | `error` | Non-nil if query failed (timeout, connection refused, parse error). Nil if NXDOMAIN (that's a valid "no NS" response). |
|
||||
|
||||
**Semantics**:
|
||||
- `NSRecords != nil && Err == nil`: Successful NS response — this label level has delegation.
|
||||
- `NSRecords == nil && Err == nil`: NXDOMAIN or no NS records — not an error, just no delegation at this level.
|
||||
- `Err != nil`: Query failure (timeout, network error). The resolver was unreachable.
|
||||
|
||||
---
|
||||
|
||||
### AuthoritativeNS
|
||||
|
||||
The nameserver selected as the most-specific authority for the target hostname.
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| Zone | `string` | The label level with the most-specific NS delegation (e.g., `"sub.example.com"`) |
|
||||
| Nameservers | `[]string` | NS hostnames for this zone (deduplicated across all resolvers that returned them) |
|
||||
|
||||
**Selection algorithm** (FR-015):
|
||||
1. Group all successful `NSResult` entries by `LabelLevel`.
|
||||
2. For each label level, merge and deduplicate `NSRecords` from all resolvers.
|
||||
3. Select the label level with the longest name (most labels = most specific).
|
||||
4. If multiple label levels have the same length (shouldn't happen in practice), prefer the one returned by more resolvers.
|
||||
|
||||
---
|
||||
|
||||
### PrivateTLD
|
||||
|
||||
Static detection set for context-aware error messages (FR-024).
|
||||
|
||||
| TLD | Source |
|
||||
|-----|--------|
|
||||
| `.local` | mDNS (RFC 6762) |
|
||||
| `.internal` | Common internal convention |
|
||||
| `.lan` | Consumer router convention |
|
||||
| `.home` | Consumer router convention |
|
||||
| `.corp` | Corporate network convention |
|
||||
| `.private` | Private network convention |
|
||||
|
||||
**Usage**: When resolution fails completely and the hostname's TLD matches this set, the error message indicates the hostname appears internal and suggests `-server <ip>`.
|
||||
|
||||
---
|
||||
|
||||
### SplitHorizonResult
|
||||
|
||||
The outcome of the Stage 2.5 cross-check comparing authoritative and local resolver answers.
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| AuthoritativeIPs | `[]string` | IPs returned by the authoritative NS in Stage 2 |
|
||||
| AuthoritativeSource | `string` | The authoritative NS that provided the answer (e.g., `"ns1.example.com."`) |
|
||||
| LocalIPs | `[]string` | IPs returned by the local resolver (nil if NXDOMAIN, timeout, or no local resolvers) |
|
||||
| LocalSource | `string` | The local resolver IP that provided the answer |
|
||||
| HasConflict | `bool` | True if LocalIPs is non-nil and differs from AuthoritativeIPs |
|
||||
|
||||
**Semantics**:
|
||||
- `LocalIPs == nil`: No local answer (NXDOMAIN, timeout, or no local resolvers). No conflict.
|
||||
- `LocalIPs != nil && sets equal to AuthoritativeIPs`: Local agrees. No conflict.
|
||||
- `LocalIPs != nil && sets differ from AuthoritativeIPs`: **Conflict detected**. Neither IP set is written to the hosts file.
|
||||
|
||||
**Comparison**: IP sets are compared as sorted, deduplicated string sets. Order does not matter — `["1.2.3.4", "5.6.7.8"]` matches `["5.6.7.8", "1.2.3.4"]`.
|
||||
|
||||
---
|
||||
|
||||
## State Transitions
|
||||
|
||||
### Resolution Flow (Default Mode)
|
||||
|
||||
```
|
||||
START
|
||||
│
|
||||
├─▸ Build ResolverPool (local + bootstrap)
|
||||
│
|
||||
├─▸ Extract label levels from hostname
|
||||
│
|
||||
├─▸ Stage 1: Parallel NS Fan-out
|
||||
│ │
|
||||
│ ├─▸ [NS records found] ──▸ Stage 2: Authoritative Query
|
||||
│ │ │
|
||||
│ │ ├─▸ [A records] ──▸ Stage 2.5: Split-Horizon Cross-Check
|
||||
│ │ │ │
|
||||
│ │ │ ├─▸ [IPs match / no local answer] ──▸ SUCCESS (IPs)
|
||||
│ │ │ └─▸ [IPs differ] ──▸ CONFLICT ERROR
|
||||
│ │ ├─▸ [CNAME] ──▸ Restart from Stage 1 (depth+1)
|
||||
│ │ └─▸ [All NS unreachable] ──▸ ERROR
|
||||
│ │
|
||||
│ └─▸ [No NS at any level] ──▸ Stage 3: Parallel A Fallback
|
||||
│ │
|
||||
│ ├─▸ [Any A success] ──▸ SUCCESS (IPs)
|
||||
│ └─▸ [All fail/NXDOMAIN] ──▸ ERROR
|
||||
│
|
||||
└─▸ ERROR includes private TLD hint if applicable
|
||||
```
|
||||
|
||||
### Resolution Flow (Local Mode)
|
||||
|
||||
```
|
||||
START
|
||||
│
|
||||
├─▸ Discover local resolvers
|
||||
│ │
|
||||
│ └─▸ [No local resolvers] ──▸ ERROR (no fallback to public)
|
||||
│
|
||||
├─▸ Query resolvers in priority order (A query)
|
||||
│ │
|
||||
│ ├─▸ [Success] ──▸ SUCCESS (IPs)
|
||||
│ ├─▸ [Timeout] ──▸ Try next resolver
|
||||
│ └─▸ [All fail] ──▸ ERROR
|
||||
│
|
||||
└─▸ No fallback to public resolvers (FR-026)
|
||||
```
|
||||
|
||||
### Resolution Flow (Gateway Mode)
|
||||
|
||||
```
|
||||
START
|
||||
│
|
||||
├─▸ Discover default gateway
|
||||
│ │
|
||||
│ └─▸ [No gateway] ──▸ ERROR
|
||||
│
|
||||
├─▸ Query gateway (A query)
|
||||
│ │
|
||||
│ ├─▸ [Success] ──▸ SUCCESS (IPs)
|
||||
│ └─▸ [Fail] ──▸ ERROR (no fallback, FR-027)
|
||||
│
|
||||
└─▸ No fallback
|
||||
```
|
||||
|
||||
### Resolution Flow (Explicit IP Mode)
|
||||
|
||||
```
|
||||
START
|
||||
│
|
||||
├─▸ Query specified server (existing behavior, unchanged)
|
||||
│ │
|
||||
│ ├─▸ [Success] ──▸ SUCCESS (IPs)
|
||||
│ └─▸ [Fail] ──▸ ERROR
|
||||
│
|
||||
└─▸ Identical to current dns-helper behavior
|
||||
```
|
||||
@@ -0,0 +1,113 @@
|
||||
# Implementation Plan: Smart DNS Server Resolution Modes
|
||||
|
||||
**Branch**: `002-server-resolution-modes` | **Date**: 2026-03-04 | **Spec**: [spec.md](spec.md)
|
||||
**Input**: Feature specification from `/specs/002-server-resolution-modes/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Extend the dns-helper CLI's `-server` flag to support multiple resolution modes: smart default resolution (parallel NS fan-out across local + public resolvers for authoritative answers), `-server local` (local resolvers only), `-server gateway` (default gateway as DNS), and the existing explicit IP mode. Key technical work: implement parallel NS fan-out across all label levels and all resolvers, authoritative NS query with recursion disabled, CNAME-aware re-resolution, parallel A-query fallback for internal hosts without zone delegation, OS-level local resolver and gateway discovery (cross-platform), configurable per-query timeout, verbose diagnostic output to stderr, context-aware error messages for private TLDs, and split-horizon conflict detection (Stage 2.5: cross-check authoritative answer against local resolvers to catch enterprise DNS overrides for public TLD hostnames).
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: Go 1.20 (ceiling per constitution; `go.mod` specifies `go 1.20`)
|
||||
**Primary Dependencies**: Go standard library, `golang.org/x/net/dns/dnsmessage` (approved; already in go.mod v0.35.0)
|
||||
**Storage**: Local filesystem (system hosts file, backup files, lock file) — unchanged from 001
|
||||
**Testing**: `go test ./...` with TDD (Red-Green-Refactor); fake DNS servers via UDP listeners; interfaces for OS-level network config discovery
|
||||
**Target Platform**: Windows, Linux, macOS (cross-compiled single binary)
|
||||
**Project Type**: CLI tool (single binary, no config files)
|
||||
**Performance Goals**: Parallel NS fan-out must complete within the latency of the slowest single resolver (SC-002); error reporting within 10 seconds (SC-005)
|
||||
**Constraints**: Go 1.20 ceiling; zero unapproved external dependencies; hosts file safety (unchanged); UDP-only DNS (no TCP fallback)
|
||||
**Scale/Scope**: ~800 LOC current across 4 packages; this feature adds ~800-1200 LOC of new resolver logic, platform discovery, and tests
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
### Principle I: System File Safety
|
||||
- **Status**: PASS — No changes to the hosts file write path. The atomic write (temp+rename), backup, and lock mechanisms from 001 remain intact and unmodified. This feature only changes what IP address is written, not how it is written.
|
||||
|
||||
### Principle II: Standard Library First
|
||||
- **Status**: PASS — All new functionality uses Go standard library and `golang.org/x/net/dns/dnsmessage` (approved source, already in go.mod). Parallel operations use goroutines and channels (stdlib). OS-level network config discovery uses `os/exec` to call platform utilities (`ipconfig`, `ip route`, `networksetup`, `scutil`), which is standard library.
|
||||
- **Risk**: Local resolver/gateway discovery requires parsing OS command output. This is fragile but the standard library approach — no approved Go library exists for cross-platform network adapter enumeration under Go 1.20.
|
||||
|
||||
### Principle III: Go 1.20 Compatibility
|
||||
- **Status**: PASS — No new language features or stdlib APIs beyond Go 1.20 are needed. `sync.WaitGroup`, channels, goroutines, `context` (available since Go 1.7), `os/exec` — all available in Go 1.20. `golang.org/x/net` v0.35.0 is already pinned and Go 1.20-compatible.
|
||||
|
||||
### Principle IV: Idiomatic Error Handling
|
||||
- **Status**: PASS — All new functions (resolver pool construction, NS fan-out, local resolver discovery, gateway discovery) will return errors to callers. No `os.Exit` outside `main()`. Error messages will include context (hostname, resolver IP, stage information). The parallel fan-out collects individual query errors without treating them as fatal — only aggregate failure (no results from any resolver) produces a user-facing error.
|
||||
|
||||
### Principle V: Simplicity and Single Purpose
|
||||
- **Status**: PASS — The tool's purpose is unchanged: update the local hosts file with DNS entries. The new resolution modes make the tool smarter about *how* it resolves, but add no new subcommands, no configuration files, no daemons. The `-server` flag gains new keyword values (`local`, `gateway`) and the default behavior becomes smart resolution when omitted. No speculative features added.
|
||||
- **Note**: The parallel NS fan-out adds inherent complexity. This is justified by the core user scenario (US-1) which requires authoritative resolution for both public and internal hostnames without user configuration.
|
||||
|
||||
### Principle VI: Test-Driven Development
|
||||
- **Status**: PASS — All new code will follow TDD (Red-Green-Refactor). Platform-specific discovery functions will accept interfaces for testability. The resolver package already has fake DNS server test helpers that will be extended for NS queries. Parallel fan-out logic will be tested with deterministic fake resolver pools.
|
||||
|
||||
### Gate Evaluation
|
||||
All six constitution principles are satisfied. No violations exist in the current codebase that this feature introduces or worsens. **GATE: PASS** — proceed to Phase 0.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/002-server-resolution-modes/
|
||||
├── plan.md # This file
|
||||
├── research.md # Phase 0 output
|
||||
├── data-model.md # Phase 1 output
|
||||
├── quickstart.md # Phase 1 output
|
||||
├── contracts/ # Phase 1 output
|
||||
└── tasks.md # Phase 2 output (created by /speckit.tasks)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
dns-helper/
|
||||
├── main.go # Entry point, CLI parsing, os.Exit — updated for new -server modes, -timeout, -verbose
|
||||
├── resolver/
|
||||
│ ├── resolver.go # Existing: single-server A-record lookup (kept for -server <ip> mode)
|
||||
│ ├── resolver_test.go # Existing tests
|
||||
│ ├── pool.go # NEW: ResolverPool (bootstrap set + local resolvers), parallel fan-out
|
||||
│ ├── pool_test.go # NEW: Tests for pool construction and parallel fan-out
|
||||
│ ├── authority.go # NEW: Authoritative NS discovery (Stage 1), authoritative query (Stage 2), fallback (Stage 3)
|
||||
│ ├── authority_test.go # NEW: Tests for NS fan-out, authoritative query, CNAME re-resolution
|
||||
│ ├── modes.go # NEW: Mode dispatcher (smart default, local, gateway, explicit IP)
|
||||
│ └── modes_test.go # NEW: Tests for mode selection and dispatch
|
||||
├── platform/
|
||||
│ ├── platform.go # Package doc — extended with network discovery interface
|
||||
│ ├── platform_windows.go # Existing: GetHostsFilePath + NEW: GetLocalResolvers, GetDefaultGateway
|
||||
│ ├── platform_linux.go # Existing: GetHostsFilePath + NEW: GetLocalResolvers, GetDefaultGateway
|
||||
│ ├── platform_darwin.go # Existing: GetHostsFilePath + NEW: GetLocalResolvers, GetDefaultGateway
|
||||
│ └── platform_test.go # Extended with discovery tests
|
||||
├── hostfile/
|
||||
│ ├── hostfile.go # Unchanged
|
||||
│ ├── hostfile_test.go # Unchanged
|
||||
│ ├── managed.go # Unchanged
|
||||
│ ├── managed_test.go # Unchanged
|
||||
│ └── filesystem.go # Unchanged
|
||||
├── lockfile/
|
||||
│ ├── lockfile.go # Unchanged
|
||||
│ └── lockfile_test.go # Unchanged
|
||||
├── go.mod
|
||||
└── go.sum
|
||||
```
|
||||
|
||||
**Structure Decision**: Extend existing package structure. New resolver logic goes into the `resolver/` package in separate files (`pool.go`, `authority.go`, `modes.go`) to maintain single-responsibility per file without introducing new top-level packages. Platform-specific network discovery extends the existing `platform/` package files. The `hostfile/` and `lockfile/` packages are untouched. This follows the established patterns from 001.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
> No constitution violations require justification. The parallel NS fan-out is the most complex new component but is justified by the core user scenario (US-1) and explicitly required by FR-013/FR-014.
|
||||
|
||||
## Constitution Re-Check (Post Phase 1 Design)
|
||||
|
||||
*All six principles re-evaluated after design phase. No new violations introduced.*
|
||||
|
||||
| Principle | Status | Evidence |
|
||||
|-----------|--------|----------|
|
||||
| I. System File Safety | PASS | No changes to the hosts file write path. Atomic write, backup, and lock mechanisms from 001 untouched. Only the DNS resolution layer changes — different IPs may be written, but the write mechanism is identical. |
|
||||
| II. Standard Library First | PASS | All new code uses Go stdlib + `golang.org/x/net/dns/dnsmessage` (approved, already in go.mod). Platform discovery uses `os/exec` to call OS utilities — no new dependencies. Parallel fan-out uses goroutines + channels (stdlib). |
|
||||
| III. Go 1.20 Compatibility | PASS | All APIs verified: `context.WithTimeout` (Go 1.7), `net.Dialer.DialContext` (Go 1.7), `errors.Join` (Go 1.20), `os/exec` (Go 1.0), `dnsmessage.TypeNS`/`parser.NSResource()` (present in x/net v0.35.0). No Go 1.21+ features used. `slices`, `maps`, `slog`, `context.WithTimeoutCause` explicitly avoided. |
|
||||
| IV. Idiomatic Error Handling | PASS | All new functions return errors. `Resolve()` returns errors to `main.go`. `Discover()` returns errors to caller. Parallel fan-out collects per-query errors in `NSResult.Err` without fatal exits. Error messages include hostname, resolver IP, stage context, and private TLD hints. Only `main()` calls `os.Exit()`. |
|
||||
| V. Simplicity and Single Purpose | PASS | Tool purpose unchanged: update hosts file with DNS entries. No new subcommands, config files, daemons, or TUI. The `-server` flag gains keyword values; `-timeout` and `-verbose` are standard CLI patterns. New files follow single-responsibility: `parse.go` (parsing), `pool.go` (pool construction), `authority.go` (NS fan-out), `fallback.go` (A fallback), `modes.go` (dispatch). |
|
||||
| VI. Test-Driven Development | PASS | All new code designed for TDD. `FakeNetworkDiscoverer` enables deterministic pool/mode testing. Existing `startFakeDNS`/`startFakeDNSMulti` test helpers extend to NS queries. `ParseServerFlag` and `ExtractLabelLevels` are pure functions with straightforward table-driven tests. Platform integration tests are build-tagged. |
|
||||
@@ -0,0 +1,185 @@
|
||||
# Quickstart: Smart DNS Server Resolution Modes
|
||||
|
||||
**Feature Branch**: `002-server-resolution-modes`
|
||||
**Date**: 2026-03-04
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Go 1.20 toolchain installed and on `PATH`
|
||||
- Repository cloned and on branch `002-server-resolution-modes`
|
||||
- `go mod tidy` run successfully
|
||||
|
||||
## Build
|
||||
|
||||
```powershell
|
||||
# From repository root
|
||||
go build -o dns-helper.exe .
|
||||
```
|
||||
|
||||
Cross-compile (existing build.ps1 pattern):
|
||||
|
||||
```powershell
|
||||
.\build.ps1
|
||||
```
|
||||
|
||||
## Run Tests
|
||||
|
||||
```powershell
|
||||
go test ./... -v
|
||||
```
|
||||
|
||||
## TDD Workflow
|
||||
|
||||
This feature follows the Red-Green-Refactor cycle mandated by Constitution Principle VI.
|
||||
|
||||
### Order of Implementation
|
||||
|
||||
The recommended implementation order (each step follows TDD):
|
||||
|
||||
1. **`resolver/parse.go`** — `ParseServerFlag()` and `ExtractLabelLevels()` (pure functions, no I/O)
|
||||
2. **`platform/network.go`** — `NetworkInfo` type, `NetworkDiscoverer` interface, `FakeNetworkDiscoverer`
|
||||
3. **`platform/network_windows.go`** — Windows `Discover()` implementation
|
||||
4. **`platform/network_linux.go`** — Linux `Discover()` implementation
|
||||
5. **`platform/network_darwin.go`** — macOS `Discover()` implementation
|
||||
6. **`resolver/pool.go`** — `BuildResolverPool()` and `BootstrapResolvers`
|
||||
7. **`resolver/transport.go`** — Generalized `udpQuery()` shared transport (extract from existing `resolver.go`)
|
||||
8. **`resolver/authority.go`** — `ParallelNSFanOut()`, `SelectAuthoritativeNS()`, `QueryAuthoritative()`
|
||||
9. **`resolver/splithorizon.go`** — `CheckSplitHorizon()` (Stage 2.5 cross-check)
|
||||
10. **`resolver/fallback.go`** — `ParallelAFallback()`
|
||||
11. **`resolver/modes.go`** — `Resolve()` mode dispatcher integrating all components
|
||||
12. **`main.go`** — Update CLI flag parsing, wire new resolver pipeline
|
||||
|
||||
### Per-Step TDD Cycle
|
||||
|
||||
For each item above:
|
||||
|
||||
1. **Red**: Write the test in `*_test.go` — it should fail (function doesn't exist yet).
|
||||
2. **Green**: Implement the minimum code to make the test pass.
|
||||
3. **Refactor**: Clean up while keeping tests green.
|
||||
|
||||
Example for `ParseServerFlag`:
|
||||
|
||||
```go
|
||||
// resolver/parse_test.go
|
||||
func TestParseServerFlag_Default(t *testing.T) {
|
||||
mode, err := resolver.ParseServerFlag("")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if mode.Mode != "default" {
|
||||
t.Errorf("expected mode 'default', got %q", mode.Mode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseServerFlag_Local(t *testing.T) {
|
||||
mode, err := resolver.ParseServerFlag("local")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if mode.Mode != "local" {
|
||||
t.Errorf("expected mode 'local', got %q", mode.Mode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseServerFlag_InvalidPort(t *testing.T) {
|
||||
_, err := resolver.ParseServerFlag("10.0.0.53:0")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for port 0")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Testing Parallel Fan-out
|
||||
|
||||
Use the existing fake DNS server helpers from `resolver/resolver_test.go`:
|
||||
|
||||
```go
|
||||
// Start multiple fake DNS servers, each responding to NS queries differently.
|
||||
// Pass their addresses as the resolver pool to ParallelNSFanOut.
|
||||
ns1 := startFakeDNS(t, handleNSQuery("example.com", []string{"ns1.example.com."}))
|
||||
ns2 := startFakeDNS(t, handleNSQueryNXDOMAIN)
|
||||
|
||||
results := resolver.ParallelNSFanOut(ctx, []string{ns1, ns2}, []string{"example.com"}, 3*time.Second)
|
||||
// Assert: 2 results, one with NS records, one with nil
|
||||
```
|
||||
|
||||
### Testing Platform Discovery
|
||||
|
||||
Use `FakeNetworkDiscoverer` for unit tests — never call real OS commands in unit tests:
|
||||
|
||||
```go
|
||||
fake := &platform.FakeNetworkDiscoverer{
|
||||
Info: platform.NetworkInfo{
|
||||
DNSServers: []string{"10.0.0.1", "10.0.0.2"},
|
||||
Gateway: "10.0.0.1",
|
||||
Interface: "eth0",
|
||||
},
|
||||
}
|
||||
pool := resolver.BuildResolverPool(resolver.ServerMode{Mode: "default"}, fake.Info)
|
||||
// Assert: pool contains local resolvers + bootstrap set
|
||||
```
|
||||
|
||||
Platform-specific integration tests (build-tagged) can test real `os/exec` parsing:
|
||||
|
||||
```go
|
||||
//go:build windows
|
||||
|
||||
func TestWindowsDiscover(t *testing.T) {
|
||||
d := &platform.WindowsNetworkDiscoverer{}
|
||||
info, err := d.Discover()
|
||||
// Assert: no error, DNSServers non-empty on a typical system
|
||||
}
|
||||
```
|
||||
|
||||
## Manual Verification
|
||||
|
||||
### Smart Default Resolution (no -server)
|
||||
|
||||
```powershell
|
||||
# Should discover authoritative NS for example.com and query it directly
|
||||
.\dns-helper.exe add -host www.example.com -verbose
|
||||
```
|
||||
|
||||
### Local Resolver Mode
|
||||
|
||||
```powershell
|
||||
.\dns-helper.exe add -host internal.corp.local -server local -verbose
|
||||
```
|
||||
|
||||
### Gateway Mode
|
||||
|
||||
```powershell
|
||||
.\dns-helper.exe add -host www.example.com -server gateway -verbose
|
||||
```
|
||||
|
||||
### Explicit IP (existing, unchanged)
|
||||
|
||||
```powershell
|
||||
.\dns-helper.exe add -host www.example.com -server 8.8.8.8
|
||||
```
|
||||
|
||||
### Timeout Override
|
||||
|
||||
```powershell
|
||||
.\dns-helper.exe add -host www.example.com -timeout 1 -verbose
|
||||
```
|
||||
|
||||
## Key Files Changed (Summary)
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `main.go` | Updated `runAdd()`: `-server` optional, new `-timeout`/`-verbose` flags, mode dispatch |
|
||||
| `resolver/resolver.go` | Existing code unchanged; generalized UDP transport extracted |
|
||||
| `resolver/parse.go` | **New**: `ParseServerFlag()`, `ExtractLabelLevels()` |
|
||||
| `resolver/pool.go` | **New**: `BuildResolverPool()`, `BootstrapResolvers` |
|
||||
| `resolver/transport.go` | **New**: shared `udpQuery()` transport layer |
|
||||
| `resolver/authority.go` | **New**: `ParallelNSFanOut()`, `SelectAuthoritativeNS()`, `QueryAuthoritative()` |
|
||||
| `resolver/splithorizon.go` | **New**: `CheckSplitHorizon()` — Stage 2.5 cross-check |
|
||||
| `resolver/fallback.go` | **New**: `ParallelAFallback()` |
|
||||
| `resolver/modes.go` | **New**: `Resolve()` mode dispatcher |
|
||||
| `platform/platform.go` | Extended with `NetworkInfo`, `NetworkDiscoverer` interface |
|
||||
| `platform/platform_windows.go` | **Extended**: `Discover()` via `netsh` |
|
||||
| `platform/platform_linux.go` | **Extended**: `Discover()` via `ip route` + `/etc/resolv.conf` |
|
||||
| `platform/platform_darwin.go` | **Extended**: `Discover()` via `route` + `scutil` |
|
||||
@@ -0,0 +1,493 @@
|
||||
# Research: Server Resolution Modes
|
||||
|
||||
**Feature Branch**: `002-server-resolution-modes`
|
||||
**Date**: 2026-03-04
|
||||
|
||||
---
|
||||
|
||||
## R-000: Parallel DNS Fan-out Patterns (Go 1.20)
|
||||
|
||||
### Decision: Goroutines + Buffered Channel
|
||||
Use goroutines writing to a buffered channel sized to `N×M` (resolvers × label levels) with a collecting loop that reads exactly `N×M` results. No mutex, no WaitGroup needed.
|
||||
|
||||
### Rationale
|
||||
- Each goroutine writes exactly one result; the collector reads exactly `N×M` values. Bounded channel means goroutines never block.
|
||||
- Alternative (WaitGroup + shared slice + Mutex) adds unnecessary synchronization complexity for no benefit.
|
||||
|
||||
### Per-Query vs Overall Timeout
|
||||
Use nested `context.WithTimeout` — parent context sets overall operation deadline (10s), child contexts set per-query deadline (3s from `-timeout` flag). Parent cancellation propagates to all children automatically. All context APIs available since Go 1.7.
|
||||
|
||||
### UDP Transport with Context (Go 1.20)
|
||||
Use `net.Dialer.DialContext()` (available since Go 1.7) for context-aware dialing. After dialing, use `conn.SetDeadline()` derived from context deadline. There is no `conn.ReadContext()` in Go 1.20 — `SetDeadline` is the standard approach.
|
||||
|
||||
### NS Query Construction
|
||||
Identical to existing A query in `resolver.go` — only `Type` changes from `dnsmessage.TypeA` to `dnsmessage.TypeNS`. Parse responses with `parser.NSResource()` which returns `NSResource{NS: dnsmessage.Name}`.
|
||||
|
||||
### Authoritative Query (RD=false)
|
||||
Set `RecursionDesired: false` in `dnsmessage.Header`. Check `Authoritative` flag in response for validation/logging. Handle `RCodeRefused` as "try next NS". Handle empty answers with RCodeSuccess as possible referral — try next NS.
|
||||
|
||||
### CNAME in NS Responses
|
||||
Uncommon but possible (zone misconfiguration or CDN aliases). Extend NS response parser to return both NS records and CNAME target. If CNAME received instead of NS, the caller restarts NS discovery for the CNAME target domain. Shared depth counter prevents infinite loops (max 10, matching existing `maxCNAMEDepth`).
|
||||
|
||||
### Label-Level Extraction
|
||||
Split hostname on `.`, generate all suffixes with ≥2 labels (from most-specific to least-specific). `host1.sub.example.com` → `["host1.sub.example.com", "sub.example.com", "example.com"]`. Single-label hostnames return nil. No public suffix list needed — specificity-based NS selection handles multi-part TLDs naturally.
|
||||
|
||||
### Go 1.20 Limitations
|
||||
- `context.WithTimeoutCause` (1.21+): Not available. Use `context.WithTimeout` + manual error wrapping.
|
||||
- `slices` package (1.21+): Not available. Use `sort.Slice` for sorting.
|
||||
- `maps` package (1.21+): Not available. Manual map iteration.
|
||||
- `slog` (1.21+): Not available. Use `fmt.Fprintf(os.Stderr, ...)` for verbose output.
|
||||
- `errors.Join` (1.20): Available for aggregating errors from parallel queries.
|
||||
- Generics (1.18): Available in Go 1.20 but not required for this feature.
|
||||
|
||||
### Alternatives Considered
|
||||
- **`sync.WaitGroup` + shared slice**: Requires mutex, separate done signal. More moving parts for the same result. Rejected.
|
||||
- **`errgroup` from `golang.org/x/sync`**: Good API but adds an approved-but-unnecessary dependency. The buffered channel pattern is simpler and requires no imports. Rejected.
|
||||
- **Manual deadline tracking with `time.After`**: Error-prone, doesn't propagate cancellation. `context.WithTimeout` is superior. Rejected.
|
||||
|
||||
---
|
||||
|
||||
## R-001: Windows — Local Resolver and Gateway Discovery
|
||||
|
||||
### Decision
|
||||
Use a three-command `netsh` pipeline via `os/exec`:
|
||||
|
||||
1. `netsh interface ipv4 show route` → find the `0.0.0.0/0` row to extract the **interface index** (Idx) and **gateway IP**.
|
||||
2. `netsh interface ipv4 show interfaces` → map the interface index to an **interface name**.
|
||||
3. `netsh interface ipv4 show dnsservers name="<interface name>"` → extract **DNS server IPs**.
|
||||
|
||||
### Rationale
|
||||
- `netsh` is present on every Windows version from Vista through Windows 11/Server 2025. It does not require PowerShell, elevated privileges, or any runtime beyond the base OS.
|
||||
- The numeric data we parse (IP addresses, interface indices, `0.0.0.0/0` prefix) is **never localized**. Column headers and labels are localized on non-English Windows, but we do not parse them — we parse by pattern (IP regex, prefix match) and by column position.
|
||||
- Each command returns rapidly (< 50ms) and produces small output.
|
||||
|
||||
### Go 1.20 Compatibility
|
||||
- `os/exec.Command()`, `cmd.Output()`: available since Go 1.0.
|
||||
- `strings`, `regexp`, `bufio`, `net`: all available in Go 1.20.
|
||||
- No CGo, no WMI bindings, no PowerShell dependency.
|
||||
|
||||
### Output Formats and Parsing Strategy
|
||||
|
||||
#### Step 1: Default Route — `netsh interface ipv4 show route`
|
||||
|
||||
Verified output (live, 2026-03-04):
|
||||
```
|
||||
Publish Type Met Prefix Idx Gateway/Interface Name
|
||||
------- -------- --- ------------------------ --- ------------------------
|
||||
No Manual 0 0.0.0.0/0 4 10.26.1.1
|
||||
No System 256 10.26.0.0/16 4 vEthernet (Bridge)
|
||||
No System 256 127.0.0.0/8 1 Loopback Pseudo-Interface 1
|
||||
```
|
||||
|
||||
**Parsing**:
|
||||
- Scan each line for `0.0.0.0/0` (literal match; never localized).
|
||||
- When found, extract fields by splitting on whitespace. The row format is:
|
||||
`<Publish> <Type> <Metric> <Prefix> <Idx> <Gateway or Interface Name>`
|
||||
- Field 4 (0-indexed) is the interface index. Field 5+ is the gateway IP (an IP address) or the interface name (a string with spaces). We only need the gateway when it's an IP address; for the `0.0.0.0/0` route there is always a gateway IP.
|
||||
- Use a regex on the gateway field to match an IPv4 address: `(\d{1,3}\.){3}\d{1,3}`.
|
||||
- If multiple `0.0.0.0/0` rows exist (e.g., VPN active), take the one with the **lowest metric** (field 2).
|
||||
|
||||
**Extracted values**: Interface Index (int), Gateway IP (string).
|
||||
|
||||
#### Step 2: Interface Name — `netsh interface ipv4 show interfaces`
|
||||
|
||||
Verified output (live, 2026-03-04):
|
||||
```
|
||||
Idx Met MTU State Name
|
||||
--- ---------- ---------- ------------ ---------------------------
|
||||
1 75 4294967295 connected Loopback Pseudo-Interface 1
|
||||
13 1 1500 disconnected Local Area Connection
|
||||
4 25 1500 connected vEthernet (Bridge)
|
||||
```
|
||||
|
||||
**Parsing**:
|
||||
- Skip header lines (first two).
|
||||
- Split each line on whitespace. Field 0 is Idx, field 4+ is the interface name (may contain spaces — take everything from field 4 onward, trimmed).
|
||||
- Match the row where Idx equals the interface index from Step 1.
|
||||
|
||||
**Extracted value**: Interface Name (string).
|
||||
|
||||
#### Step 3: DNS Servers — `netsh interface ipv4 show dnsservers name="<name>"`
|
||||
|
||||
Verified output (live, 2026-03-04, DHCP-configured):
|
||||
```
|
||||
Configuration for interface "vEthernet (Bridge)"
|
||||
DNS servers configured through DHCP: 10.26.1.1
|
||||
Register with which suffix: Primary only
|
||||
```
|
||||
|
||||
Known format for static configuration with multiple servers:
|
||||
```
|
||||
Configuration for interface "Ethernet"
|
||||
Statically Configured DNS Servers: 10.0.0.53
|
||||
8.8.8.8
|
||||
8.8.4.4
|
||||
Register with which suffix: Primary only
|
||||
```
|
||||
|
||||
**Parsing**:
|
||||
- Do **not** parse the label text (it is localized). Instead, scan each line for an IPv4 address regex: `^\s*(?:\S.*:\s+)?(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\s*$`.
|
||||
- This matches both the first DNS server (after the label) and continuation lines (indented IPs on subsequent lines).
|
||||
- Validate each match with `net.ParseIP()` to reject false positives.
|
||||
- Collect all IPs in order — the first is primary, second is secondary, etc.
|
||||
|
||||
**Extracted values**: Ordered list of DNS server IPs.
|
||||
|
||||
### Alternatives Considered
|
||||
|
||||
1. **`ipconfig /all`**: Outputs all adapter info in one shot, but labels ("Default Gateway", "DNS Servers") are **fully localized** on non-English Windows. Parsing is fragile because there is no fixed column format — values can wrap to multiple lines with inconsistent indentation, and adapter sections are delimited only by blank lines. Rejected for localization and parsing reliability.
|
||||
|
||||
2. **`route print 0.0.0.0`**: Returns the IPv4 route table including the default route. Shows Gateway and Interface (as IP, not name), but the output mixes freeform text (Interface List) with a columnar table, making parsing more complex than `netsh interface ipv4 show route`. Also, the interface is shown as an IP address, not an index or name, requiring an additional correlation step. Rejected for added complexity with no benefit.
|
||||
|
||||
3. **PowerShell `Get-DnsClientServerAddress` / `Get-NetIPConfiguration`**: Powerful and structured, but requires PowerShell to be present and invocable. On Server Core or minimal installs, PowerShell may not be available. Invoking PowerShell from `os/exec` incurs a ~500ms startup penalty. The output of `Get-NetIPConfiguration` is CIM object formatting (e.g., `MSFT_NetRoute (InstanceID = ...)`) that is not human-parseable without `-Format` switches. Rejected for startup cost and deployment fragility.
|
||||
|
||||
4. **Registry (`HKLM\...\Tcpip\Parameters\Interfaces`)**: The `NameServer` (static) and `DhcpNameServer` (DHCP) registry values contain DNS server IPs, and `DhcpDefaultGateway` contains the gateway. This is locale-independent and fast. However, correlating the interface GUID with the interface that holds the default route requires reading the route table anyway (the registry stores routes per-interface-GUID, not in a centralized table). The approach was considered as a backup strategy but rejected for primary use because the GUID→default-route correlation is complex and brittle. It remains a viable fallback if `netsh` is ever unavailable.
|
||||
|
||||
5. **WMI/CIM (Go bindings)**: No maintained Go WMI library works under Go 1.20. `github.com/StackExchange/wmi` requires `go-ole` which has compatibility issues. Out of scope per Go 1.20 constraint.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
| Case | Handling |
|
||||
|------|----------|
|
||||
| **DHCP configuration** | Verified: `netsh` shows DHCP-assigned DNS identically to static — just the label differs. Our IP-regex parsing handles both. |
|
||||
| **Multiple default routes** (VPN, dual NIC) | Parse all `0.0.0.0/0` rows, select the one with the lowest metric. This matches Windows routing behavior. |
|
||||
| **No default route** | No `0.0.0.0/0` row found. Return empty resolver list and empty gateway. FR-009 handles gracefully. |
|
||||
| **No DNS servers on default-route adapter** | Step 3 output contains no IP addresses. Return empty resolver list. |
|
||||
| **IPv6-only adapter** | We query `ipv4 show route`. No IPv4 default route found → return empty. IPv6 DNS discovery is out of scope. |
|
||||
| **Interface name with spaces/parens** | Verified: interface names like `vEthernet (Bridge)` work correctly when quoted in `netsh` commands. Always quote the name. |
|
||||
| **`netsh` not found (Server Nano/Container)** | `exec.Command` returns error. Return empty list. FR-009 handles gracefully. |
|
||||
| **Localized Windows** | All parsed data (IPs, indices, `0.0.0.0/0`) is numeric/standard notation. Label text is ignored. Safe on all locales. |
|
||||
|
||||
---
|
||||
|
||||
## R-002: Linux — Local Resolver and Gateway Discovery
|
||||
|
||||
### Decision
|
||||
|
||||
Use a two-phase approach:
|
||||
|
||||
1. `ip route show default` → extract **gateway IP** and **interface name**.
|
||||
2. Parse `/etc/resolv.conf` for `nameserver` lines → extract **DNS server IPs**.
|
||||
3. If all nameservers are stub-resolver addresses (`127.0.0.53`, `127.0.0.1`, `127.0.1.1`), attempt `resolvectl status <interface>` to discover **upstream DNS servers**.
|
||||
|
||||
### Rationale
|
||||
- `ip route` is part of `iproute2`, present on every mainstream Linux distribution (Debian, Ubuntu, RHEL, Fedora, Arch, Alpine, SUSE) since ~2010. It replaced the deprecated `route` command.
|
||||
- `/etc/resolv.conf` is the POSIX-standard DNS configuration file, present on every Linux system.
|
||||
- The `resolvectl` fallback handles the systemd-resolved case cleanly without adding complexity for non-systemd distributions.
|
||||
|
||||
### Go 1.20 Compatibility
|
||||
- `os/exec.Command()`: available since Go 1.0.
|
||||
- `os.Open()`, `bufio.Scanner`: available since Go 1.0/1.1.
|
||||
- `os.Readlink()`: available since Go 1.0 (used for symlink detection).
|
||||
- No CGo required.
|
||||
|
||||
### Output Formats and Parsing Strategy
|
||||
|
||||
#### Step 1: Default Route — `ip route show default`
|
||||
|
||||
Standard output:
|
||||
```
|
||||
default via 192.168.1.1 dev eth0 proto dhcp metric 100
|
||||
```
|
||||
|
||||
Multiple default routes (rare):
|
||||
```
|
||||
default via 192.168.1.1 dev eth0 proto dhcp metric 100
|
||||
default via 10.0.0.1 dev wlan0 proto dhcp metric 600
|
||||
```
|
||||
|
||||
**Parsing**:
|
||||
- Split each line on whitespace.
|
||||
- Look for lines starting with `default`.
|
||||
- Extract `via <IP>` for gateway and `dev <interface>` for interface name.
|
||||
- If multiple default routes exist, take the one with the **lowest metric** (parse `metric <N>`).
|
||||
- The `via` and `dev` keywords are not localized (they are part of the `ip` command's fixed syntax).
|
||||
|
||||
**Extracted values**: Gateway IP (string), Interface Name (string).
|
||||
|
||||
#### Step 2: DNS Servers — `/etc/resolv.conf`
|
||||
|
||||
Standard format (RFC 2136):
|
||||
```
|
||||
# Generated by NetworkManager
|
||||
nameserver 192.168.1.1
|
||||
nameserver 8.8.8.8
|
||||
```
|
||||
|
||||
systemd-resolved stub format:
|
||||
```
|
||||
# This is /run/systemd/resolve/stub-resolv.conf managed by systemd-resolved.
|
||||
nameserver 127.0.0.53
|
||||
options edns0 trust-ad
|
||||
search lan
|
||||
```
|
||||
|
||||
**Parsing**:
|
||||
- Read file line by line.
|
||||
- Skip lines starting with `#` or `;` (comments).
|
||||
- For lines starting with `nameserver`, extract the IP address after the keyword.
|
||||
- Validate with `net.ParseIP()`.
|
||||
- Collect in order — the order in `resolv.conf` represents priority.
|
||||
|
||||
**Extracted values**: Ordered list of DNS server IPs.
|
||||
|
||||
#### Step 3 (conditional): systemd-resolved — `resolvectl status <interface>`
|
||||
|
||||
Triggered when all nameservers from Step 2 are loopback addresses (`127.0.0.53`, `127.0.0.1`, `127.0.1.1`).
|
||||
|
||||
Standard output:
|
||||
```
|
||||
Link 2 (eth0)
|
||||
Current Scopes: DNS
|
||||
DefaultRoute setting: yes
|
||||
LLMNR setting: yes
|
||||
MulticastDNS setting: no
|
||||
DNSOverTLS setting: no
|
||||
DNSSEC setting: no
|
||||
DNSSEC supported: no
|
||||
Current DNS Server: 192.168.1.1
|
||||
DNS Servers: 192.168.1.1
|
||||
8.8.8.8
|
||||
DNS Domain: lan
|
||||
```
|
||||
|
||||
**Parsing**:
|
||||
- Look for the `DNS Servers:` label (or fall back to `Current DNS Server:` if `DNS Servers:` is absent).
|
||||
- Extract the IP after the colon on the `DNS Servers:` line.
|
||||
- Continue reading subsequent lines that are indented (continuation IPs).
|
||||
- Validate each with `net.ParseIP()`.
|
||||
|
||||
**Note on localization**: `resolvectl` output is **not localized** — the keywords are hardcoded in the systemd source. Safe to parse literally.
|
||||
|
||||
### Alternatives Considered
|
||||
|
||||
1. **`resolvectl` as primary approach (skip `/etc/resolv.conf`)**: Would miss DNS configuration on non-systemd systems (Alpine, older Debian, container distros, WSL1). `/etc/resolv.conf` is universally present. Rejected as primary; used only as fallback for stub detection.
|
||||
|
||||
2. **`nmcli dev show <interface>`**: NetworkManager-specific. Returns `IP4.DNS[1]: 192.168.1.1` etc. Works well on desktop Linux with NetworkManager but absent on servers, containers, and Alpine. Considered as a tertiary fallback after `resolvectl` but rejected to avoid cascading fallback complexity. If `resolvectl` fails on a stub-resolver system, the stub address itself (`127.0.0.53`) is still a valid local resolver — it just forwards to upstream. Acceptable for the tool's purposes.
|
||||
|
||||
3. **`systemd-resolve --status`**: Deprecated alias for `resolvectl status`. Using `resolvectl` directly is forward-compatible. If `resolvectl` is not found, `systemd-resolve` could be tried, but this adds complexity for diminishing returns. Rejected.
|
||||
|
||||
4. **Parse `/run/systemd/resolve/resolv.conf`** (the non-stub version): Contains actual upstream nameservers without the `127.0.0.53` stub. However, this file's existence and path are implementation details of systemd-resolved, not a stable contract. Rejected in favor of `resolvectl` which is a proper CLI API.
|
||||
|
||||
5. **`route -n` (deprecated `net-tools` command)**: Not present on minimal installs. `ip route` from `iproute2` is the modern replacement and universally available. Rejected.
|
||||
|
||||
### The systemd-resolved Question
|
||||
|
||||
**Q: Is `/etc/resolv.conf` sufficient on Linux, or does systemd-resolved require special handling?**
|
||||
|
||||
**A: `/etc/resolv.conf` is sufficient for basic functionality, but systemd-resolved benefits from special handling for optimal results.**
|
||||
|
||||
The detailed analysis:
|
||||
|
||||
| Scenario | `/etc/resolv.conf` contents | Behavior if we use it as-is | With `resolvectl` fallback |
|
||||
|----------|----------------------------|----------------------------|---------------------------|
|
||||
| No systemd-resolved | Real upstream IPs (e.g., `192.168.1.1`) | Correct. These are the actual DNS servers. | N/A — fallback not triggered. |
|
||||
| systemd-resolved (stub mode, default) | `127.0.0.53` | **Functional but suboptimal.** The tool queries the stub resolver, which forwards to upstream. For `-server local` mode, the user expects to query the network's DNS directly, not a local forwarder. For the resolver pool in default mode, the stub is fine — it provides access to the same DNS infrastructure. | Discovers actual upstream servers (e.g., `192.168.1.1`). Better fidelity to FR-010 ("resolvers configured on the network adapter"). |
|
||||
| systemd-resolved (non-stub symlink) | Real upstream IPs | Correct. `/etc/resolv.conf` → `/run/systemd/resolve/resolv.conf` which has real IPs. | N/A — fallback not triggered. |
|
||||
| dnsmasq local forwarder | `127.0.0.1` | Similar to systemd-resolved stub. Functional but indirect. | `resolvectl` won't help here (different subsystem). The `127.0.0.1` address is the best we can do — it's the system's DNS path. |
|
||||
|
||||
**Recommendation**: Implement the `resolvectl` fallback to handle the common systemd-resolved stub case. For other local forwarders (dnsmasq, unbound), accept the loopback address as the local resolver — it IS the system-configured DNS path and will function correctly.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
| Case | Handling |
|
||||
|------|----------|
|
||||
| **DHCP configuration** | `ip route` shows DHCP routes identically to static. `/etc/resolv.conf` is updated by DHCP client or NetworkManager. Transparent. |
|
||||
| **No default route** | `ip route show default` returns empty stdout. Return empty list. |
|
||||
| **Multiple default routes** | Take the lowest-metric route. |
|
||||
| **`/etc/resolv.conf` missing** | `os.Open()` returns error. Return empty resolver list. |
|
||||
| **`/etc/resolv.conf` is a broken symlink** | `os.Open()` returns error. Return empty resolver list. |
|
||||
| **WSL (Windows Subsystem for Linux)** | `/etc/resolv.conf` typically contains the Windows host IP (e.g., `172.x.x.1`). `ip route show default` works. This is the correct local resolver for WSL. |
|
||||
| **Alpine / BusyBox** | `ip` from BusyBox supports `ip route show default`. `/etc/resolv.conf` is standard. No systemd-resolved. Works correctly. |
|
||||
| **Container (Docker/Podman)** | `/etc/resolv.conf` is injected by the runtime. `ip route show default` works. Standard behavior. |
|
||||
| **`resolvectl` not found** | `exec.LookPath("resolvectl")` fails. Skip fallback, use `/etc/resolv.conf` entries as-is (the stub `127.0.0.53` is still functional). |
|
||||
| **`ip` command not found** | Extremely rare (would need a broken minimal install). `exec.Command` returns error. Return empty list. |
|
||||
|
||||
---
|
||||
|
||||
## R-003: macOS — Local Resolver and Gateway Discovery
|
||||
|
||||
### Decision
|
||||
|
||||
Use a two-command approach:
|
||||
|
||||
1. `route -n get default` → extract **gateway IP** and **interface name**.
|
||||
2. `scutil --dns` → extract **DNS server IPs** by matching resolver entries to the default-route interface.
|
||||
|
||||
### Rationale
|
||||
- `route` and `scutil` are built-in macOS system utilities, present on every macOS version from at least 10.6 (Snow Leopard) through the latest releases.
|
||||
- `scutil --dns` provides the most complete DNS configuration view on macOS, including both manually configured and DHCP-assigned DNS servers, with interface correlation.
|
||||
- Neither command requires elevated privileges for reading configuration.
|
||||
|
||||
### Go 1.20 Compatibility
|
||||
- `os/exec.Command()`, `cmd.Output()`: available since Go 1.0.
|
||||
- `strings`, `regexp`, `bufio`, `net`: all available in Go 1.20.
|
||||
- No CGo, no Objective-C runtime, no Swift.
|
||||
|
||||
### Output Formats and Parsing Strategy
|
||||
|
||||
#### Step 1: Default Route — `route -n get default`
|
||||
|
||||
Standard output:
|
||||
```
|
||||
route to: default
|
||||
destination: default
|
||||
mask: default
|
||||
gateway: 192.168.1.1
|
||||
interface: en0
|
||||
flags: <UP,GATEWAY,DONE,STATIC,PRCLONING,AUTOCONF>
|
||||
recvpipe sendpipe ssthresh rtt,msec rttvar hopcount mtu expire
|
||||
0 0 0 0 0 0 1500 0
|
||||
```
|
||||
|
||||
**Parsing**:
|
||||
- Scan lines for `gateway:` → extract the IP address after the colon (trimmed).
|
||||
- Scan lines for `interface:` → extract the interface name after the colon (trimmed).
|
||||
- Both keywords are fixed (macOS `route` output is not localized).
|
||||
- Validate gateway with `net.ParseIP()`.
|
||||
|
||||
**Extracted values**: Gateway IP (string), Interface Name (string, e.g., `en0`).
|
||||
|
||||
#### Step 2: DNS Servers — `scutil --dns`
|
||||
|
||||
Standard output (example with Wi-Fi on en0):
|
||||
```
|
||||
DNS configuration
|
||||
|
||||
resolver #1
|
||||
nameserver[0] : 192.168.1.1
|
||||
nameserver[1] : 8.8.8.8
|
||||
if_index : 6 (en0)
|
||||
flags : Request A records
|
||||
reach : 0x00020002 (Reachable,Directly Reachable Address)
|
||||
|
||||
resolver #2
|
||||
domain : local
|
||||
options : mdns
|
||||
timeout : 5
|
||||
flags : Request A records
|
||||
reach : 0x00000000 (Not Reachable)
|
||||
order : 300000
|
||||
|
||||
DNS configuration (for scoped queries)
|
||||
|
||||
resolver #1
|
||||
nameserver[0] : 192.168.1.1
|
||||
nameserver[1] : 8.8.8.8
|
||||
if_index : 6 (en0)
|
||||
flags : Scoped, Request A records
|
||||
reach : 0x00020002 (Reachable,Directly Reachable Address)
|
||||
```
|
||||
|
||||
**Parsing strategy**:
|
||||
|
||||
The output is organized into resolver blocks separated by `resolver #N` headers. The parsing approach:
|
||||
|
||||
1. Split the output into resolver blocks (delimited by `resolver #<N>` lines).
|
||||
2. For each block, extract:
|
||||
- Nameserver IPs: lines matching `nameserver\[\d+\]\s*:\s*(\S+)`
|
||||
- Interface: line matching `if_index\s*:\s*\d+\s*\((\w+)\)` → extract the interface name from parentheses
|
||||
3. **Primary strategy**: Find the resolver block in the **top section** (before "DNS configuration (for scoped queries)") whose `if_index` matches the default-route interface from Step 1. Use its nameservers.
|
||||
4. **Fallback strategy**: If no interface match is found, use the nameservers from `resolver #1` in the top section — this is the system's primary resolver and is almost always the correct one.
|
||||
5. Ignore the "scoped queries" section (below the second `DNS configuration` header) — these are duplicate entries for interface-scoped resolution.
|
||||
6. Ignore resolver blocks with `domain : local` and `options : mdns` — these are mDNS/Bonjour resolvers, not general-purpose DNS.
|
||||
|
||||
**Extracted values**: Ordered list of DNS server IPs.
|
||||
|
||||
### Alternatives Considered
|
||||
|
||||
1. **`networksetup -getdnsservers <service>`**: Returns manually configured DNS servers for a named network service (e.g., "Wi-Fi", "Thunderbolt Ethernet"). Problems: (a) Service names are potentially localized. (b) Only returns manually-set DNS servers — returns "There aren't any DNS Servers set on Wi-Fi." when DNS is DHCP-assigned. (c) Requires a separate `networksetup -listallhardwareports` call to map the interface device (e.g., `en0`) to a service name. Rejected for DHCP blindness and localization risk.
|
||||
|
||||
2. **`ipconfig getpacket <interface>`**: Returns the DHCP packet for an interface, including `domain_name_server` option with DNS IPs. Works for DHCP but does not work for statically configured DNS. Would need a separate path for static DNS. Rejected for complexity.
|
||||
|
||||
3. **Parse `/etc/resolv.conf` on macOS**: macOS does have `/etc/resolv.conf`, but it is managed by `configd` and may not reflect the actual DNS configuration accurately (especially with split DNS, VPN, or multiple interfaces). `scutil --dns` is the authoritative source on macOS. Rejected for accuracy concerns.
|
||||
|
||||
4. **`dns-sd -G v4 <hostname>`**: The `dns-sd` command performs mDNS/DNS-SD lookups, not general DNS configuration discovery. Not relevant. Rejected.
|
||||
|
||||
5. **System Configuration framework via CGo**: Could call `SCDynamicStoreCopyValue` directly for `State:/Network/Global/DNS`. Would require CGo and Objective-C bridging. Violates the `os/exec` approach constraint and adds build complexity. Rejected.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
| Case | Handling |
|
||||
|------|----------|
|
||||
| **DHCP configuration** | `scutil --dns` shows DHCP-assigned DNS servers identically to static ones. Transparent. |
|
||||
| **Manually set DNS** | `scutil --dns` shows manually configured DNS servers. Transparent. |
|
||||
| **VPN active** | VPN may add resolver blocks and change the default route. `route -n get default` will return the VPN gateway. `scutil --dns` will show VPN-specific resolvers. The correct behavior is to follow the default route — if VPN is active, its DNS is the right choice. |
|
||||
| **Wi-Fi + Ethernet both active** | `route -n get default` returns the active path (typically Ethernet due to lower metric). `scutil --dns` interface matching selects the correct resolver block. |
|
||||
| **No default route** | `route -n get default` outputs an error or no `gateway:` line. Return empty list. |
|
||||
| **No DNS configured** | `scutil --dns` shows resolver blocks with no nameserver lines. Return empty list. |
|
||||
| **mDNS-only resolver entries** | Filtered out by ignoring blocks with `domain : local` + `options : mdns`. |
|
||||
| **Split DNS (e.g., `search` domains)** | The tool uses the primary resolver block, not domain-specific blocks. This is correct for general-purpose DNS queries. |
|
||||
|
||||
---
|
||||
|
||||
## R-004: Gateway IP Discovery (All Platforms)
|
||||
|
||||
### Decision
|
||||
|
||||
Gateway IP is discovered as a byproduct of the default-route discovery command on each platform:
|
||||
|
||||
| Platform | Command | Gateway extraction |
|
||||
|----------|---------|-------------------|
|
||||
| Windows | `netsh interface ipv4 show route` | Gateway column of the `0.0.0.0/0` row |
|
||||
| Linux | `ip route show default` | The IP after `via` keyword |
|
||||
| macOS | `route -n get default` | The IP on the `gateway:` line |
|
||||
|
||||
No additional commands needed. The gateway IP is always available from the same command that identifies the default-route interface.
|
||||
|
||||
---
|
||||
|
||||
## R-005: Implementation Interface Design
|
||||
|
||||
### Decision
|
||||
|
||||
Define a `NetworkDiscoverer` interface in the `platform` package for testability. Each platform file implements it. Tests use a fake implementation.
|
||||
|
||||
```go
|
||||
// NetworkInfo holds discovered network configuration.
|
||||
type NetworkInfo struct {
|
||||
// DNSServers is the ordered list of DNS server IPs configured on the
|
||||
// default-route adapter. May be empty if discovery fails.
|
||||
DNSServers []string
|
||||
|
||||
// Gateway is the default gateway IP. May be empty if no default route.
|
||||
Gateway string
|
||||
|
||||
// Interface is the name of the adapter holding the default route.
|
||||
Interface string
|
||||
}
|
||||
|
||||
// NetworkDiscoverer discovers local network configuration.
|
||||
type NetworkDiscoverer interface {
|
||||
Discover() (NetworkInfo, error)
|
||||
}
|
||||
```
|
||||
|
||||
The implementations call `os/exec` internally. For unit tests, a `FakeNetworkDiscoverer` returns predetermined values, enabling deterministic testing of the resolver pool construction and mode dispatch logic without running real OS commands.
|
||||
|
||||
Platform-specific integration tests (build-tagged) can test the actual `os/exec` parsing against the real system — these run only on CI machines matching the target OS.
|
||||
|
||||
### Error Handling
|
||||
- Each `os/exec` call wraps errors with context: `fmt.Errorf("discovering default route: %w", err)`.
|
||||
- If the default-route command succeeds but returns no default route, return `NetworkInfo{}` with empty fields and a `nil` error — this is a valid state (FR-009).
|
||||
- If `os/exec` fails (command not found, permission denied), return `NetworkInfo{}` with an error. The caller (resolver pool construction) logs the error in verbose mode and proceeds with only the bootstrap resolver set.
|
||||
|
||||
---
|
||||
|
||||
## R-006: Summary of Recommended Commands
|
||||
|
||||
### Quick Reference
|
||||
|
||||
| Platform | Default Route + Gateway | DNS Servers | Commands |
|
||||
|----------|------------------------|-------------|----------|
|
||||
| **Windows** | `netsh interface ipv4 show route` → `0.0.0.0/0` row (Idx + Gateway) | `netsh interface ipv4 show interfaces` (Idx→Name) then `netsh interface ipv4 show dnsservers name="<Name>"` (IPs) | 3 commands |
|
||||
| **Linux** | `ip route show default` → `via <gw> dev <iface>` | `/etc/resolv.conf` (nameserver lines); if stub (`127.0.0.53`), `resolvectl status <iface>` | 1 command + 1 file read (+ 1 conditional command) |
|
||||
| **macOS** | `route -n get default` → `gateway:` + `interface:` lines | `scutil --dns` → nameserver lines in matching resolver block | 2 commands |
|
||||
|
||||
### Confidence Assessment
|
||||
|
||||
| Platform | Approach Confidence | Key Risk |
|
||||
|----------|-------------------|----------|
|
||||
| **Windows** | **High** — Verified on live system. `netsh` is the canonical non-PowerShell network CLI. Output format is stable across Windows versions. Numeric data is locale-independent. | `netsh` deprecated in future Windows versions (no announced timeline). |
|
||||
| **Linux** | **High** — `ip route` and `/etc/resolv.conf` are universal. `resolvectl` handles the systemd-resolved stub case. | `resolvectl` not present on non-systemd distros; mitigated by stub-address fallback being functional. |
|
||||
| **macOS** | **High** — `route` and `scutil` are stable system utilities. `scutil --dns` is the documented way to inspect DNS configuration. | Apple could change `scutil` output format in a future macOS version (no precedent for this). |
|
||||
@@ -0,0 +1,228 @@
|
||||
# Feature Specification: Smart DNS Server Resolution Modes
|
||||
|
||||
**Feature Branch**: `002-server-resolution-modes`
|
||||
**Created**: 2026-03-03
|
||||
**Updated**: 2026-03-04
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Extend the -server flag to support multiple resolution modes (local, gateway, default smart resolution) with parallel authoritative NS discovery, NXDOMAIN short-circuit, and smart error messages for internal hostnames."
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Smart Default Resolution (Priority: P1)
|
||||
|
||||
As a network engineer deploying services on client networks, I need the tool to resolve hostnames intelligently without requiring me to specify a DNS server. The tool should automatically find the best authoritative answer — whether the hostname is a public internet address or an internal network resource — by querying local and public resolvers in parallel to discover the authoritative nameservers, then querying the authority directly for the freshest answer.
|
||||
|
||||
When no `-server` flag is provided, the tool builds a resolver pool from locally configured DNS resolvers plus a hardcoded set of reliable public resolvers. It performs a parallel NS fan-out at every label level of the hostname across all resolvers in the pool simultaneously. From the results, it picks the most-specific nameserver delegation and queries the authoritative NS directly. If no NS records are found at any level (common for internal hosts without zone delegation), it falls back to a parallel A query against all resolvers and takes the first successful answer.
|
||||
|
||||
**Why this priority**: This is the core behavior of the tool. Most users should never need to think about which resolver to use. The tool should just work — for public domains it gets a fresh authoritative answer, and for internal domains it gets the answer from whichever resolver knows about it.
|
||||
|
||||
**Independent Test**: Can be fully tested by running `dns-helper add -host www.example.com` (no `-server` flag) and verifying the tool discovers the authoritative NS for `example.com`, queries it directly, and adds the resulting IP to the hosts file.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a public hostname `www.example.com` and both local and public resolvers are available, **When** the user runs `dns-helper add -host www.example.com`, **Then** the tool performs parallel NS lookups for `www.example.com` and `example.com` across all resolvers in the pool (local + public), identifies the most-specific NS, resolves one of those NS hostnames to an IP, queries that NS directly for the A record with recursion disabled, and adds the result to the hosts file.
|
||||
|
||||
2. **Given** a hostname with subdomain delegation `host1.sub.domain.example.com` where `sub.domain.example.com` has its own NS records, **When** the user runs add without `-server`, **Then** the tool discovers the subdomain delegation (more specific than `example.com`) and queries those delegated nameservers for the final A record.
|
||||
|
||||
3. **Given** an internal hostname `myservice.client.local` where no NS records exist at any label level, **When** the user runs add without `-server`, **Then** the NS fan-out returns no NS records from any resolver, so the tool falls back to a parallel A query across all resolvers in the pool. The local resolver returns the IP while public resolvers return NXDOMAIN. The tool takes the successful answer from the local resolver.
|
||||
|
||||
4. **Given** a hostname that resolves to a CNAME pointing to a different domain, **When** the user runs add without `-server`, **Then** the tool follows the CNAME chain, restarts the authoritative NS discovery for the CNAME target domain, and returns the freshest A record from the target's authority.
|
||||
|
||||
5. **Given** local DNS is completely down (all local resolvers timeout), **When** resolving a public hostname, **Then** the public resolvers in the pool still return NS records and the tool proceeds with authoritative resolution normally — no delay waiting for local resolvers since all queries run in parallel.
|
||||
|
||||
6. **Given** all resolvers in the pool return NXDOMAIN at every label level and the hostname uses a well-known private TLD (e.g., `.local`, `.internal`, `.lan`), **When** the fan-out completes, **Then** the tool provides a context-aware error message indicating the hostname appears to be internal but no resolver could resolve it, and suggests using `-server <ip>` to specify a known working internal resolver.
|
||||
|
||||
7. **Given** a hostname `app.acme.com` where the public authoritative NS returns `203.0.113.50` but a local resolver returns a different IP `10.0.5.100` (split-horizon / internal override), **When** the user runs `dns-helper add -host app.acme.com` (no `-server`), **Then** the tool detects the conflict between the authoritative answer and the local answer, does NOT write either IP to the hosts file, and reports an error listing both IPs with a suggestion to use `-server local` to trust internal DNS or `-server <ip>` to choose explicitly.
|
||||
|
||||
8. **Given** a hostname `app.acme.com` where the public authoritative NS returns `93.184.216.34` and the local resolver also returns `93.184.216.34` (no conflict), **When** the user runs add without `-server`, **Then** the tool proceeds normally — the local cross-check confirms agreement and the IP is written to the hosts file.
|
||||
|
||||
9. **Given** a hostname `app.acme.com` where the authoritative NS returns an IP but the local resolver returns NXDOMAIN (no local override), **When** the user runs add without `-server`, **Then** the tool proceeds normally — NXDOMAIN from local resolvers is not a conflict, it simply means the hostname is not overridden locally.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Local Resolver Mode (Priority: P2)
|
||||
|
||||
As a user deploying on a client network where internal DNS is the source of truth, I want to explicitly use only the locally configured DNS resolvers to ensure I get answers exclusively from the local DNS infrastructure — without any public resolver involvement.
|
||||
|
||||
**Why this priority**: Supports the internal network deployment use case where the user explicitly trusts the local DNS infrastructure and wants to guarantee that only it is consulted. This is distinct from default mode where all resolvers participate.
|
||||
|
||||
**Independent Test**: Can be tested by running `dns-helper add -host internal.client.local -server local` and verifying it queries only the locally configured resolvers in priority order.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a system with DNS resolvers configured (statically or via DHCP) on the adapter that holds the default route (0.0.0.0), **When** the user runs with `-server local`, **Then** the tool discovers those resolvers and queries them in order (primary, secondary, tertiary) with a standard A query.
|
||||
|
||||
2. **Given** the primary local resolver times out, **When** using `-server local`, **Then** the tool tries the secondary resolver, and then the tertiary.
|
||||
|
||||
3. **Given** all local resolvers are unreachable, **When** using `-server local`, **Then** the tool displays an error. The user explicitly chose local resolution, so it does NOT fall back to public resolvers.
|
||||
|
||||
4. **Given** the local adapter has resolvers configured via DHCP, **When** using `-server local`, **Then** those DHCP-assigned resolvers are discovered and used identically to statically configured ones.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Gateway Resolver Mode (Priority: P3)
|
||||
|
||||
As a user on a SOHO or SMB network where the network gateway also acts as a DNS forwarder, I want to send DNS queries directly to the gateway for resolution.
|
||||
|
||||
**Why this priority**: Common in small networks without dedicated DNS infrastructure. The gateway (router) typically provides DNS forwarding, and this is a quick way to use it directly.
|
||||
|
||||
**Independent Test**: Can be tested by running `dns-helper add -host www.example.com -server gateway` and verifying the query is sent to the default gateway IP.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a system with a default route via a gateway (e.g., 192.168.1.1), **When** the user runs with `-server gateway`, **Then** the tool discovers the gateway IP and sends the DNS query to it.
|
||||
|
||||
2. **Given** the gateway does not respond to DNS queries, **When** using `-server gateway`, **Then** the tool displays an appropriate error message without falling back to other resolvers.
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 - Explicit Server IP (Priority: P4)
|
||||
|
||||
As a user who knows exactly which DNS server to query (e.g., a newly deployed internal resolver), I want to specify it directly by IP address.
|
||||
|
||||
**Why this priority**: This is existing functionality that must continue to work identically.
|
||||
|
||||
**Independent Test**: Already tested by existing test suite.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a valid DNS server IP, **When** the user runs with `-server 10.0.0.53`, **Then** the query is sent to that IP (existing behavior, unchanged).
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- **What happens when a CNAME chain exceeds 10 hops?** The tool stops immediately and reports an error indicating a probable CNAME loop or misconfigured zone. No further DNS queries are issued.
|
||||
- **What happens when `-server` is given as `<ip>:<port>` and the port is invalid (e.g., 0 or >65535)?** The tool MUST reject the value immediately with a clear usage error before performing any DNS queries.
|
||||
- **What happens when the hostname has only two labels (e.g., `example.com`)?** The NS fan-out queries NS for `example.com` only (one level), since there is no parent domain to check below the TLD.
|
||||
- **What happens when a local resolver and a public resolver return different NS records for the same zone?** The tool collects results from all resolvers, deduplicates NS records, and picks the most-specific zone. If both return NS for the same zone, the records are merged and deduplicated.
|
||||
- **What happens when the authoritative NS itself is unreachable?** If the first NS is unreachable, the tool tries other NS records returned for the same zone. If all NS are unreachable, it reports an error indicating the authoritative servers are not responding.
|
||||
- **What happens when the NS fan-out returns no NS records at any label level?** The tool falls back to a parallel A query across all resolvers in the pool and takes the first successful answer. If all A queries also fail, it reports an error.
|
||||
- **What happens when a CNAME target is in a different domain with its own delegation?** The tool restarts the authoritative NS discovery process for the CNAME target domain, following the same parallel fan-out workflow.
|
||||
- **What happens when no local resolvers can be discovered?** The resolver pool consists of only the public bootstrap set. The tool proceeds normally — local resolver discovery failure is not an error for the default mode.
|
||||
- **What happens when the hostname is internal but uses a public TLD (e.g., `server1.corp.acme.com` where `corp.acme.com` is an internal zone)?** The local resolver knows about the internal zone delegation and returns NS records for it during the fan-out. The tool selects those NS records as the most specific. If local DNS is down, the public resolvers won't know about the internal zone, and the tool will get the public answer (or NXDOMAIN if the zone doesn't exist publicly) — this is the best possible outcome when local DNS is unavailable.
|
||||
- **What happens when a hostname uses a public TLD but the local network overrides it (split-horizon DNS, e.g., `app.acme.com` resolves to `10.0.5.100` locally but `203.0.113.50` publicly)?** After Stage 2 returns an authoritative answer, the tool performs a split-horizon cross-check: it queries only the local resolvers for the same hostname. If any local resolver returns a different IP than the authoritative answer, the tool reports a conflict and does NOT write either IP to the hosts file. The error message lists both IPs and suggests using `-server local` or `-server <ip>`. If the local resolver returns the same IP, NXDOMAIN, or fails, there is no conflict and the tool proceeds normally. This cross-check only runs in default mode (not `local`, `gateway`, or explicit IP modes) and only when local resolvers are available.
|
||||
- **What happens when the local resolver returns a different IP but it's a CNAME chain that ultimately resolves to the same IP as the authoritative answer?** The cross-check compares the final A-record IPs, not intermediate CNAME targets. If the IPs match after resolution, there is no conflict.
|
||||
- **What happens when the cross-check local query times out or fails?** A failed local query is treated as "no conflict" — the tool proceeds with the authoritative answer. Only a successful response with a different IP triggers the conflict error.
|
||||
- **How does the tool distinguish a connection failure from an NXDOMAIN response?** Connection failures (timeout, refused) mean the resolver was unreachable. NXDOMAIN means the resolver responded authoritatively that the name does not exist. During the parallel fan-out, both are simply collected as results — the selection logic considers only successful NS responses. During the Stage 3 parallel A fallback, NXDOMAIN responses are skipped and only successful A responses are used.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
#### Server Mode Selection
|
||||
|
||||
- **FR-001**: The `-server` flag MUST accept the following values: an IP address (existing), `local`, `gateway`, or be omitted entirely.
|
||||
- **FR-002**: When `-server` is omitted, the system MUST perform smart default resolution: parallel NS fan-out across all resolvers and all label levels, authoritative NS query, with a parallel A query fallback when no NS records are found.
|
||||
- **FR-003**: When `-server local` is specified, the system MUST use only the DNS resolvers configured on the network adapter that holds the default route (0.0.0.0), querying each in priority order with standard A queries.
|
||||
- **FR-004**: When `-server gateway` is specified, the system MUST send the DNS query to the default gateway IP of the adapter that holds the default route.
|
||||
- **FR-005**: When `-server <ip>` or `-server <ip>:<port>` is specified, the system MUST send the DNS query to that IP address and port. When no port is provided, port 53 MUST be used. This extends existing behavior while remaining backward-compatible.
|
||||
- **FR-006**: The `-server`, `-timeout`, and `-verbose` flags MUST be available on every command that performs DNS resolution. New commands added in future that perform DNS resolution MUST also expose these flags.
|
||||
|
||||
#### Resolver Pool
|
||||
|
||||
- **FR-007**: The system MUST maintain a hardcoded bootstrap resolver set consisting of (in order): 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).
|
||||
- **FR-008**: When using default resolution (no `-server` flag), the system MUST build a resolver pool by combining locally discovered DNS resolvers with the bootstrap resolver set.
|
||||
- **FR-009**: If local resolver discovery fails (e.g., no default route adapter found), the resolver pool MUST consist of only the bootstrap resolver set, and the tool MUST proceed without error.
|
||||
|
||||
#### Local Resolver and Gateway Discovery
|
||||
|
||||
- **FR-010**: The system MUST discover DNS resolvers configured on the network adapter that holds the default route (0.0.0.0), regardless of whether the adapter is configured statically or via DHCP.
|
||||
- **FR-011**: Local resolvers MUST be returned in priority order (primary, secondary, tertiary).
|
||||
- **FR-012**: The system MUST discover the default gateway IP for the adapter that holds the default route.
|
||||
|
||||
#### Default Resolution — Stage 1: Parallel NS Fan-out
|
||||
|
||||
- **FR-013**: The system MUST perform parallel NS lookups for every label level of the specified hostname, excluding TLDs and the root. For `host1.sub.domain.example.com`, the system queries NS for: `host1.sub.domain.example.com`, `sub.domain.example.com`, `domain.example.com`, and `example.com`.
|
||||
- **FR-014**: The parallel NS lookups MUST be sent simultaneously to every resolver in the pool, across every label level. Each combination of (label level × resolver) runs as a concurrent operation.
|
||||
- **FR-015**: The system MUST select the NS records from the most-specific label level (longest name) that returned valid NS records from any resolver in the pool.
|
||||
- **FR-016**: Each NS result collected MUST include the queried label level, the resolver that was queried, and the NS records returned (if any), so the selection logic can evaluate specificity.
|
||||
|
||||
#### Default Resolution — Stage 2: Authoritative Query
|
||||
|
||||
- **FR-017**: After identifying the authoritative NS from Stage 1, the system MUST resolve one of the returned NS hostnames to an IP address using the resolver pool.
|
||||
- **FR-018**: The system MUST send the final A-record query to the authoritative NS with recursion disabled.
|
||||
- **FR-019**: If the authoritative answer is a CNAME pointing to a different domain, the system MUST restart the full resolution process (Stage 1) for the CNAME target domain. The system MUST NOT follow more than 10 CNAME hops in a single resolution; if this limit is exceeded, the tool MUST report an error indicating a probable CNAME loop.
|
||||
- **FR-020**: If the first authoritative NS is unreachable, the system MUST try other NS records from the same zone before reporting failure.
|
||||
|
||||
#### Default Resolution — Stage 2.5: Split-Horizon Conflict Detection
|
||||
|
||||
- **FR-032**: After Stage 2 returns an authoritative A-record answer, and when local resolvers are available in the resolver pool, the system MUST perform a split-horizon cross-check by querying only the local resolvers for the same hostname with a standard A query.
|
||||
- **FR-033**: The cross-check query SHOULD run in parallel with Stage 2 (fired at the same time or immediately after Stage 1 completes) so that it adds no additional wall-clock latency to the happy path.
|
||||
- **FR-034**: If any local resolver returns a successful A-record response with a different set of IPs than the authoritative answer from Stage 2, the system MUST report a conflict error and MUST NOT write either IP to the hosts file.
|
||||
- **FR-035**: The conflict error message MUST include both the authoritative IP(s) and the local IP(s), identify which resolver provided each, and suggest using `-server local` to trust the internal DNS or `-server <ip>` to choose a specific resolver.
|
||||
- **FR-036**: If the local cross-check returns the same IP(s) as the authoritative answer, returns NXDOMAIN, or fails (timeout, connection error), the system MUST treat this as "no conflict" and proceed normally with the authoritative answer.
|
||||
- **FR-037**: The split-horizon cross-check MUST only run in default mode (no `-server` flag). It MUST NOT run in `local`, `gateway`, or explicit IP modes — those modes represent an explicit user choice and should not second-guess the result.
|
||||
- **FR-038**: The split-horizon cross-check MUST NOT run when no local resolvers were discovered (i.e., the resolver pool contains only bootstrap resolvers). In this case, there is no local DNS to conflict with.
|
||||
|
||||
#### Default Resolution — Stage 3: Parallel A Query Fallback
|
||||
|
||||
- **FR-021**: If Stage 1 returns no NS records at any label level from any resolver, the system MUST fall back to a parallel A query for the hostname across all resolvers in the pool.
|
||||
- **FR-022**: The system MUST use the first successful A-record response from the parallel query. NXDOMAIN responses and connection failures are ignored in favor of any successful answer.
|
||||
- **FR-023**: If all resolvers return NXDOMAIN or fail, the system MUST report an error.
|
||||
|
||||
#### Timeout Configuration
|
||||
|
||||
- **FR-028**: The `-timeout` flag MUST accept a positive integer value representing the per-query DNS timeout in seconds. The default value is 3 seconds when the flag is omitted.
|
||||
- **FR-029**: The configured timeout MUST apply uniformly to every DNS query issued by the tool — NS fan-out queries (Stage 1), authoritative NS resolution (Stage 2), parallel A fallback queries (Stage 3), and queries made in `-server local` and `-server gateway` modes.
|
||||
|
||||
#### Verbose Output
|
||||
|
||||
- **FR-030**: By default the tool MUST produce no diagnostic output — only the final result or an error message is printed. This ensures output is safe for scripting and automation.
|
||||
- **FR-031**: When the `-verbose` flag is provided, the tool MUST emit a per-stage resolution trace to stderr, including: resolvers in the pool, NS records found at each label level, the authoritative NS selected, whether Stage 3 fallback was triggered, and any CNAME redirects followed.
|
||||
|
||||
#### Error Messages and Private TLD Detection
|
||||
|
||||
- **FR-024**: The system MUST detect well-known private TLDs (`.local`, `.internal`, `.lan`, `.home`, `.corp`, `.private`) in the target hostname.
|
||||
- **FR-025**: When resolution fails entirely and the hostname uses a private TLD, the error message MUST indicate the hostname appears to be internal and suggest using `-server <ip>` with a known internal resolver.
|
||||
- **FR-026**: When using `-server local` and all local resolvers are unreachable, the system MUST NOT fall back to public resolvers and MUST report an error.
|
||||
- **FR-027**: When using `-server gateway` and the gateway does not respond to DNS queries, the system MUST report an appropriate error without fallback.
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **Server Mode**: The resolution strategy selected by the user — one of: explicit IP, `local`, `gateway`, or default (omitted). Determines which resolvers are queried and the resolution pattern used.
|
||||
- **Resolver Pool**: The combined set of all local resolvers and public bootstrap resolvers, built at runtime for default resolution. All members are queried in parallel during fan-out operations.
|
||||
- **Bootstrap Resolver Set**: An ordered list of six well-known public DNS resolvers across four independent providers (Cloudflare, Google, Quad9, OpenDNS). Always available regardless of local network state.
|
||||
- **Local Resolver List**: An ordered list of DNS server IPs configured on the network adapter that holds the default route. Discovered at runtime from the operating system. May be empty if discovery fails.
|
||||
- **NS Fan-out Result**: The response from a single NS query during Stage 1 discovery. Contains the queried label level, the resolver that was queried, and the returned NS records (if any). Collected from parallel operations for evaluation.
|
||||
- **Authoritative NS**: The nameserver identified as the most-specific authority for the target hostname. The final A-record query is sent here with recursion disabled.
|
||||
- **Per-Query Timeout**: The maximum time the tool waits for any single DNS query response before treating the resolver as non-responsive. Defaults to 3 seconds; configurable via the `-timeout` flag.
|
||||
- **Verbose Mode**: An opt-in diagnostic mode activated by the `-verbose` flag. Emits a per-stage resolution trace to stderr without affecting stdout output used by callers.
|
||||
- **Split-Horizon Conflict**: A state in default mode where the authoritative answer (from Stage 2) differs from the local resolver's answer for the same hostname. Indicates the hostname exists in both public DNS and local DNS with different IPs — typically caused by split-horizon DNS, internal overrides, or conditional forwarding. The tool cannot determine which IP the user intended, so it reports both and asks the user to choose explicitly.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: When no `-server` flag is provided for a public hostname, the resolved IP matches the IP returned by directly querying the hostname's authoritative nameserver, confirming the tool bypasses intermediate caches.
|
||||
- **SC-002**: The parallel NS fan-out completes within the latency of the slowest single resolver response — wall-clock time does not grow linearly with the number of resolvers or label levels queried.
|
||||
- **SC-003**: When no `-server` flag is provided, the tool resolves both public hostnames (e.g., `www.google.com`) and internal hostnames (when local DNS is functional) without any additional flags.
|
||||
- **SC-004**: When local DNS is down and a public hostname is specified without `-server`, the tool still resolves because public resolvers in the pool return NS records and the authoritative path succeeds.
|
||||
- **SC-005**: When resolution fails for a hostname with a private TLD, the user receives an actionable error message within 10 seconds.
|
||||
- **SC-006**: The `-server local` mode discovers and uses all configured resolvers (primary, secondary, tertiary) on both statically configured and DHCP-configured adapters.
|
||||
- **SC-007**: The `-server gateway` mode correctly identifies and queries the default gateway even when the gateway IP changes between sessions.
|
||||
- **SC-008**: Subdomain delegation is correctly detected — when a subdomain has its own NS records, the default resolution queries those NS servers rather than the parent domain's NS servers.
|
||||
- **SC-009**: Internal hostnames without NS delegation (Stage 3 fallback) resolve within the same timeout window as hostnames with NS records — the fallback does not add meaningful delay because all queries run in parallel.
|
||||
- **SC-010**: When a hostname resolves to different IPs via authoritative DNS vs. local DNS (split-horizon), the tool detects the conflict and reports both IPs without writing either to the hosts file.
|
||||
- **SC-011**: The split-horizon cross-check adds no measurable latency to the happy path (no conflict) because it runs in parallel with Stage 2.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- The network adapter holding the default route (0.0.0.0) is the correct adapter to inspect for local resolvers and gateway — multi-homed configurations with multiple default routes are out of scope.
|
||||
- The bootstrap resolver set (Cloudflare, Google, Quad9, OpenDNS) provides sufficient resilience for the vast majority of scenarios; full iterative resolution from root hints is deferred to a future enhancement.
|
||||
- DNS queries use UDP only (no TCP fallback), consistent with the existing resolver behavior.
|
||||
- The tool runs with sufficient OS permissions to query adapter configuration (DNS servers, gateway IP) on Windows, Linux, and macOS.
|
||||
- Private TLD detection uses a static list of well-known private TLDs; custom internal TLDs using public suffixes (e.g., `.com` used internally) are not automatically detected but will still be resolved correctly by local resolvers participating in the pool.
|
||||
- When no NS records are found during Stage 1, the fallback to parallel A queries (Stage 3) is appropriate — this primarily covers internal hosts in environments without zone delegation (common in small AD or dnsmasq setups).
|
||||
- The per-query DNS timeout default is 3 seconds. This fits within the 10-second error window stated in SC-005 and aligns with OS resolver defaults. Users may override this with the `-timeout` flag.
|
||||
|
||||
## Clarifications
|
||||
|
||||
### Session 2026-03-04
|
||||
|
||||
- Q: What is the per-query DNS timeout, and should it be user-configurable? → A: 3 seconds default; expose a `-timeout <seconds>` flag so users can override it.
|
||||
- Q: Should the tool emit diagnostic output during multi-stage resolution? → A: Silent by default; `-verbose` flag emits per-stage trace (resolvers queried, NS selected, fallbacks triggered) to stderr.
|
||||
- Q: Should `-server <ip>` support a non-standard port? → A: Accept `<ip>` or `<ip>:<port>`; default to port 53 when port is omitted. Invalid port values are rejected with a usage error.
|
||||
- Q: Should CNAME chain following have a depth limit? → A: Hard limit of 10 CNAME hops; report an error indicating a probable loop if exceeded.
|
||||
- Q: Which commands does the new resolution behavior (`-server`, `-timeout`, `-verbose`) apply to? → A: All commands that perform DNS resolution, current and future.
|
||||
- Q: What happens when the local network overrides a public hostname (split-horizon DNS)? → A: After Stage 2 gets an authoritative answer, the tool cross-checks against local resolvers. If local resolvers return a different IP, the tool reports a conflict with both IPs and suggests `-server local` or `-server <ip>`. A ping-based tiebreaker was considered and rejected because ICMP is widely blocked, requires elevated privileges on Linux, and reachability ≠ correctness. The A-record comparison is sufficient — the divergence itself is the signal.
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user