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

494 lines
29 KiB
Markdown
Raw Normal View History

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