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

1672 lines
89 KiB
Markdown
Raw Normal View History

# Tasks: Smart DNS Server Resolution Modes
**Input**: Design documents from `/specs/002-server-resolution-modes/`
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/cli.md, contracts/packages.md, quickstart.md
**Tests**: Tests follow TDD (Red-Green-Refactor) per Constitution Principle VI and copilot-instructions.md. Test tasks are included for all new code.
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
**Self-Containment**: Tasks will be executed by a DIFFERENT model than the one generating them. Every non-trivial task includes an indented **Context** block with existing code references, target file state, expected implementation pattern, key decisions/gotchas, and acceptance signals.
**Toolchain**: Always use `go1.20` instead of `go` for all Go toolchain operations (see copilot-instructions.md).
## Format: `[ID] [P?] [Story] Description`
- **[P]**: Can run in parallel (different files, no dependencies)
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
- Include exact file paths in descriptions
---
## Phase 1: Setup
**Purpose**: Create new files and foundational types needed before any user story implementation.
**Phase Context**:
- **Files modified in this phase**: `resolver/parse.go` (new — flag parsing and label extraction), `resolver/parse_test.go` (new — tests for pure functions), `platform/network.go` (new — `NetworkInfo` type, `NetworkDiscoverer` interface, `FakeNetworkDiscoverer`), `platform/network_test.go` (new — tests for fake discoverer and NetworkInfo)
- **Key types/interfaces used**: None yet — this phase creates foundational types
- **Codebase conventions to follow**: Go 1.20 only. Table-driven tests preferred. Error wrapping with `fmt.Errorf("context: %w", err)`. Package per directory, build tags for platform code. Test files use `_test` suffix in same package or `_test` package. See `resolver/resolver_test.go` for fake DNS test patterns and `platform/platform_test.go` for platform test patterns.
---
### Parsing Utilities
- [X] T001 [P] Create `ParseServerFlag()` and `ExtractLabelLevels()` tests in `resolver/parse_test.go`
**Context**:
- **Target file state**: `resolver/parse_test.go` does not exist yet. The `resolver/` directory already contains `resolver.go` and `resolver_test.go`. The test file should use `package resolver_test` (external test package) matching the convention in `resolver/resolver_test.go`.
- **Expected implementation pattern**: Use table-driven tests following the existing project convention. Write tests for:
- `ParseServerFlag("")``ServerMode{Mode: "default"}`, no error
- `ParseServerFlag("local")``ServerMode{Mode: "local"}`, no error
- `ParseServerFlag("LOCAL")``ServerMode{Mode: "local"}`, no error (case-insensitive)
- `ParseServerFlag("gateway")``ServerMode{Mode: "gateway"}`, no error
- `ParseServerFlag("GATEWAY")``ServerMode{Mode: "gateway"}`, no error
- `ParseServerFlag("10.0.0.53")``ServerMode{Mode: "explicit", ExplicitAddr: "10.0.0.53"}`, no error
- `ParseServerFlag("10.0.0.53:5353")``ServerMode{Mode: "explicit", ExplicitAddr: "10.0.0.53:5353"}`, no error
- `ParseServerFlag("10.0.0.53:0")` → error containing "port must be between 1 and 65535"
- `ParseServerFlag("10.0.0.53:99999")` → error containing "port"
- `ParseServerFlag("notanip")` → error
- `ExtractLabelLevels("host1.sub.domain.example.com")``["host1.sub.domain.example.com", "sub.domain.example.com", "domain.example.com", "example.com"]`
- `ExtractLabelLevels("www.example.com")``["www.example.com", "example.com"]`
- `ExtractLabelLevels("example.com")``["example.com"]`
- `ExtractLabelLevels("localhost")``nil`
- `ExtractLabelLevels("example.com.")``["example.com"]` (trailing dot stripped)
- **Key decisions and gotchas**: `ParseServerFlag` keywords are case-insensitive per CLI contract. Port validation must reject 0 and >65535. `ExtractLabelLevels` must strip trailing dot before splitting. Single-label hostnames return nil. The types `ServerMode` is defined in `resolver/parse.go` (T002).
- **Acceptance signal**: Tests compile but FAIL because `resolver/parse.go` does not exist yet. `go1.20 test ./resolver/... -run TestParseServerFlag` and `go1.20 test ./resolver/... -run TestExtractLabelLevels` should show compilation errors.
- [X] T002 [P] Create `ParseServerFlag()`, `ExtractLabelLevels()`, and types in `resolver/parse.go`
**Context**:
- **Target file state**: `resolver/parse.go` does not exist yet. The `resolver/` package currently contains `resolver.go` (which defines the `Resolver` interface and `DNSResolver` struct).
- **Expected implementation pattern**: Define types and implement pure functions:
```go
package resolver
import (
"fmt"
"net"
"strconv"
"strings"
)
// 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
}
// ParseServerFlag parses the -server flag value into a ServerMode.
func ParseServerFlag(value string) (ServerMode, error) {
// empty → default
// "local"/"gateway" (case-insensitive) → keyword modes
// valid IP → explicit
// valid IP:port → explicit (validate port 1-65535)
// anything else → error
}
// ExtractLabelLevels returns all queryable domain levels from a hostname,
// from most-specific to least-specific, excluding single-label TLDs.
func ExtractLabelLevels(hostname string) []string {
// Strip trailing dot, split on ".", generate suffixes with ≥2 labels
}
```
- **Key decisions and gotchas**:
- Use `strings.ToLower` for case-insensitive keyword matching.
- For IP:port parsing, use `net.SplitHostPort` which handles the colon splitting. If it fails (no port), try `net.ParseIP` directly.
- Port validation: `strconv.Atoi` then check 1 ≤ port ≤ 65535.
- For `ExtractLabelLevels`: strip trailing dot first, then `strings.Split(hostname, ".")`. Generate all suffixes with ≥2 parts. Return nil for single-label hostnames.
- Must also define `BootstrapResolvers`, `PrivateTLDs`, and `maxCNAMEDepth` constants here (FR-007, FR-024, FR-019):
```go
var BootstrapResolvers = []string{
"1.1.1.1", "8.8.8.8", "1.0.0.1", "8.8.4.4", "9.9.9.9", "208.67.222.222",
}
var PrivateTLDs = map[string]bool{
"local": true, "internal": true, "lan": true,
"home": true, "corp": true, "private": true,
}
// maxCNAMEDepth is the maximum number of CNAME hops before aborting (FR-019).
const maxCNAMEDepth = 10
```
- **Acceptance signal**: `go1.20 test ./resolver/... -run TestParseServerFlag` and `go1.20 test ./resolver/... -run TestExtractLabelLevels` all pass. `go1.20 vet ./resolver/...` clean.
---
### Platform Network Discovery Interface
- [X] T003 [P] Create `NetworkInfo`, `NetworkDiscoverer` interface, and `FakeNetworkDiscoverer` in `platform/network.go`
**Context**:
- **Target file state**: `platform/network.go` does not exist yet. The `platform/` package currently contains `platform.go` (package doc only: `// Package platform provides the platform-specific hosts file path.`) and three platform-specific files (`platform_windows.go`, `platform_linux.go`, `platform_darwin.go`) that each define only `GetHostsFilePath()`. No build tags on `network.go` — it is shared across all platforms.
- **Expected implementation pattern**:
```go
package platform
// 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 default route (informational)
}
// NetworkDiscoverer discovers local network configuration.
type NetworkDiscoverer interface {
Discover() (NetworkInfo, error)
}
// FakeNetworkDiscoverer returns predetermined NetworkInfo for testing.
type FakeNetworkDiscoverer struct {
Info NetworkInfo
Err error
}
func (f *FakeNetworkDiscoverer) Discover() (NetworkInfo, error) {
return f.Info, f.Err
}
```
- **Key decisions and gotchas**: No build tag on this file — types are shared. `FakeNetworkDiscoverer` is exported so other packages (like `resolver`) can use it in tests. All fields in `NetworkInfo` may be empty (per FR-009, empty `DNSServers` is not an error).
- **Acceptance signal**: `go1.20 build ./platform/...` succeeds. `go1.20 vet ./platform/...` clean.
- [X] T004 [P] Create tests for `FakeNetworkDiscoverer` in `platform/network_test.go`
**Context**:
- **Target file state**: `platform/network_test.go` does not exist yet. `platform/platform_test.go` already exists with tests for `GetHostsFilePath()` using `package platform_test`.
- **Expected implementation pattern**: Simple tests verifying the fake returns expected values:
```go
package platform_test
import (
"errors"
"testing"
"dns-helper/platform"
)
func TestFakeNetworkDiscoverer_ReturnsInfo(t *testing.T) {
fake := &platform.FakeNetworkDiscoverer{
Info: platform.NetworkInfo{
DNSServers: []string{"10.0.0.1", "10.0.0.2"},
Gateway: "10.0.0.1",
Interface: "eth0",
},
}
info, err := fake.Discover()
if err != nil { t.Fatalf("unexpected error: %v", err) }
if len(info.DNSServers) != 2 { t.Errorf("expected 2 DNS servers, got %d", len(info.DNSServers)) }
// ... verify gateway, interface
}
func TestFakeNetworkDiscoverer_ReturnsError(t *testing.T) {
fake := &platform.FakeNetworkDiscoverer{
Err: errors.New("discovery failed"),
}
_, err := fake.Discover()
if err == nil { t.Fatal("expected error") }
}
```
- **Acceptance signal**: `go1.20 test ./platform/... -run TestFakeNetworkDiscoverer` passes.
**Checkpoint**: Foundational types (`ServerMode`, `QueryConfig`, `NetworkInfo`, `NetworkDiscoverer`, `FakeNetworkDiscoverer`, `ParseServerFlag`, `ExtractLabelLevels`, `BootstrapResolvers`, `PrivateTLDs`, `maxCNAMEDepth`) are defined and tested. All subsequent phases depend on these.
---
### Platform Constructor (Stub)
- [X] T004b [P] Create stub `NewNetworkDiscoverer()` constructors in `platform/discoverer_<os>.go`
**Context**:
- **Target file state**: Three new files needed, one per platform, each with a build tag:
- `platform/discoverer_windows.go` (`//go:build windows`)
- `platform/discoverer_linux.go` (`//go:build linux`)
- `platform/discoverer_darwin.go` (`//go:build darwin`)
- **Expected implementation pattern**: Each file returns a stub discoverer that returns empty `NetworkInfo`. This enables `main.go` (T019) to compile in Phase 3 before the real platform implementations arrive in Phase 4.
```go
//go:build windows // (or linux, darwin)
package platform
// stubNetworkDiscoverer is a temporary placeholder until the real
// platform-specific discoverer is implemented in Phase 4.
type stubNetworkDiscoverer struct{}
func (s *stubNetworkDiscoverer) Discover() (NetworkInfo, error) {
return NetworkInfo{}, nil
}
// NewNetworkDiscoverer returns the platform-specific NetworkDiscoverer.
// This stub returns empty NetworkInfo; replaced by real implementation in T020-T022.
func NewNetworkDiscoverer() NetworkDiscoverer {
return &stubNetworkDiscoverer{}
}
```
- **Key decisions and gotchas**: The stub returns empty `NetworkInfo` with no error. Per FR-009, the resolver pool construction handles empty local resolvers gracefully (falls back to bootstrap-only pool). The real implementations in T020-T022 will replace `stubNetworkDiscoverer` with `WindowsNetworkDiscoverer` etc., and T023 will update `NewNetworkDiscoverer()` to return the real type.
- **Acceptance signal**: `go1.20 build .` succeeds on the current platform. `main.go` can call `platform.NewNetworkDiscoverer()`.
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Shared transport layer and resolver pool construction that ALL user stories depend on. MUST complete before any user story work begins.
**⚠️ CRITICAL**: No user story work can begin until this phase is complete.
**Phase Context**:
- **Files modified in this phase**: `resolver/transport.go` (new — shared UDP query function), `resolver/transport_test.go` (new — tests for UDP transport), `resolver/pool.go` (new — `BuildResolverPool()` function), `resolver/pool_test.go` (new — tests for pool construction)
- **Key types/interfaces used**:
- `ServerMode` from `resolver/parse.go`: `type ServerMode struct { Mode string; ExplicitAddr string }`
- `NetworkInfo` from `platform/network.go`: `type NetworkInfo struct { DNSServers []string; Gateway string; Interface string }`
- `BootstrapResolvers` from `resolver/parse.go`: `var BootstrapResolvers = []string{"1.1.1.1", "8.8.8.8", ...}`
- **Codebase conventions to follow**: Existing `resolver.go` uses `net.DialTimeout("udp", addr, defaultTimeout)` for connections and `dnsmessage` for DNS wire protocol. See `resolver.go` lines 74-93 for the UDP dial+write+read pattern. Tests use `startFakeDNS`/`startFakeDNSMulti` helpers from `resolver_test.go`.
---
### Shared Transport Layer
- [X] T005 [P] Create tests for shared UDP query transport in `resolver/transport_test.go`
**Context**:
- **Target file state**: `resolver/transport_test.go` does not exist. The existing `resolver/resolver_test.go` contains `startFakeDNS`, `startFakeDNSMulti`, `queryID`, `buildAResponse`, `buildCNAMEResponse`, `buildNXDOMAINResponse`, and `mustNewName` helpers. These helpers are in `package resolver_test` (external test package). The new transport test file should also use `package resolver_test`.
- **Expected implementation pattern**: Test `UDPQuery` function that sends a raw DNS query and returns raw bytes:
```go
func TestUDPQuery_Success(t *testing.T) {
// Start a fake DNS server that echoes back a valid A response
// Call resolver.UDPQuery(ctx, addr, queryBytes, timeout)
// Assert: response bytes received, no error
}
func TestUDPQuery_Timeout(t *testing.T) {
// Start a fake DNS server that never responds
// Call UDPQuery with a short timeout
// Assert: error contains timeout information
}
func TestUDPQuery_InvalidAddress(t *testing.T) {
// Call UDPQuery with an unreachable address
// Assert: error returned
}
```
- **Key decisions and gotchas**: The `UDPQuery` function accepts a `context.Context` for cancellation. It uses `net.Dialer.DialContext()` (available since Go 1.7) instead of `net.DialTimeout` so the context can propagate cancellation. Timeout is set via `conn.SetDeadline()` derived from context deadline. Must reuse the `startFakeDNS` pattern from `resolver_test.go` — but since that's in `package resolver_test`, the new test file can access those helpers directly if in the same package, or they need to be duplicated/extracted. Since both files are `package resolver_test`, helpers are shared.
- **Acceptance signal**: Tests compile but FAIL because `resolver/transport.go` does not exist yet.
- [X] T006 [P] Implement shared UDP query transport in `resolver/transport.go`
**Context**:
- **Existing code references**: The existing UDP transport pattern in `resolver/resolver.go` (lines 74-93):
```go
addr := server
if !strings.Contains(server, ":") {
addr = server + ":53"
}
conn, err := net.DialTimeout("udp", addr, defaultTimeout)
if err != nil {
return nil, fmt.Errorf("connecting to DNS server %s: %w", server, err)
}
defer conn.Close()
conn.SetDeadline(time.Now().Add(defaultTimeout))
if _, err := conn.Write(packed); err != nil {
return nil, fmt.Errorf("sending DNS query to %s: %w", server, err)
}
buf := make([]byte, udpBufSize)
n, err := conn.Read(buf)
if err != nil {
return nil, fmt.Errorf("reading DNS response from %s: %w", server, err)
}
```
- **Target file state**: `resolver/transport.go` does not exist. This extracts the UDP dial/write/read pattern into a reusable function.
- **Expected implementation pattern**:
```go
package resolver
import (
"context"
"fmt"
"net"
"strings"
"time"
)
// udpQuery sends a raw DNS query to addr over UDP and returns the raw response.
// addr may be "ip" or "ip:port" — ":53" is appended if no port specified.
// The timeout parameter sets the per-query deadline. The ctx allows cancellation.
func udpQuery(ctx context.Context, addr string, query []byte, timeout time.Duration) ([]byte, error) {
if !strings.Contains(addr, ":") {
addr = addr + ":53"
}
dialer := net.Dialer{Timeout: timeout}
conn, err := dialer.DialContext(ctx, "udp", addr)
if err != nil {
return nil, fmt.Errorf("connecting to %s: %w", addr, err)
}
defer conn.Close()
deadline, ok := ctx.Deadline()
if !ok {
deadline = time.Now().Add(timeout)
}
conn.SetDeadline(deadline)
if _, err := conn.Write(query); err != nil {
return nil, fmt.Errorf("sending query to %s: %w", addr, err)
}
buf := make([]byte, udpBufSize) // udpBufSize = 1232 from resolver.go
n, err := conn.Read(buf)
if err != nil {
return nil, fmt.Errorf("reading response from %s: %w", addr, err)
}
return buf[:n], nil
}
```
- **Key decisions and gotchas**: Use `net.Dialer.DialContext()` (Go 1.7+) for context-aware dialing. The function is unexported (`udpQuery`) since it's internal to the `resolver` package. Uses `udpBufSize` constant already defined in `resolver.go` (1232 bytes). The existing `lookupIPWithDepth` in `resolver.go` is NOT refactored to use this yet — that would be a separate task if desired, but keeping existing code unchanged is safer.
- **Acceptance signal**: `go1.20 test ./resolver/... -run TestUDPQuery` passes.
---
### Resolver Pool Construction
- [X] T007 [P] Create tests for `BuildResolverPool()` in `resolver/pool_test.go`
**Context**:
- **Target file state**: `resolver/pool_test.go` does not exist.
- **Expected implementation pattern**: Table-driven tests covering all modes:
```go
func TestBuildResolverPool(t *testing.T) {
tests := []struct {
name string
mode resolver.ServerMode
info platform.NetworkInfo
expected []string
}{
{
name: "default mode with local resolvers",
mode: resolver.ServerMode{Mode: "default"},
info: platform.NetworkInfo{DNSServers: []string{"10.0.0.1", "10.0.0.2"}},
expected: []string{"10.0.0.1", "10.0.0.2", "1.1.1.1", "8.8.8.8", "1.0.0.1", "8.8.4.4", "9.9.9.9", "208.67.222.222"},
},
{
name: "default mode deduplicates",
mode: resolver.ServerMode{Mode: "default"},
info: platform.NetworkInfo{DNSServers: []string{"8.8.8.8", "10.0.0.1"}},
expected: []string{"8.8.8.8", "10.0.0.1", "1.1.1.1", "1.0.0.1", "8.8.4.4", "9.9.9.9", "208.67.222.222"},
},
{
name: "default mode no local resolvers",
mode: resolver.ServerMode{Mode: "default"},
info: platform.NetworkInfo{},
expected: resolver.BootstrapResolvers,
},
{
name: "local mode",
mode: resolver.ServerMode{Mode: "local"},
info: platform.NetworkInfo{DNSServers: []string{"10.0.0.1"}},
expected: []string{"10.0.0.1"},
},
{
name: "gateway mode",
mode: resolver.ServerMode{Mode: "gateway"},
info: platform.NetworkInfo{Gateway: "192.168.1.1"},
expected: []string{"192.168.1.1"},
},
{
name: "explicit mode",
mode: resolver.ServerMode{Mode: "explicit", ExplicitAddr: "10.0.0.53:5353"},
info: platform.NetworkInfo{},
expected: []string{"10.0.0.53:5353"},
},
}
// ... run table tests
}
```
- **Key decisions and gotchas**: Deduplication must preserve order (local resolvers first). If a local resolver matches a bootstrap resolver, the bootstrap duplicate is removed (the local one stays at its earlier position).
- **Acceptance signal**: Tests compile but FAIL because `resolver/pool.go` does not exist yet.
- [X] T008 [P] Implement `BuildResolverPool()` in `resolver/pool.go`
**Context**:
- **Depends on**: `ServerMode` from `resolver/parse.go` (T002), `NetworkInfo` from `platform/network.go` (T003), `BootstrapResolvers` from `resolver/parse.go` (T002).
- **Target file state**: `resolver/pool.go` does not exist.
- **Expected implementation pattern**:
```go
package resolver
import "dns-helper/platform"
// BuildResolverPool constructs the resolver list for the given mode.
func BuildResolverPool(mode ServerMode, info platform.NetworkInfo) []string {
switch mode.Mode {
case "default":
// Concatenate local + bootstrap, deduplicate preserving order
seen := make(map[string]bool)
var pool []string
for _, r := range info.DNSServers {
if !seen[r] { seen[r] = true; pool = append(pool, r) }
}
for _, r := range BootstrapResolvers {
if !seen[r] { seen[r] = true; pool = append(pool, r) }
}
return pool
case "local":
return info.DNSServers
case "gateway":
return []string{info.Gateway}
case "explicit":
return []string{mode.ExplicitAddr}
default:
return nil
}
}
```
- **Key decisions and gotchas**: Returns `nil` for unknown modes (caller handles). Deduplication uses a `map[string]bool` with order-preserving iteration. No error return — the pool may contain empty strings for gateway mode if gateway wasn't discovered; the caller validates.
- **Acceptance signal**: `go1.20 test ./resolver/... -run TestBuildResolverPool` passes. `go1.20 vet ./resolver/...` clean.
**Checkpoint**: Foundation ready — shared transport (`udpQuery`), pool construction (`BuildResolverPool`), parsing (`ParseServerFlag`, `ExtractLabelLevels`), platform types (`NetworkInfo`, `NetworkDiscoverer`), and constants (`BootstrapResolvers`, `PrivateTLDs`) all in place. User story implementation can now begin.
---
## Phase 3: User Story 1 — Smart Default Resolution (Priority: P1) 🎯 MVP
**Goal**: When no `-server` flag is provided, the tool performs parallel NS fan-out across local + public resolvers for authoritative answers, with parallel A fallback for internal hosts, CNAME re-resolution, and split-horizon conflict detection.
**Independent Test**: Run `dns-helper add -host www.example.com` (no `-server` flag) and verify the tool discovers the authoritative NS for `example.com`, queries it directly, and adds the resulting IP to the hosts file.
**Phase Context**:
- **Files modified in this phase**: `resolver/authority.go` (new — NS fan-out, NS selection, authoritative query), `resolver/authority_test.go` (new — tests), `resolver/fallback.go` (new — parallel A fallback), `resolver/fallback_test.go` (new — tests), `resolver/splithorizon.go` (new — split-horizon cross-check), `resolver/splithorizon_test.go` (new — tests), `resolver/modes.go` (new — mode dispatcher), `resolver/modes_test.go` (new — tests), `main.go` (updated — new flags, mode dispatch)
- **Key types/interfaces used**:
- `ServerMode` from `resolver/parse.go`: `type ServerMode struct { Mode string; ExplicitAddr string }`
- `QueryConfig` from `resolver/parse.go`: `type QueryConfig struct { Timeout time.Duration; Verbose bool }`
- `NSResult` from `resolver/authority.go` (defined in this phase): `type NSResult struct { LabelLevel string; Resolver string; NSRecords []string; CNAMETarget string; Err error }`
- `AuthoritativeNS` from `resolver/authority.go` (defined in this phase): `type AuthoritativeNS struct { Zone string; Nameservers []string }`
- `SplitHorizonResult` from `resolver/splithorizon.go` (defined in this phase)
- `NetworkDiscoverer` from `platform/network.go`: `type NetworkDiscoverer interface { Discover() (NetworkInfo, error) }`
- `BuildResolverPool` from `resolver/pool.go`
- `udpQuery` from `resolver/transport.go`
- Existing `startFakeDNS`/`startFakeDNSMulti` test helpers from `resolver/resolver_test.go`
- **Codebase conventions to follow**: Error wrapping with `fmt.Errorf("context: %w", err)`. Use goroutines + buffered channels for parallelism (R-000). Use `context.WithTimeout` for per-query timeouts. DNS wire protocol via `dnsmessage` package. Tests use fake UDP DNS servers listening on `127.0.0.1:0`.
---
### Stage 1: Parallel NS Fan-out
- [X] T009 [P] [US1] Create tests for `ParallelNSFanOut()` and `SelectAuthoritativeNS()` in `resolver/authority_test.go`
**Context**:
- **Target file state**: `resolver/authority_test.go` does not exist. Test helpers (`startFakeDNS`, `startFakeDNSMulti`, `queryID`, `mustNewName`, `buildAResponse`, `buildNXDOMAINResponse`) are in `resolver/resolver_test.go` under `package resolver_test` and are accessible from this file (same test package).
- **Expected implementation pattern**: Need a new helper `buildNSResponse` for NS queries:
```go
func buildNSResponse(id uint16, name dnsmessage.Name, nsNames []dnsmessage.Name) []byte {
answers := make([]dnsmessage.Resource, len(nsNames))
for i, ns := range nsNames {
answers[i] = dnsmessage.Resource{
Header: dnsmessage.ResourceHeader{
Name: name, Type: dnsmessage.TypeNS, Class: dnsmessage.ClassINET, TTL: 3600,
},
Body: &dnsmessage.NSResource{NS: ns},
}
}
msg := dnsmessage.Message{
Header: dnsmessage.Header{ID: id, Response: true, RCode: dnsmessage.RCodeSuccess},
Questions: []dnsmessage.Question{{Name: name, Type: dnsmessage.TypeNS, Class: dnsmessage.ClassINET}},
Answers: answers,
}
packed, _ := msg.Pack()
return packed
}
```
Test cases:
- **Fan-out with 2 resolvers × 2 levels**: One resolver returns NS for `example.com`, other returns NXDOMAIN. Verify 4 results total.
- **Fan-out timeout**: One resolver never responds, verify results still come back from the other.
- **SelectAuthoritativeNS — most specific wins**: Results include NS at `example.com` and `sub.example.com`, verify `sub.example.com` selected.
- **SelectAuthoritativeNS — no NS found**: All results have nil NSRecords, verify nil returned.
- **SelectAuthoritativeNS — deduplication**: Two resolvers return overlapping NS records for same zone, verify merged and deduplicated.
- **Key decisions and gotchas**: The fake DNS servers need to handle NS queries (check for `dnsmessage.TypeNS` in the question). Use `startFakeDNSMulti` since the fan-out sends multiple queries. NS query response parsing uses `parser.NSResource()` which returns `NSResource{NS: dnsmessage.Name}`.
- **Acceptance signal**: Tests compile but FAIL because `resolver/authority.go` does not exist yet.
- [X] T010 [US1] Implement `NSResult`, `AuthoritativeNS`, `ParallelNSFanOut()`, and `SelectAuthoritativeNS()` in `resolver/authority.go`
**Context**:
- **Depends on**: `udpQuery` from `resolver/transport.go` (T006), `ExtractLabelLevels` from `resolver/parse.go` (T002).
- **Target file state**: `resolver/authority.go` does not exist.
- **Expected implementation pattern**:
```go
package resolver
import (
"context"
"fmt"
"sort"
"strings"
"time"
"golang.org/x/net/dns/dnsmessage"
)
type NSResult struct {
LabelLevel string
Resolver string
NSRecords []string
CNAMETarget string
Err error
}
type AuthoritativeNS struct {
Zone string
Nameservers []string
}
// ParallelNSFanOut queries NS records for all label levels across all resolvers.
func ParallelNSFanOut(ctx context.Context, resolvers []string, labelLevels []string, timeout time.Duration) []NSResult {
total := len(resolvers) * len(labelLevels)
ch := make(chan NSResult, total)
for _, r := range resolvers {
for _, level := range labelLevels {
go func(resolver, label string) {
result := queryNS(ctx, resolver, label, timeout)
ch <- result
}(r, level)
}
}
results := make([]NSResult, 0, total)
for i := 0; i < total; i++ {
results = append(results, <-ch)
}
return results
}
// queryNS sends a single NS query and returns an NSResult.
func queryNS(ctx context.Context, resolver, label string, timeout time.Duration) NSResult {
// Build NS query using dnsmessage, send via udpQuery, parse NS records
// Return NSResult with LabelLevel, Resolver, NSRecords, Err
}
// SelectAuthoritativeNS picks the most-specific NS delegation from fan-out results.
func SelectAuthoritativeNS(results []NSResult) *AuthoritativeNS {
// Group by LabelLevel, merge NS records, select longest (most labels)
// Return nil if no NS records found at any level
}
```
- **Key decisions and gotchas**:
- Use buffered channel sized to `N×M` per R-000 research. Collector reads exactly `N×M` results — no goroutine leak.
- Each goroutine gets a child `context.WithTimeout(ctx, timeout)` for its individual query deadline.
- NS query construction: identical to A query in `resolver.go` but with `dnsmessage.TypeNS` instead of `dnsmessage.TypeA`. Parse with `parser.NSResource()` which returns `NSResource{NS: dnsmessage.Name}`.
- NXDOMAIN (`RCodeNameError`): produce `NSResult{Err: nil, NSRecords: nil}` — not an error.
- `SelectAuthoritativeNS` selection: count labels via `strings.Count(level, ".") + 1`, pick highest. Merge NS records across all resolvers for the winning level. Deduplicate NS hostnames.
- Sort NS hostnames alphabetically for deterministic output before returning.
- **Acceptance signal**: `go1.20 test ./resolver/... -run TestParallelNSFanOut` and `go1.20 test ./resolver/... -run TestSelectAuthoritativeNS` pass.
---
### Stage 2: Authoritative Query
- [X] T011 [P] [US1] Create tests for `QueryAuthoritative()` in `resolver/authority_test.go`
**Context**:
- **Target file state**: `resolver/authority_test.go` already exists from T009 with NS fan-out tests.
- **Expected implementation pattern**: Add test functions to the existing file:
- **A record success**: Fake "NS server" returns A record with RD=0. Verify IPs returned, cnameTarget empty, nsHostnameUsed populated.
- **CNAME response**: Fake NS returns CNAME. Verify empty IPs, cnameTarget populated, nsHostnameUsed populated.
- **First NS unreachable, second works**: Two NS hostnames, first goes to a closed port, second responds. Verify IPs returned from second and nsHostnameUsed is the second NS hostname.
- **All NS unreachable**: All NS go to closed ports. Verify error returned.
- **NS hostname resolution**: Fake resolvers that respond to A queries for NS hostnames. Verify the function resolves the NS hostname to an IP before querying it.
- **Key decisions and gotchas**: `QueryAuthoritative` needs to resolve the NS hostname to an IP first (using the resolver pool). Then it sends an A query with `RecursionDesired: false` to that IP. The fake DNS server for the authoritative NS should check that `RecursionDesired` is false in the incoming query. The function now returns a 4th value `nsHostnameUsed string` which is the NS hostname that successfully answered — needed for the split-horizon conflict error message (FR-035).
- **Acceptance signal**: Tests compile but initially FAIL, then pass after T012 implementation.
- [X] T012 [US1] Implement `QueryAuthoritative()` in `resolver/authority.go`
**Context**:
- **Depends on**: `udpQuery` from `resolver/transport.go` (T006), `ParallelNSFanOut` for NS hostname resolution.
- **Target file state**: `resolver/authority.go` already exists from T010 with `ParallelNSFanOut`, `SelectAuthoritativeNS`, `NSResult`, `AuthoritativeNS` types.
- **Expected implementation pattern**:
```go
// QueryAuthoritative sends an A query to an authoritative NS with recursion disabled.
// Tries each NS in order if one is unreachable.
// resolvers is the pool used to resolve NS hostnames to IPs.
// Returns the IPs, any CNAME target, the NS hostname that answered, and any error.
func QueryAuthoritative(ctx context.Context, ns *AuthoritativeNS, hostname string, resolvers []string, timeout time.Duration) (ips []string, cnameTarget string, nsHostnameUsed string, err error) {
for _, nsHostname := range ns.Nameservers {
// Step 1: Resolve NS hostname to IP using a simple A query via the pool
nsIP, err := resolveNSHostname(ctx, nsHostname, resolvers, timeout)
if err != nil {
continue // try next NS
}
// Step 2: Build A query with RecursionDesired=false
// Step 3: Send via udpQuery to nsIP
// Step 4: Parse response — collect A records and CNAME targets
// If A records found: return ips, "", nsHostname, nil
// If CNAME found: return nil, cnameTarget, nsHostname, nil (caller restarts Stage 1)
// If unreachable: continue to next NS
}
return nil, "", "", fmt.Errorf("all authoritative nameservers for %s unreachable", ns.Zone)
}
```
- **Key decisions and gotchas**:
- Set `RecursionDesired: false` in the `dnsmessage.Header` when querying the authoritative NS.
- The response parser follows the same pattern as `resolver.go` lines 98-165: skip questions, iterate answers for TypeA and TypeCNAME.
- If the authoritative NS returns RCodeRefused, try next NS (per R-000).
- If empty answer with RCodeSuccess, treat as possible referral — try next NS.
- `resolveNSHostname` helper: send A query to each resolver in the pool, return first IP. This is a simple sequential try, not a full parallel fan-out.
- **Acceptance signal**: `go1.20 test ./resolver/... -run TestQueryAuthoritative` passes.
---
### Stage 3: Parallel A Fallback
- [X] T013 [P] [US1] Create tests for `ParallelAFallback()` in `resolver/fallback_test.go`
**Context**:
- **Target file state**: `resolver/fallback_test.go` does not exist.
- **Expected implementation pattern**: Uses the same fake DNS helpers from `resolver_test.go`:
```go
func TestParallelAFallback_FirstSuccessWins(t *testing.T) {
// Start two fake servers: one returns A record, one returns NXDOMAIN
// Call ParallelAFallback with both
// Assert: IPs from the successful one returned
}
func TestParallelAFallback_AllNXDOMAIN(t *testing.T) {
// All fake servers return NXDOMAIN
// Assert: error returned
}
func TestParallelAFallback_OneTimeout(t *testing.T) {
// One server times out, one returns A record
// Assert: IPs from the responsive one returned
}
func TestParallelAFallback_AllFail(t *testing.T) {
// All servers unreachable
// Assert: error returned
}
```
- **Key decisions and gotchas**: The fallback returns the first *successful* result. NXDOMAIN and failures are ignored. "First" means whichever goroutine writes to the channel first — non-deterministic but correct.
- **Acceptance signal**: Tests compile but FAIL because `resolver/fallback.go` does not exist.
- [X] T014 [US1] Implement `ParallelAFallback()` in `resolver/fallback.go`
**Context**:
- **Depends on**: `udpQuery` from `resolver/transport.go` (T006).
- **Target file state**: `resolver/fallback.go` does not exist.
- **Expected implementation pattern**:
```go
package resolver
import (
"context"
"fmt"
"time"
"golang.org/x/net/dns/dnsmessage"
)
// ParallelAFallback sends A queries for hostname to all resolvers simultaneously.
// Returns the first successful A record result.
func ParallelAFallback(ctx context.Context, resolvers []string, hostname string, timeout time.Duration) ([]string, error) {
type aResult struct {
IPs []string
Err error
}
ch := make(chan aResult, len(resolvers))
for _, r := range resolvers {
go func(resolver string) {
ips, err := queryA(ctx, resolver, hostname, timeout)
ch <- aResult{IPs: ips, Err: err}
}(r)
}
var lastErr error
for i := 0; i < len(resolvers); i++ {
result := <-ch
if result.Err == nil && len(result.IPs) > 0 {
return result.IPs, nil
}
if result.Err != nil {
lastErr = result.Err
}
}
if lastErr != nil {
return nil, fmt.Errorf("all resolvers failed for %s: %w", hostname, lastErr)
}
return nil, fmt.Errorf("no resolver returned A records for %s", hostname)
}
// queryA sends a single A query and returns IPs.
func queryA(ctx context.Context, resolver, hostname string, timeout time.Duration) ([]string, error) {
// Build A query with dnsmessage, send via udpQuery, parse A records
// Similar to queryNS but for TypeA
}
```
- **Key decisions and gotchas**: Uses buffered channel sized to `len(resolvers)`. Reads all results (doesn't short-circuit) to avoid goroutine leaks — channels are buffered so goroutines can write even after we return. Returns the first successful result encountered during iteration. NXDOMAIN: return `nil, fmt.Errorf(...)` (an error, not a success).
- **Acceptance signal**: `go1.20 test ./resolver/... -run TestParallelAFallback` passes.
---
### Stage 2.5: Split-Horizon Conflict Detection
- [X] T015 [P] [US1] Create tests for `CheckSplitHorizon()` in `resolver/splithorizon_test.go`
**Context**:
- **Target file state**: `resolver/splithorizon_test.go` does not exist.
- **Expected implementation pattern**:
```go
func TestCheckSplitHorizon_NoConflict_SameIPs(t *testing.T) {
// Local resolver returns same IP as authoritative
// Assert: HasConflict is false
}
func TestCheckSplitHorizon_Conflict_DifferentIPs(t *testing.T) {
// Local resolver returns 10.0.5.100, authoritative was 203.0.113.50
// Assert: HasConflict is true, both IP sets populated
}
func TestCheckSplitHorizon_NoConflict_LocalNXDOMAIN(t *testing.T) {
// Local resolver returns NXDOMAIN
// Assert: HasConflict is false, LocalIPs is nil
}
func TestCheckSplitHorizon_NoConflict_LocalTimeout(t *testing.T) {
// Local resolver unreachable
// Assert: HasConflict is false, LocalIPs is nil
}
func TestCheckSplitHorizon_NoConflict_OrderIndependent(t *testing.T) {
// Local returns ["5.6.7.8", "1.2.3.4"], authoritative was ["1.2.3.4", "5.6.7.8"]
// Assert: HasConflict is false (order doesn't matter, sets are equal)
}
```
- **Key decisions and gotchas**: IP set comparison must be order-independent — sort both sets and compare. `nil` local IPs (NXDOMAIN or failure) means no conflict. The `SplitHorizonResult` type is defined in `resolver/splithorizon.go`.
- **Acceptance signal**: Tests compile but FAIL because `resolver/splithorizon.go` does not exist.
- [X] T016 [US1] Implement `CheckSplitHorizon()` and `SplitHorizonResult` in `resolver/splithorizon.go`
**Context**:
- **Depends on**: `queryA` from `resolver/fallback.go` (T014) or `udpQuery` from `resolver/transport.go` (T006).
- **Target file state**: `resolver/splithorizon.go` does not exist.
- **Expected implementation pattern**:
```go
package resolver
import (
"context"
"sort"
"time"
)
type SplitHorizonResult struct {
AuthoritativeIPs []string
AuthoritativeSource string
LocalIPs []string
LocalSource string
HasConflict bool
}
// CheckSplitHorizon queries local resolvers and compares against authoritative IPs.
func CheckSplitHorizon(ctx context.Context, localResolvers []string, hostname string, authoritativeIPs []string, authoritativeSource string, timeout time.Duration) SplitHorizonResult {
result := SplitHorizonResult{
AuthoritativeIPs: authoritativeIPs,
AuthoritativeSource: authoritativeSource,
}
// Query each local resolver in order, take first success
for _, lr := range localResolvers {
ips, err := queryA(ctx, lr, hostname, timeout)
if err != nil {
continue // timeout, NXDOMAIN, etc.
}
result.LocalIPs = ips
result.LocalSource = lr
// Compare IP sets
result.HasConflict = !ipSetsEqual(authoritativeIPs, ips)
return result
}
// All local resolvers failed or returned NXDOMAIN — no conflict
return result
}
// ipSetsEqual compares two IP slices as sorted, deduplicated sets.
func ipSetsEqual(a, b []string) bool {
sa := sortedDedup(a)
sb := sortedDedup(b)
if len(sa) != len(sb) { return false }
for i := range sa {
if sa[i] != sb[i] { return false }
}
return true
}
func sortedDedup(ips []string) []string {
m := make(map[string]bool)
for _, ip := range ips { m[ip] = true }
result := make([]string, 0, len(m))
for ip := range m { result = append(result, ip) }
sort.Strings(result)
return result
}
```
- **Key decisions and gotchas**:
- The `queryA` function from `fallback.go` returns an error for NXDOMAIN. So NXDOMAIN from a local resolver means `err != nil`, which correctly falls through to "no conflict".
- IP set comparison is order-independent: sort and deduplicate both sets.
- This function queries local resolvers sequentially (not in parallel) since we only need one successful answer. This keeps it simple and avoids the complexity of racing multiple local resolvers for the cross-check.
- FR-037: This function is only called in default mode — the caller (`Resolve()` in `modes.go`) is responsible for that gating.
- FR-038: This function is only called when local resolvers exist — the caller checks.
- **Note**: The `ipSetsEqual` and `sortedDedup` helper functions are used both here and in `resolveAuthoritative` (modes.go). They should be placed in this file (`splithorizon.go`) and called from both places.
- **Note on concurrency**: In the refactored design (T018), the local resolver query is fired concurrently with Stage 2 via `queryLocalResolvers()` in modes.go. `CheckSplitHorizon` remains available for standalone testing, but the primary production path uses the concurrent pattern in `resolveAuthoritative`.
- **Acceptance signal**: `go1.20 test ./resolver/... -run TestCheckSplitHorizon` passes.
---
### Mode Dispatcher
- [X] T017 [P] [US1] Create tests for `Resolve()` mode dispatcher in `resolver/modes_test.go`
**Context**:
- **Target file state**: `resolver/modes_test.go` does not exist.
- **Expected implementation pattern**: Tests for the default mode path through `Resolve()`:
```go
func TestResolve_DefaultMode_AuthoritativeSuccess(t *testing.T) {
// Setup: fake DNS servers that respond to NS and A queries
// NS server: returns NS records for "example.com"
// Authoritative server: returns A record for "www.example.com"
// FakeNetworkDiscoverer with local resolvers
// Call Resolve("www.example.com", ServerMode{Mode:"default"}, config, discoverer)
// Assert: returns IPs from the authoritative server
}
func TestResolve_DefaultMode_FallbackToParallelA(t *testing.T) {
// NS fan-out returns no NS records at any level
// One resolver returns A record for the hostname
// Assert: returns IPs from fallback
}
func TestResolve_DefaultMode_CNAMERestart(t *testing.T) {
// Stage 2 returns a CNAME target
// Second pass: NS found, authoritative returns A record
// Assert: returns IPs from the second pass
}
func TestResolve_DefaultMode_CNAMEDepthExceeded(t *testing.T) {
// Setup: 11 fake NS/authoritative servers, each returns a CNAME
// pointing to the next one. Depth exceeds maxCNAMEDepth (10).
// Key: verify the depth counter propagates through
// resolveDefault → resolveAuthoritative → resolveDefault(depth+1)
// Assert: error containing "CNAME chain depth exceeded"
}
func TestResolve_DefaultMode_SplitHorizonConflict(t *testing.T) {
// Authoritative returns 203.0.113.50
// Local resolver returns 10.0.5.100
// Assert: error containing "conflicting DNS answers"
}
func TestResolve_DefaultMode_PrivateTLDError(t *testing.T) {
// Hostname is "myservice.client.local"
// All queries fail
// Assert: error contains "private TLD" or "internal" hint
}
func TestResolve_DefaultMode_NoLocalResolvers(t *testing.T) {
// FakeNetworkDiscoverer returns empty DNSServers
// Bootstrap resolvers handle resolution
// Assert: succeeds, no split-horizon check (no local resolvers)
}
```
- **Key decisions and gotchas**: These are integration-level tests for the full default mode pipeline. They require setting up multiple fake DNS servers with different roles (NS responder, authoritative server, local resolver). Keep the test setup functions focused and well-named. The `FakeNetworkDiscoverer` from `platform/network.go` is used to inject deterministic local resolver/gateway info.
- **Acceptance signal**: Tests compile but FAIL because `resolver/modes.go` does not exist.
- [X] T018 [US1] Implement `Resolve()` mode dispatcher in `resolver/modes.go`
**Context**:
- **Depends on**: All prior resolver functions: `ParseServerFlag`, `BuildResolverPool`, `ExtractLabelLevels`, `ParallelNSFanOut`, `SelectAuthoritativeNS`, `QueryAuthoritative`, `ParallelAFallback`, `CheckSplitHorizon`.
- **Target file state**: `resolver/modes.go` does not exist.
- **Expected implementation pattern**:
```go
package resolver
import (
"context"
"fmt"
"io"
"strings"
"time"
"dns-helper/platform"
)
// Resolve performs DNS resolution using the specified mode and configuration.
func Resolve(hostname string, mode ServerMode, config QueryConfig, discoverer platform.NetworkDiscoverer) ([]string, error) {
switch mode.Mode {
case "default":
return resolveDefaultEntry(hostname, config, discoverer)
case "local":
return resolveLocal(hostname, config, discoverer)
case "gateway":
return resolveGateway(hostname, config, discoverer)
case "explicit":
return resolveExplicit(hostname, mode, config)
default:
return nil, fmt.Errorf("unknown server mode %q", mode.Mode)
}
}
// resolveDefaultEntry is the public entry point for default mode.
// It discovers network info once and delegates to resolveDefault with depth=0.
func resolveDefaultEntry(hostname string, config QueryConfig, discoverer platform.NetworkDiscoverer) ([]string, error) {
info, _ := discoverer.Discover() // ignore error per FR-009
pool := BuildResolverPool(ServerMode{Mode: "default"}, info)
return resolveDefault(hostname, pool, info.DNSServers, config, 0)
}
// resolveDefault performs smart default resolution.
// pool and localResolvers are passed explicitly so CNAME restarts reuse them
// without needing a NetworkDiscoverer (avoids using FakeNetworkDiscoverer in production).
// depth tracks CNAME hops to enforce maxCNAMEDepth (FR-019).
func resolveDefault(hostname string, pool []string, localResolvers []string, config QueryConfig, depth int) ([]string, error) {
if depth > maxCNAMEDepth {
return nil, fmt.Errorf("CNAME chain depth exceeded for %s (max %d hops): probable CNAME loop or misconfigured zone", hostname, maxCNAMEDepth)
}
// 1. Extract label levels
levels := ExtractLabelLevels(hostname)
// 2. Stage 1: Parallel NS fan-out
ctx := context.Background()
nsResults := ParallelNSFanOut(ctx, pool, levels, config.Timeout)
authority := SelectAuthoritativeNS(nsResults)
if authority != nil {
// 3. Stage 2: Authoritative query
return resolveAuthoritative(ctx, authority, hostname, pool, localResolvers, config, depth)
}
// 4. Stage 3: Parallel A fallback
ips, err := ParallelAFallback(ctx, pool, hostname, config.Timeout)
if err != nil {
return nil, addPrivateTLDHint(hostname, err)
}
return ips, nil
}
// resolveAuthoritative handles Stage 2 + Stage 2.5 + CNAME restarts.
func resolveAuthoritative(ctx context.Context, authority *AuthoritativeNS, hostname string, pool []string, localResolvers []string, config QueryConfig, depth int) ([]string, error) {
// Fire Stage 2.5 cross-check concurrently with Stage 2 (FR-033, SC-011).
// The cross-check goroutine starts now and runs in parallel with the
// authoritative query so it adds no wall-clock latency to the happy path.
type shOut struct{ result SplitHorizonResult }
var shCh chan shOut
if len(localResolvers) > 0 {
shCh = make(chan shOut, 1)
go func() {
// Stage 2.5 fires against local resolvers for hostname
// We don't know the authoritative IPs yet; we'll compare after Stage 2.
localIPs, localSource := queryLocalResolvers(ctx, localResolvers, hostname, config.Timeout)
shCh <- shOut{result: SplitHorizonResult{LocalIPs: localIPs, LocalSource: localSource}}
}()
}
// Stage 2: Query authoritative NS
ips, cnameTarget, nsHostnameUsed, err := QueryAuthoritative(ctx, authority, hostname, pool, config.Timeout)
if err != nil {
return nil, addPrivateTLDHint(hostname, err)
}
if cnameTarget != "" {
// CNAME: restart full resolution for the target domain.
// Increment depth to enforce maxCNAMEDepth (FR-019).
return resolveDefault(cnameTarget, pool, localResolvers, config, depth+1)
}
// Stage 2.5: Collect cross-check result and compare
if shCh != nil {
shOut := <-shCh
shResult := shOut.result
shResult.AuthoritativeIPs = ips
shResult.AuthoritativeSource = nsHostnameUsed
shResult.HasConflict = shResult.LocalIPs != nil && !ipSetsEqual(ips, shResult.LocalIPs)
if shResult.HasConflict {
return nil, fmt.Errorf("conflicting DNS answers for %s\n Authoritative (%s): %s\n Local resolver (%s): %s\n The hostname resolves to different IPs depending on the DNS source.\n Use -server local to trust your internal DNS, or -server <ip> to choose explicitly.",
hostname, shResult.AuthoritativeSource, strings.Join(shResult.AuthoritativeIPs, ", "),
shResult.LocalSource, strings.Join(shResult.LocalIPs, ", "))
}
}
return ips, nil
}
// queryLocalResolvers queries each local resolver sequentially for an A record.
// Returns the first successful IPs and the resolver that provided them.
// Returns nil, "" if all fail or return NXDOMAIN.
func queryLocalResolvers(ctx context.Context, localResolvers []string, hostname string, timeout time.Duration) ([]string, string) {
for _, lr := range localResolvers {
ips, err := queryA(ctx, lr, hostname, timeout)
if err == nil && len(ips) > 0 {
return ips, lr
}
}
return nil, ""
}
func resolveLocal(hostname string, config QueryConfig, discoverer platform.NetworkDiscoverer) ([]string, error) {
info, err := discoverer.Discover()
if err != nil || len(info.DNSServers) == 0 {
return nil, fmt.Errorf("failed to resolve %s using local resolvers: no local DNS servers found", hostname)
}
// Query each in priority order
ctx := context.Background()
var lastErr error
for _, server := range info.DNSServers {
ips, err := queryA(ctx, server, hostname, config.Timeout)
if err == nil && len(ips) > 0 {
return ips, nil
}
lastErr = err
}
return nil, fmt.Errorf("failed to resolve %s using local resolvers: all local DNS servers are unreachable\n Local resolvers tried: %s", hostname, strings.Join(info.DNSServers, ", "))
}
func resolveGateway(hostname string, config QueryConfig, discoverer platform.NetworkDiscoverer) ([]string, error) {
info, err := discoverer.Discover()
if err != nil || info.Gateway == "" {
return nil, fmt.Errorf("failed to resolve %s using gateway: no default gateway found", hostname)
}
ctx := context.Background()
ips, err := queryA(ctx, info.Gateway, hostname, config.Timeout)
if err != nil {
return nil, fmt.Errorf("failed to resolve %s using gateway: gateway %s did not respond to DNS query", hostname, info.Gateway)
}
return ips, nil
}
func resolveExplicit(hostname string, mode ServerMode, config QueryConfig) ([]string, error) {
// Use per-query timeout from config (FR-029) via the shared transport.
// We use queryA directly instead of DNSResolver.LookupIP so the user's
// -timeout flag is respected in explicit mode.
ctx := context.Background()
return queryA(ctx, mode.ExplicitAddr, hostname, config.Timeout)
}
// addPrivateTLDHint wraps a resolution error with a hint if the hostname uses a private TLD.
func addPrivateTLDHint(hostname string, origErr error) error {
parts := strings.Split(strings.TrimSuffix(hostname, "."), ".")
if len(parts) > 0 {
tld := strings.ToLower(parts[len(parts)-1])
if PrivateTLDs[tld] {
return fmt.Errorf("%w\n Hint: the hostname uses a private TLD (.%s). Try specifying an internal DNS server:\n dns-helper add -host %s -server <internal-dns-ip>", origErr, tld, hostname)
}
}
return origErr
}
```
- **Key decisions and gotchas**:
- **CNAME depth tracking (FR-019)**: `resolveDefault` accepts a `depth int` parameter. On CNAME restart, it calls `resolveDefault(cnameTarget, pool, localResolvers, config, depth+1)`. The depth check at the top of `resolveDefault` rejects anything over `maxCNAMEDepth` (10). This ensures the limit works correctly even across multiple restarts.
- **No FakeNetworkDiscoverer in production**: `resolveDefaultEntry` discovers network info once and passes `pool` and `localResolvers` as plain slices to `resolveDefault`. CNAME restarts reuse these slices directly — no need to wrap them in a fake discoverer.
- **Split-horizon concurrent cross-check (FR-033, SC-011)**: `resolveAuthoritative` fires the local cross-check as a goroutine at the same time Stage 2 starts. The goroutine queries local resolvers and sends results to a buffered channel. After Stage 2 completes, the main goroutine reads the channel and compares IPs. This adds zero wall-clock latency to the happy path. Only runs when `len(localResolvers) > 0` (FR-038) and only in default mode (FR-037).
- **QueryAuthoritative return value change**: `QueryAuthoritative` now returns 4 values: `(ips []string, cnameTarget string, nsHostnameUsed string, err error)`. The `nsHostnameUsed` is the actual NS hostname that answered (e.g., `"ns1.example.com"`), used as `AuthoritativeSource` in the split-horizon conflict error message (FR-035).
- **Explicit mode timeout (FR-029)**: `resolveExplicit` uses `queryA` with `config.Timeout` instead of `New().LookupIP()` to respect the user's `-timeout` flag.
- Private TLD hint: only added when resolution fails entirely (FR-025).
- `resolveLocal` and `resolveGateway` do NOT fall back to public resolvers (FR-026, FR-027).
- Verbose output (`config.Verbose`): emit to `os.Stderr` using `fmt.Fprintf`. Add verbose logging at key stages if Verbose is true. This can be deferred to the T027 polish task.
- **Acceptance signal**: `go1.20 test ./resolver/... -run TestResolve` passes for all default mode test cases.
---
### CLI Integration
- [X] T019 [US1] Update `main.go` to support optional `-server`, new `-timeout`/`-verbose` flags, and mode dispatch
**Context**:
- **Existing code references**: Current `runAdd` function in `main.go` (lines 42-70):
```go
func runAdd(args []string) int {
fs := flag.NewFlagSet("add", flag.ContinueOnError)
hostFlag := fs.String("host", "", "Comma separated list of hostnames to add")
serverFlag := fs.String("server", "", "DNS resolver to use")
if err := fs.Parse(args); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
return 1
}
if *hostFlag == "" {
fmt.Fprintf(os.Stderr, "Error: -host flag is required for the add command\n")
return 1
}
if *serverFlag == "" {
fmt.Fprintf(os.Stderr, "Error: -server flag is required for the add command\n")
return 1
}
hostnames := strings.Split(*hostFlag, ",")
server := *serverFlag
res := resolver.New()
// ... resolves each hostname via res.LookupIP(h, server)
```
- **Target file state**: `main.go` currently has `runAdd` that requires `-server` and uses `resolver.New().LookupIP()` directly. It needs to:
1. Remove the `-server` required check (make it optional).
2. Add `-timeout` (int, default 3) and `-verbose` (bool, default false) flags.
3. Parse `-server` with `resolver.ParseServerFlag()`.
4. Validate `-timeout` (must be positive).
5. Dispatch to `resolver.Resolve()` instead of `resolver.New().LookupIP()`.
6. Create a platform `NetworkDiscoverer` — need a concrete discoverer. For now, import the platform-specific one (T020-T022 provide it).
7. Update `printUsage()` with new examples.
- **Expected changes to `runAdd`**:
```go
func runAdd(args []string) int {
fs := flag.NewFlagSet("add", flag.ContinueOnError)
hostFlag := fs.String("host", "", "Comma separated list of hostnames to add")
serverFlag := fs.String("server", "", "Resolution mode: omit for smart default, local, gateway, or IP/IP:port")
timeoutFlag := fs.Int("timeout", 3, "Per-query DNS timeout in seconds")
verboseFlag := fs.Bool("verbose", false, "Emit per-stage resolution trace to stderr")
// ... parse
if *hostFlag == "" { /* error */ }
// Remove the server required check!
if *timeoutFlag <= 0 {
fmt.Fprintf(os.Stderr, "Error: -timeout must be a positive integer\n")
return 1
}
mode, err := resolver.ParseServerFlag(*serverFlag)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
return 1
}
config := resolver.QueryConfig{
Timeout: time.Duration(*timeoutFlag) * time.Second,
Verbose: *verboseFlag,
}
discoverer := platform.NewNetworkDiscoverer() // platform-specific constructor
// For each hostname:
ips, err := resolver.Resolve(h, mode, config, discoverer)
```
- **Key decisions and gotchas**:
- Must add `"time"` to imports.
- Need `platform.NewNetworkDiscoverer()` — a constructor that returns the platform-specific implementation. This will be created in T023. To avoid a compilation gap, T023 has been moved to after the platform types (Phase 1 checkpoint) and provides a minimal stub `NewNetworkDiscoverer()` that returns `&stubNetworkDiscoverer{}` (returns empty NetworkInfo, no error). The real implementations (T020-T022) replace the stub bodies in Phase 4. This way `main.go` compiles cleanly in Phase 3.
- Update `printUsage()` to match the CLI contract from `contracts/cli.md`.
- The `-server` flag is now optional — remove the empty check that returns error.
- Existing import of `"dns-helper/resolver"` and `"dns-helper/platform"` already present.
- **Acceptance signal**: `go1.20 build .` succeeds. Running `dns-helper add -host www.example.com` (no `-server`) invokes the new smart default resolution path. Running with `-server 8.8.8.8` still uses the existing explicit IP path.
**Checkpoint**: User Story 1 (smart default resolution) is functionally complete at the resolver level. The tool can resolve hostnames without `-server` using the full Stage 1 → 2 → 2.5 → 3 pipeline. Platform-specific discovery (needed for real local resolvers) is handled in US2 but fallback to bootstrap-only pool works for public hostnames.
---
## Phase 4: User Story 2 — Local Resolver Mode (Priority: P2)
**Goal**: When `-server local` is specified, the tool discovers locally configured DNS resolvers and queries only those. No public resolver fallback. Also provides the platform-specific `NetworkDiscoverer` implementations that US1 depends on for local resolver discovery in default mode.
**Independent Test**: Run `dns-helper add -host internal.client.local -server local` and verify it queries only the locally configured resolvers in priority order.
**Phase Context**:
- **Files modified in this phase**: `platform/network_windows.go` (extended — `WindowsNetworkDiscoverer`), `platform/network_linux.go` (new — `LinuxNetworkDiscoverer`), `platform/network_darwin.go` (new — `DarwinNetworkDiscoverer`), `platform/discoverer.go` (new — `NewNetworkDiscoverer()` constructor, one per platform with build tags), `resolver/modes_test.go` (extended — local mode tests)
- **Key types/interfaces used**:
- `NetworkDiscoverer` interface from `platform/network.go`: `type NetworkDiscoverer interface { Discover() (NetworkInfo, error) }`
- `NetworkInfo` from `platform/network.go`: `type NetworkInfo struct { DNSServers []string; Gateway string; Interface string }`
- **Codebase conventions to follow**: Platform files use `//go:build <os>` build tags (see `platform_windows.go`, `platform_linux.go`, `platform_darwin.go`). Use `os/exec.Command()` for external commands. Validate IPs with `net.ParseIP()`. Wrap errors with context using `fmt.Errorf`.
---
### Windows Network Discovery
- [X] T020 [P] [US2] Implement `WindowsNetworkDiscoverer` in `platform/network_windows.go`
**Context**:
- **Target file state**: `platform/platform_windows.go` exists with `//go:build windows` and only `GetHostsFilePath()`. The new discoverer struct and `Discover()` method go in a **new file** `platform/network_windows.go` with `//go:build windows`.
- **Expected implementation pattern**:
```go
//go:build windows
package platform
import (
"bufio"
"fmt"
"net"
"os/exec"
"regexp"
"strconv"
"strings"
)
// WindowsNetworkDiscoverer discovers network info using netsh commands.
type WindowsNetworkDiscoverer struct{}
func (d *WindowsNetworkDiscoverer) Discover() (NetworkInfo, error) {
info := NetworkInfo{}
// Step 1: netsh interface ipv4 show route → find 0.0.0.0/0 row
// Extract: interface index, gateway IP
// If multiple 0.0.0.0/0 rows, take lowest metric
// Step 2: netsh interface ipv4 show interfaces → map index to name
// Step 3: netsh interface ipv4 show dnsservers name="<name>" → extract DNS IPs
return info, nil
}
```
- **Key decisions and gotchas** (from R-001 in research.md):
- **Step 1 parsing**: Scan each line for literal `0.0.0.0/0`. Split on whitespace. Field 4 (0-indexed) is the interface index. Use IPv4 regex `(\d{1,3}\.){3}\d{1,3}` on field 5+ for gateway. If multiple default routes, take lowest metric (field 2).
- **Step 2 parsing**: Skip first two header lines. Field 0 is Idx. Field 4+ is interface name (may contain spaces — join fields from index 4 onward).
- **Step 3 parsing**: Do NOT parse label text (it's localized). Instead, scan each line with IPv4 regex `^\s*(?:\S.*:\s+)?(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\s*$`. Validate with `net.ParseIP()`.
- Always quote the interface name in the `netsh` command: `name="vEthernet (Bridge)"`.
- If any `exec.Command` fails, return partial `NetworkInfo` with a wrapped error.
- **Acceptance signal**: `go1.20 build ./platform/...` succeeds on Windows. `go1.20 vet ./platform/...` clean. Manual test: run on a Windows machine and verify DNS servers and gateway match `ipconfig /all` output.
### Linux Network Discovery
- [X] T021 [P] [US2] Implement `LinuxNetworkDiscoverer` in `platform/network_linux.go`
**Context**:
- **Target file state**: `platform/platform_linux.go` exists with `//go:build linux` and only `GetHostsFilePath()`. New file `platform/network_linux.go` with `//go:build linux`.
- **Expected implementation pattern**:
```go
//go:build linux
package platform
import (
"bufio"
"fmt"
"net"
"os"
"os/exec"
"strings"
)
type LinuxNetworkDiscoverer struct{}
func (d *LinuxNetworkDiscoverer) Discover() (NetworkInfo, error) {
info := NetworkInfo{}
// Step 1: ip route show default → extract gateway ("via <ip>") and interface ("dev <iface>")
// If multiple default routes, take lowest metric ("metric <N>")
// Step 2: Parse /etc/resolv.conf for "nameserver" lines
// Step 3 (conditional): If all nameservers are stub addresses (127.0.0.53, 127.0.0.1, 127.0.1.1),
// try resolvectl status <interface> to find upstream DNS servers
return info, nil
}
```
- **Key decisions and gotchas** (from R-002 in research.md):
- `ip route show default` output: `default via 192.168.1.1 dev eth0 proto dhcp metric 100`. Parse `via <IP>` and `dev <iface>` by keyword matching. `via` and `dev` are not localized — they are fixed in `iproute2`.
- `/etc/resolv.conf`: read lines, skip `#` and `;` comments, extract IP after `nameserver` keyword. Validate with `net.ParseIP()`.
- Stub resolver detection: if all nameservers are in `{127.0.0.53, 127.0.0.1, 127.0.1.1}`, try `resolvectl status <interface>`. Parse `DNS Servers:` line and indented continuation lines for IPs.
- If `resolvectl` is not found (`exec.LookPath` fails), skip — use the stub addresses as-is (they're still functional).
- If `/etc/resolv.conf` doesn't exist or can't be opened, return empty DNS servers with no error.
- **Acceptance signal**: `GOOS=linux go1.20 build ./platform/...` succeeds. `go1.20 vet ./platform/...` clean.
### macOS Network Discovery
- [X] T022 [P] [US2] Implement `DarwinNetworkDiscoverer` in `platform/network_darwin.go`
**Context**:
- **Target file state**: `platform/platform_darwin.go` exists with `//go:build darwin` and only `GetHostsFilePath()`. New file `platform/network_darwin.go` with `//go:build darwin`.
- **Expected implementation pattern**:
```go
//go:build darwin
package platform
import (
"bufio"
"fmt"
"net"
"os/exec"
"regexp"
"strings"
)
type DarwinNetworkDiscoverer struct{}
func (d *DarwinNetworkDiscoverer) Discover() (NetworkInfo, error) {
info := NetworkInfo{}
// Step 1: route -n get default → extract "gateway:" and "interface:" lines
// Step 2: scutil --dns → parse resolver blocks for nameserver IPs
// Match resolver block whose if_index contains the interface from Step 1
// Filter out mDNS blocks (domain: local + options: mdns)
// Fallback: use resolver #1 from top section if no interface match
return info, nil
}
```
- **Key decisions and gotchas** (from R-003 in research.md):
- `route -n get default` output: scan for `gateway: <IP>` and `interface: <name>`. Keywords are fixed (not localized). Validate gateway with `net.ParseIP()`.
- `scutil --dns` parsing: split into resolver blocks by `resolver #N` lines. For each block, extract `nameserver[N] : <IP>` lines and `if_index : N (<iface>)` line. Match the block whose `if_index` contains the interface from Step 1.
- Filter out blocks with `domain : local` + `options : mdns` (mDNS/Bonjour, not general DNS).
- Ignore the "DNS configuration (for scoped queries)" section (after the second `DNS configuration` header).
- Fallback: if no interface match, use `resolver #1` from the top section.
- **Acceptance signal**: `GOOS=darwin go1.20 build ./platform/...` succeeds. `go1.20 vet ./platform/...` clean.
### Platform Constructor
- [X] T023 [US2] Update `NewNetworkDiscoverer()` stubs (from T004b) to return real platform discoverers in `platform/discoverer_<os>.go`
**Context**:
- **Target file state**: `platform/discoverer_windows.go`, `platform/discoverer_linux.go`, and `platform/discoverer_darwin.go` already exist from T004b with stub discoverers. Now replace the stub with the real implementation.
- **Expected implementation pattern**: In each file, remove the `stubNetworkDiscoverer` type and update `NewNetworkDiscoverer()` to return the real discoverer:
```go
//go:build windows // (or linux, darwin)
package platform
// NewNetworkDiscoverer returns the platform-specific NetworkDiscoverer.
func NewNetworkDiscoverer() NetworkDiscoverer {
return &WindowsNetworkDiscoverer{} // (or LinuxNetworkDiscoverer, DarwinNetworkDiscoverer)
}
```
- **Key decisions and gotchas**: The function returns the interface type `NetworkDiscoverer`, not the concrete struct. This allows `main.go` to call `platform.NewNetworkDiscoverer()` without build-tag conditionals. Each platform file returns its own concrete type. The stub types from T004b should be deleted.
- **Acceptance signal**: `go1.20 build .` succeeds on the current platform. `main.go` can call `platform.NewNetworkDiscoverer()` and get real platform discovery results.
### Local Mode Tests
- [X] T024 [US2] Add local mode tests to `resolver/modes_test.go`
**Context**:
- **Target file state**: `resolver/modes_test.go` exists from T017 with default mode tests.
- **Expected implementation pattern**: Add tests for local mode:
```go
func TestResolve_LocalMode_Success(t *testing.T) {
// FakeNetworkDiscoverer with DNSServers: ["<fakeServer>"]
// Fake server returns A record
// Call Resolve with Mode: "local"
// Assert: IPs returned
}
func TestResolve_LocalMode_NoLocalResolvers(t *testing.T) {
// FakeNetworkDiscoverer with empty DNSServers
// Assert: error "no local DNS servers found"
}
func TestResolve_LocalMode_AllFail(t *testing.T) {
// FakeNetworkDiscoverer with unreachable servers
// Assert: error "all local DNS servers are unreachable"
}
func TestResolve_LocalMode_PriorityOrder(t *testing.T) {
// Two local resolvers: first times out, second responds
// Assert: IPs from second resolver
}
func TestResolve_LocalMode_NoPublicFallback(t *testing.T) {
// Local resolvers all fail, verify no attempt to query bootstrap resolvers
}
```
- **Acceptance signal**: `go1.20 test ./resolver/... -run TestResolve_LocalMode` passes.
**Checkpoint**: User Story 2 (local resolver mode) is complete. `-server local` discovers and queries locally configured DNS resolvers on Windows, Linux, and macOS. User Story 1's default mode also benefits from platform discovery now providing real local resolvers.
---
## Phase 5: User Story 3 — Gateway Resolver Mode (Priority: P3)
**Goal**: When `-server gateway` is specified, the tool discovers the default gateway IP and sends DNS queries directly to it.
**Independent Test**: Run `dns-helper add -host www.example.com -server gateway` and verify the query is sent to the default gateway IP.
**Phase Context**:
- **Files modified in this phase**: `resolver/modes_test.go` (extended — gateway mode tests)
- **Key types/interfaces used**: `Resolve()` from `resolver/modes.go`, `NetworkDiscoverer` from `platform/network.go`, `FakeNetworkDiscoverer` for testing
- **Codebase conventions to follow**: Same error message patterns as local mode. Gateway discovery reuses the `Discover()` implementations from Phase 4.
---
- [X] T025 [US3] Add gateway mode tests to `resolver/modes_test.go`
**Context**:
- **Target file state**: `resolver/modes_test.go` exists from T017/T024 with default and local mode tests.
- **Expected implementation pattern**:
```go
func TestResolve_GatewayMode_Success(t *testing.T) {
// FakeNetworkDiscoverer with Gateway: "<fakeServer>"
// Fake server returns A record
// Call Resolve with Mode: "gateway"
// Assert: IPs returned
}
func TestResolve_GatewayMode_NoGateway(t *testing.T) {
// FakeNetworkDiscoverer with empty Gateway
// Assert: error "no default gateway found"
}
func TestResolve_GatewayMode_GatewayNotResponding(t *testing.T) {
// FakeNetworkDiscoverer with Gateway pointing to closed port
// Assert: error "did not respond to DNS query"
}
func TestResolve_GatewayMode_NoFallback(t *testing.T) {
// Gateway fails, verify no attempt to query other resolvers
}
```
- **Key decisions and gotchas**: Gateway mode is the simplest mode — single resolver, no fallback, no split-horizon check. The `resolveGateway` implementation in `modes.go` (T018) already handles this. These tests verify the integration.
- **Acceptance signal**: `go1.20 test ./resolver/... -run TestResolve_GatewayMode` passes.
**Checkpoint**: User Story 3 (gateway mode) is complete. The implementation was already created in T018 as part of the `Resolve()` dispatcher; this phase adds focused tests.
---
## Phase 6: User Story 4 — Explicit Server IP (Priority: P4)
**Goal**: When `-server <ip>` or `-server <ip>:<port>` is specified, the tool queries that server directly. This is existing behavior that must remain backward-compatible.
**Independent Test**: Already tested by existing `resolver/resolver_test.go`. Additional integration tests verify the new parsing + dispatch path.
**Phase Context**:
- **Files modified in this phase**: `resolver/modes_test.go` (extended — explicit mode tests)
- **Key types/interfaces used**: `Resolve()` from `resolver/modes.go`, `ServerMode` from `resolver/parse.go`
- **Codebase conventions to follow**: Backward compatibility is critical — existing `LookupIP` behavior must be preserved exactly.
---
- [X] T026 [US4] Add explicit mode tests to `resolver/modes_test.go`
**Context**:
- **Target file state**: `resolver/modes_test.go` exists with default, local, and gateway mode tests.
- **Expected implementation pattern**:
```go
func TestResolve_ExplicitMode_Success(t *testing.T) {
// Start fake DNS server
// Call Resolve with Mode: "explicit", ExplicitAddr: "<fakeServer>"
// Assert: IPs returned (same as existing LookupIP behavior)
}
func TestResolve_ExplicitMode_WithPort(t *testing.T) {
// Start fake DNS on non-standard port
// Call Resolve with ExplicitAddr: "<ip>:<port>"
// Assert: IPs returned
}
func TestResolve_ExplicitMode_ServerUnreachable(t *testing.T) {
// ExplicitAddr points to closed port
// Assert: error returned
}
```
- **Key decisions and gotchas**: The explicit mode now uses `queryA` with `config.Timeout` instead of `resolver.New().LookupIP()` to ensure the user's `-timeout` flag is respected (FR-029). The existing `resolver_test.go` tests verify that the underlying DNS query behavior is unchanged. No split-horizon check in explicit mode (FR-037).
- **Acceptance signal**: `go1.20 test ./resolver/... -run TestResolve_ExplicitMode` passes. Existing tests in `resolver_test.go` continue to pass.
**Checkpoint**: User Story 4 (explicit IP mode) is verified as backward-compatible through the new mode dispatch path.
---
## Phase 7: Polish & Cross-Cutting Concerns
**Purpose**: Verbose output, error message refinement, documentation updates, and full validation.
**Phase Context**:
- **Files modified in this phase**: `resolver/modes.go` (verbose logging), `resolver/authority.go` (verbose logging), `resolver/fallback.go` (verbose logging), `resolver/splithorizon.go` (verbose logging), `main.go` (usage text), `README.md` (documentation)
- **Key types/interfaces used**: `QueryConfig.Verbose` flag from `resolver/parse.go`
- **Codebase conventions to follow**: Verbose output goes to `os.Stderr` using `fmt.Fprintf`. Prefix all verbose lines with `[dns]`. See `contracts/cli.md` for exact verbose output format. README updates per copilot-instructions.md (must be done after every implementation change).
---
- [X] T027 [P] Add verbose diagnostic output to resolver pipeline in `resolver/modes.go`, `resolver/authority.go`, `resolver/fallback.go`, `resolver/splithorizon.go`
**Context**:
- **Target file state**: All four files exist from Phases 2-3. Verbose output needs to be threaded through the resolution pipeline via an `io.Writer` parameter.
- **Expected implementation pattern**: Add verbose logging at key decision points per `contracts/cli.md` verbose output spec:
```
[dns] Resolver pool: [10.26.1.1, 1.1.1.1, 8.8.8.8, ...]
[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] 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] Stage 2.5: Split-horizon cross-check against local resolvers [10.26.1.1]
[dns] Result: 93.184.216.34
```
- **Function signature changes**: Thread an `io.Writer` through the pipeline. Pass `io.Discard` when verbose is off, `os.Stderr` when on. The following signatures gain a `w io.Writer` parameter:
```go
// resolver/modes.go
func resolveDefault(hostname string, pool []string, localResolvers []string, config QueryConfig, depth int) ([]string, error)
// → Add w io.Writer parameter; pass through to all Stage functions.
func resolveAuthoritative(ctx context.Context, authority *AuthoritativeNS, hostname string, pool []string, localResolvers []string, config QueryConfig, depth int) ([]string, error)
// → Add w io.Writer parameter.
// resolver/authority.go
func ParallelNSFanOut(ctx context.Context, resolvers []string, labelLevels []string, timeout time.Duration) []NSResult
// → Add w io.Writer parameter. Log query count and per-level NS results.
func QueryAuthoritative(ctx context.Context, ns *AuthoritativeNS, hostname string, resolvers []string, timeout time.Duration) (ips []string, cnameTarget string, nsHostnameUsed string, err error)
// → Add w io.Writer parameter. Log NS hostname resolution and authoritative query.
// resolver/fallback.go
func ParallelAFallback(ctx context.Context, resolvers []string, hostname string, timeout time.Duration) ([]string, error)
// → Add w io.Writer parameter. Log fallback trigger and per-resolver results.
```
The `Resolve()` entry point constructs the writer:
```go
var w io.Writer = io.Discard
if config.Verbose {
w = os.Stderr
}
```
- **Key decisions and gotchas**:
- Use `fmt.Fprintf(w, ...)` — no `slog` (Go 1.21+). FR-030 says silent by default; FR-031 says verbose to stderr.
- `io.Writer` is more testable than a `bool` — tests can pass a `bytes.Buffer` and assert on verbose output content.
- Prefix all lines with `[dns] ` for easy grep-ability.
- Writing to `io.Discard` is effectively a no-op with zero allocation for format strings without args. For format strings with args, the formatting still happens but the write is discarded. This is acceptable for a diagnostic-only path.
- **Acceptance signal**: Run `dns-helper add -host www.example.com -verbose` and verify stderr shows resolution trace. Run without `-verbose` and verify stderr is silent.
- [X] T028 [P] Update `printUsage()` in `main.go` with new flags and examples
**Context**:
- **Existing code references**: Current `printUsage()` in `main.go` (lines 282-290):
```go
func printUsage() {
fmt.Println("This utility will update the local hosts file with DNS entries obtained by the specified DNS server.")
fmt.Println("Usage: dns-helper [add|delete] -host hostname [-server dns.example.com]")
// ...
}
```
- **Expected new content** (from `contracts/cli.md`):
```
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
```
- **Acceptance signal**: Run `dns-helper` with no args and verify updated usage text.
- [X] T029 [P] Update `README.md` with new CLI flags, resolution modes, program flow, and examples
**Context**:
- **Target file state**: `README.md` exists at repo root. Per copilot-instructions.md, README must be updated after implementation changes with: new CLI flags, new resolution modes, updated examples, any new prerequisites.
- **Expected updates**:
1. Document `-server` (now optional with keyword modes), `-timeout`, `-verbose` flags.
2. Add a **"Resolution Modes"** section explaining the four modes (smart default, local, gateway, explicit IP) with examples for each.
3. Add a **"How Smart Default Resolution Works"** section describing the multi-stage pipeline:
- **Stage 1 — NS Fan-out**: Parallel NS queries across all label levels × all resolvers (local + public) to discover the authoritative nameserver.
- **Stage 2 — Authoritative Query**: Direct A-record query to the discovered NS with recursion disabled for the freshest answer.
- **Stage 2.5 — Split-Horizon Check**: Cross-check the authoritative answer against local resolvers to detect enterprise DNS overrides (runs concurrently with Stage 2).
- **Stage 3 — Parallel A Fallback**: If no NS records found (internal hosts), fall back to parallel A queries across all resolvers, taking the first success.
- **CNAME Following**: Automatic CNAME chain resolution with 10-hop limit.
4. Add a **"Error Messages"** section explaining context-aware errors: private TLD hints, split-horizon conflict reports, CNAME loop detection.
5. Note the per-platform network discovery behavior (Windows: netsh, Linux: ip route + resolv.conf, macOS: route + scutil).
- **Acceptance signal**: `README.md` accurately reflects all new CLI capabilities and includes a clear description of the smart default resolution program flow.
- [X] T030 Run full test suite and build validation
**Context**:
- **Purpose**: Final validation per copilot-instructions.md — run `go1.20 test ./...`, `go1.20 vet ./...`, and `.\build.ps1` to confirm everything compiles and all tests pass.
- **Expected commands**:
```powershell
go1.20 vet ./...
go1.20 test ./... -v
.\build.ps1
```
- **Acceptance signal**: All tests pass. Vet is clean. Build produces binaries. Zero regressions in existing test suite.
- [X] T031 Run quickstart.md manual validation scenarios
**Context**:
- **Purpose**: Execute the manual verification scenarios from `quickstart.md` to confirm end-to-end behavior:
```powershell
.\build\dns-helper.exe add -host www.example.com -verbose
.\build\dns-helper.exe add -host www.example.com -server local -verbose
.\build\dns-helper.exe add -host www.example.com -server gateway -verbose
.\build\dns-helper.exe add -host www.example.com -server 8.8.8.8
.\build\dns-helper.exe add -host www.example.com -timeout 1 -verbose
```
- **Acceptance signal**: All scenarios produce expected output. Existing `delete` commands unaffected.
---
## Dependencies & Execution Order
### Phase Dependencies
- **Phase 1 (Setup)**: No dependencies — can start immediately. Tasks T001-T004 and T004b can all run in parallel.
- **Phase 2 (Foundational)**: Depends on Phase 1 (needs `ServerMode`, `NetworkInfo`, `BootstrapResolvers`). Tasks T005-T008 can run in parallel.
- **Phase 3 (US1 — Smart Default)**: Depends on Phase 2 (needs `udpQuery`, `BuildResolverPool`) and T004b (needs `NewNetworkDiscoverer()` stub for T019). Internal ordering: T009/T010 (NS fan-out) → T011/T012 (authoritative) → T013/T014 (fallback) and T015/T016 (split-horizon) in parallel → T017/T018 (dispatcher) → T019 (CLI).
- **Phase 4 (US2 — Local)**: Depends on Phase 2 (needs `NetworkDiscoverer` interface). T020-T022 can run in parallel. T023 depends on T020-T022 (replaces T004b stubs with real implementations). T024 depends on T018.
- **Phase 5 (US3 — Gateway)**: Depends on Phase 3 (needs `Resolve()`). T025 only.
- **Phase 6 (US4 — Explicit)**: Depends on Phase 3 (needs `Resolve()`). T026 only.
- **Phase 7 (Polish)**: Depends on all phases. T027-T031.
### User Story Dependencies
- **US1 (Smart Default)**: Can start after Phase 2. Core story — all others build on this.
- **US2 (Local Mode)**: Can start after Phase 2 in parallel with US1 for platform discovery (T020-T022). But T024 (local mode tests) depends on `Resolve()` from US1 (T018).
- **US3 (Gateway)**: Depends on `Resolve()` from US1 (T018). Gateway discovery provided by US2 platform tasks.
- **US4 (Explicit)**: Depends on `Resolve()` from US1 (T018). Backward-compatible path.
### Within Each User Story
- Tests MUST be written and FAIL before implementation (TDD)
- Types/models before functions
- Core functions before dispatcher
- Dispatcher before CLI integration
### Parallel Opportunities
**Phase 1** (all in parallel):
- T001 (parse tests) | T002 (parse impl) | T003 (network types) | T004 (network tests) | T004b (stub discoverer constructors)
**Phase 2** (all in parallel):
- T005 (transport tests) | T006 (transport impl) | T007 (pool tests) | T008 (pool impl)
**Phase 3** (within story):
- T009/T010 (NS fan-out) then T011/T012 (authoritative)
- T013/T014 (A fallback) | T015/T016 (split-horizon) — both in parallel after T010
- T017/T018 (dispatcher) after all above
- T019 (CLI) after T018
**Phase 4** (platform tasks in parallel):
- T020 (Windows) | T021 (Linux) | T022 (macOS) — all in parallel
- T023 (constructors) after T020-T022
- T024 (local tests) after T018 + T023
**Phase 7** (polish, partially parallel):
- T027 (verbose) | T028 (usage) | T029 (README) — all in parallel
- T030 (validation) after all above
- T031 (quickstart) after T030
---
## Implementation Strategy
### MVP First (User Story 1 Only)
1. Complete Phase 1: Setup (T001-T004)
2. Complete Phase 2: Foundational (T005-T008)
3. Complete Phase 3: User Story 1 — Smart Default Resolution (T009-T019)
4. **STOP and VALIDATE**: Run `go1.20 test ./...` and `.\build.ps1`. Test US1 by running `dns-helper add -host www.example.com` (no `-server`).
5. The tool works for public hostnames via bootstrap resolvers even without platform discovery.
### Incremental Delivery
1. Setup + Foundational → Foundation ready
2. Add US1 (Smart Default) → Test independently → **MVP! Tool works without `-server` for public hostnames**
3. Add US2 (Local Mode) → Platform discovery unlocks local resolvers for both US1 and US2
4. Add US3 (Gateway Mode) → Adds gateway resolution keyword
5. Add US4 (Explicit Mode) → Verifies backward compatibility through new dispatch path
6. Polish → Verbose output, documentation, final validation
### Task Count Summary
| Phase | Tasks | Description |
|-------|-------|-------------|
| Phase 1: Setup | 5 | T001-T004, T004b |
| Phase 2: Foundational | 4 | T005-T008 |
| Phase 3: US1 (Smart Default) | 11 | T009-T019 |
| Phase 4: US2 (Local Mode) | 5 | T020-T024 |
| Phase 5: US3 (Gateway Mode) | 1 | T025 |
| Phase 6: US4 (Explicit Mode) | 1 | T026 |
| Phase 7: Polish | 5 | T027-T031 |
| **Total** | **32** | |