89 KiB
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 —NetworkInfotype,NetworkDiscovererinterface,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_testsuffix in same package or_testpackage. Seeresolver/resolver_test.gofor fake DNS test patterns andplatform/platform_test.gofor platform test patterns.
Parsing Utilities
-
T001 [P] Create
ParseServerFlag()andExtractLabelLevels()tests inresolver/parse_test.goContext:
- Target file state:
resolver/parse_test.godoes not exist yet. Theresolver/directory already containsresolver.goandresolver_test.go. The test file should usepackage resolver_test(external test package) matching the convention inresolver/resolver_test.go. - Expected implementation pattern: Use table-driven tests following the existing project convention. Write tests for:
ParseServerFlag("")→ServerMode{Mode: "default"}, no errorParseServerFlag("local")→ServerMode{Mode: "local"}, no errorParseServerFlag("LOCAL")→ServerMode{Mode: "local"}, no error (case-insensitive)ParseServerFlag("gateway")→ServerMode{Mode: "gateway"}, no errorParseServerFlag("GATEWAY")→ServerMode{Mode: "gateway"}, no errorParseServerFlag("10.0.0.53")→ServerMode{Mode: "explicit", ExplicitAddr: "10.0.0.53"}, no errorParseServerFlag("10.0.0.53:5353")→ServerMode{Mode: "explicit", ExplicitAddr: "10.0.0.53:5353"}, no errorParseServerFlag("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")→ errorExtractLabelLevels("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")→nilExtractLabelLevels("example.com.")→["example.com"](trailing dot stripped)
- Key decisions and gotchas:
ParseServerFlagkeywords are case-insensitive per CLI contract. Port validation must reject 0 and >65535.ExtractLabelLevelsmust strip trailing dot before splitting. Single-label hostnames return nil. The typesServerModeis defined inresolver/parse.go(T002). - Acceptance signal: Tests compile but FAIL because
resolver/parse.godoes not exist yet.go1.20 test ./resolver/... -run TestParseServerFlagandgo1.20 test ./resolver/... -run TestExtractLabelLevelsshould show compilation errors.
- Target file state:
-
T002 [P] Create
ParseServerFlag(),ExtractLabelLevels(), and types inresolver/parse.goContext:
- Target file state:
resolver/parse.godoes not exist yet. Theresolver/package currently containsresolver.go(which defines theResolverinterface andDNSResolverstruct). - Expected implementation pattern: Define types and implement pure functions:
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.ToLowerfor case-insensitive keyword matching. - For IP:port parsing, use
net.SplitHostPortwhich handles the colon splitting. If it fails (no port), trynet.ParseIPdirectly. - Port validation:
strconv.Atoithen check 1 ≤ port ≤ 65535. - For
ExtractLabelLevels: strip trailing dot first, thenstrings.Split(hostname, "."). Generate all suffixes with ≥2 parts. Return nil for single-label hostnames. - Must also define
BootstrapResolvers,PrivateTLDs, andmaxCNAMEDepthconstants here (FR-007, FR-024, FR-019):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
- Use
- Acceptance signal:
go1.20 test ./resolver/... -run TestParseServerFlagandgo1.20 test ./resolver/... -run TestExtractLabelLevelsall pass.go1.20 vet ./resolver/...clean.
- Target file state:
Platform Network Discovery Interface
-
T003 [P] Create
NetworkInfo,NetworkDiscovererinterface, andFakeNetworkDiscovererinplatform/network.goContext:
- Target file state:
platform/network.godoes not exist yet. Theplatform/package currently containsplatform.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 onlyGetHostsFilePath(). No build tags onnetwork.go— it is shared across all platforms. - Expected implementation pattern:
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.
FakeNetworkDiscovereris exported so other packages (likeresolver) can use it in tests. All fields inNetworkInfomay be empty (per FR-009, emptyDNSServersis not an error). - Acceptance signal:
go1.20 build ./platform/...succeeds.go1.20 vet ./platform/...clean.
- Target file state:
-
T004 [P] Create tests for
FakeNetworkDiscovererinplatform/network_test.goContext:
- Target file state:
platform/network_test.godoes not exist yet.platform/platform_test.goalready exists with tests forGetHostsFilePath()usingpackage platform_test. - Expected implementation pattern: Simple tests verifying the fake returns expected values:
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 TestFakeNetworkDiscovererpasses.
- Target file state:
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)
-
T004b [P] Create stub
NewNetworkDiscoverer()constructors inplatform/discoverer_<os>.goContext:
- 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 enablesmain.go(T019) to compile in Phase 3 before the real platform implementations arrive in Phase 4.//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
NetworkInfowith 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 replacestubNetworkDiscovererwithWindowsNetworkDiscovereretc., and T023 will updateNewNetworkDiscoverer()to return the real type. - Acceptance signal:
go1.20 build .succeeds on the current platform.main.gocan callplatform.NewNetworkDiscoverer().
- Target file state: Three new files needed, one per platform, each with a build tag:
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:
ServerModefromresolver/parse.go:type ServerMode struct { Mode string; ExplicitAddr string }NetworkInfofromplatform/network.go:type NetworkInfo struct { DNSServers []string; Gateway string; Interface string }BootstrapResolversfromresolver/parse.go:var BootstrapResolvers = []string{"1.1.1.1", "8.8.8.8", ...}
- Codebase conventions to follow: Existing
resolver.gousesnet.DialTimeout("udp", addr, defaultTimeout)for connections anddnsmessagefor DNS wire protocol. Seeresolver.golines 74-93 for the UDP dial+write+read pattern. Tests usestartFakeDNS/startFakeDNSMultihelpers fromresolver_test.go.
Shared Transport Layer
-
T005 [P] Create tests for shared UDP query transport in
resolver/transport_test.goContext:
- Target file state:
resolver/transport_test.godoes not exist. The existingresolver/resolver_test.gocontainsstartFakeDNS,startFakeDNSMulti,queryID,buildAResponse,buildCNAMEResponse,buildNXDOMAINResponse, andmustNewNamehelpers. These helpers are inpackage resolver_test(external test package). The new transport test file should also usepackage resolver_test. - Expected implementation pattern: Test
UDPQueryfunction that sends a raw DNS query and returns raw bytes: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
UDPQueryfunction accepts acontext.Contextfor cancellation. It usesnet.Dialer.DialContext()(available since Go 1.7) instead ofnet.DialTimeoutso the context can propagate cancellation. Timeout is set viaconn.SetDeadline()derived from context deadline. Must reuse thestartFakeDNSpattern fromresolver_test.go— but since that's inpackage 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 arepackage resolver_test, helpers are shared. - Acceptance signal: Tests compile but FAIL because
resolver/transport.godoes not exist yet.
- Target file state:
-
T006 [P] Implement shared UDP query transport in
resolver/transport.goContext:
- Existing code references: The existing UDP transport pattern in
resolver/resolver.go(lines 74-93):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.godoes not exist. This extracts the UDP dial/write/read pattern into a reusable function. - Expected implementation pattern:
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 theresolverpackage. UsesudpBufSizeconstant already defined inresolver.go(1232 bytes). The existinglookupIPWithDepthinresolver.gois 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 TestUDPQuerypasses.
- Existing code references: The existing UDP transport pattern in
Resolver Pool Construction
-
T007 [P] Create tests for
BuildResolverPool()inresolver/pool_test.goContext:
- Target file state:
resolver/pool_test.godoes not exist. - Expected implementation pattern: Table-driven tests covering all modes:
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.godoes not exist yet.
- Target file state:
-
T008 [P] Implement
BuildResolverPool()inresolver/pool.goContext:
- Depends on:
ServerModefromresolver/parse.go(T002),NetworkInfofromplatform/network.go(T003),BootstrapResolversfromresolver/parse.go(T002). - Target file state:
resolver/pool.godoes not exist. - Expected implementation pattern:
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
nilfor unknown modes (caller handles). Deduplication uses amap[string]boolwith 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 TestBuildResolverPoolpasses.go1.20 vet ./resolver/...clean.
- Depends on:
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:
ServerModefromresolver/parse.go:type ServerMode struct { Mode string; ExplicitAddr string }QueryConfigfromresolver/parse.go:type QueryConfig struct { Timeout time.Duration; Verbose bool }NSResultfromresolver/authority.go(defined in this phase):type NSResult struct { LabelLevel string; Resolver string; NSRecords []string; CNAMETarget string; Err error }AuthoritativeNSfromresolver/authority.go(defined in this phase):type AuthoritativeNS struct { Zone string; Nameservers []string }SplitHorizonResultfromresolver/splithorizon.go(defined in this phase)NetworkDiscovererfromplatform/network.go:type NetworkDiscoverer interface { Discover() (NetworkInfo, error) }BuildResolverPoolfromresolver/pool.goudpQueryfromresolver/transport.go- Existing
startFakeDNS/startFakeDNSMultitest helpers fromresolver/resolver_test.go
- Codebase conventions to follow: Error wrapping with
fmt.Errorf("context: %w", err). Use goroutines + buffered channels for parallelism (R-000). Usecontext.WithTimeoutfor per-query timeouts. DNS wire protocol viadnsmessagepackage. Tests use fake UDP DNS servers listening on127.0.0.1:0.
Stage 1: Parallel NS Fan-out
-
T009 [P] [US1] Create tests for
ParallelNSFanOut()andSelectAuthoritativeNS()inresolver/authority_test.goContext:
- Target file state:
resolver/authority_test.godoes not exist. Test helpers (startFakeDNS,startFakeDNSMulti,queryID,mustNewName,buildAResponse,buildNXDOMAINResponse) are inresolver/resolver_test.gounderpackage resolver_testand are accessible from this file (same test package). - Expected implementation pattern: Need a new helper
buildNSResponsefor NS queries:Test cases: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 }- 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.comandsub.example.com, verifysub.example.comselected. - 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.
- Fan-out with 2 resolvers × 2 levels: One resolver returns NS for
- Key decisions and gotchas: The fake DNS servers need to handle NS queries (check for
dnsmessage.TypeNSin the question). UsestartFakeDNSMultisince the fan-out sends multiple queries. NS query response parsing usesparser.NSResource()which returnsNSResource{NS: dnsmessage.Name}. - Acceptance signal: Tests compile but FAIL because
resolver/authority.godoes not exist yet.
- Target file state:
-
T010 [US1] Implement
NSResult,AuthoritativeNS,ParallelNSFanOut(), andSelectAuthoritativeNS()inresolver/authority.goContext:
- Depends on:
udpQueryfromresolver/transport.go(T006),ExtractLabelLevelsfromresolver/parse.go(T002). - Target file state:
resolver/authority.godoes not exist. - Expected implementation pattern:
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×Mper R-000 research. Collector reads exactlyN×Mresults — 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.gobut withdnsmessage.TypeNSinstead ofdnsmessage.TypeA. Parse withparser.NSResource()which returnsNSResource{NS: dnsmessage.Name}. - NXDOMAIN (
RCodeNameError): produceNSResult{Err: nil, NSRecords: nil}— not an error. SelectAuthoritativeNSselection: count labels viastrings.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.
- Use buffered channel sized to
- Acceptance signal:
go1.20 test ./resolver/... -run TestParallelNSFanOutandgo1.20 test ./resolver/... -run TestSelectAuthoritativeNSpass.
- Depends on:
Stage 2: Authoritative Query
-
T011 [P] [US1] Create tests for
QueryAuthoritative()inresolver/authority_test.goContext:
- Target file state:
resolver/authority_test.goalready 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:
QueryAuthoritativeneeds to resolve the NS hostname to an IP first (using the resolver pool). Then it sends an A query withRecursionDesired: falseto that IP. The fake DNS server for the authoritative NS should check thatRecursionDesiredis false in the incoming query. The function now returns a 4th valuensHostnameUsed stringwhich 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.
- Target file state:
-
T012 [US1] Implement
QueryAuthoritative()inresolver/authority.goContext:
- Depends on:
udpQueryfromresolver/transport.go(T006),ParallelNSFanOutfor NS hostname resolution. - Target file state:
resolver/authority.goalready exists from T010 withParallelNSFanOut,SelectAuthoritativeNS,NSResult,AuthoritativeNStypes. - Expected implementation pattern:
// 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: falsein thednsmessage.Headerwhen querying the authoritative NS. - The response parser follows the same pattern as
resolver.golines 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.
resolveNSHostnamehelper: send A query to each resolver in the pool, return first IP. This is a simple sequential try, not a full parallel fan-out.
- Set
- Acceptance signal:
go1.20 test ./resolver/... -run TestQueryAuthoritativepasses.
- Depends on:
Stage 3: Parallel A Fallback
-
T013 [P] [US1] Create tests for
ParallelAFallback()inresolver/fallback_test.goContext:
- Target file state:
resolver/fallback_test.godoes not exist. - Expected implementation pattern: Uses the same fake DNS helpers from
resolver_test.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.godoes not exist.
- Target file state:
-
T014 [US1] Implement
ParallelAFallback()inresolver/fallback.goContext:
- Depends on:
udpQueryfromresolver/transport.go(T006). - Target file state:
resolver/fallback.godoes not exist. - Expected implementation pattern:
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: returnnil, fmt.Errorf(...)(an error, not a success). - Acceptance signal:
go1.20 test ./resolver/... -run TestParallelAFallbackpasses.
- Depends on:
Stage 2.5: Split-Horizon Conflict Detection
-
T015 [P] [US1] Create tests for
CheckSplitHorizon()inresolver/splithorizon_test.goContext:
- Target file state:
resolver/splithorizon_test.godoes not exist. - Expected implementation pattern:
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.
nillocal IPs (NXDOMAIN or failure) means no conflict. TheSplitHorizonResulttype is defined inresolver/splithorizon.go. - Acceptance signal: Tests compile but FAIL because
resolver/splithorizon.godoes not exist.
- Target file state:
-
T016 [US1] Implement
CheckSplitHorizon()andSplitHorizonResultinresolver/splithorizon.goContext:
- Depends on:
queryAfromresolver/fallback.go(T014) orudpQueryfromresolver/transport.go(T006). - Target file state:
resolver/splithorizon.godoes not exist. - Expected implementation pattern:
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
queryAfunction fromfallback.goreturns an error for NXDOMAIN. So NXDOMAIN from a local resolver meanserr != 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()inmodes.go) is responsible for that gating. - FR-038: This function is only called when local resolvers exist — the caller checks.
- Note: The
ipSetsEqualandsortedDeduphelper functions are used both here and inresolveAuthoritative(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.CheckSplitHorizonremains available for standalone testing, but the primary production path uses the concurrent pattern inresolveAuthoritative.
- The
- Acceptance signal:
go1.20 test ./resolver/... -run TestCheckSplitHorizonpasses.
- Depends on:
Mode Dispatcher
-
T017 [P] [US1] Create tests for
Resolve()mode dispatcher inresolver/modes_test.goContext:
- Target file state:
resolver/modes_test.godoes not exist. - Expected implementation pattern: Tests for the default mode path through
Resolve():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
FakeNetworkDiscovererfromplatform/network.gois used to inject deterministic local resolver/gateway info. - Acceptance signal: Tests compile but FAIL because
resolver/modes.godoes not exist.
- Target file state:
-
T018 [US1] Implement
Resolve()mode dispatcher inresolver/modes.goContext:
- Depends on: All prior resolver functions:
ParseServerFlag,BuildResolverPool,ExtractLabelLevels,ParallelNSFanOut,SelectAuthoritativeNS,QueryAuthoritative,ParallelAFallback,CheckSplitHorizon. - Target file state:
resolver/modes.godoes not exist. - Expected implementation pattern:
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):
resolveDefaultaccepts adepth intparameter. On CNAME restart, it callsresolveDefault(cnameTarget, pool, localResolvers, config, depth+1). The depth check at the top ofresolveDefaultrejects anything overmaxCNAMEDepth(10). This ensures the limit works correctly even across multiple restarts. - No FakeNetworkDiscoverer in production:
resolveDefaultEntrydiscovers network info once and passespoolandlocalResolversas plain slices toresolveDefault. CNAME restarts reuse these slices directly — no need to wrap them in a fake discoverer. - Split-horizon concurrent cross-check (FR-033, SC-011):
resolveAuthoritativefires 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 whenlen(localResolvers) > 0(FR-038) and only in default mode (FR-037). - QueryAuthoritative return value change:
QueryAuthoritativenow returns 4 values:(ips []string, cnameTarget string, nsHostnameUsed string, err error). ThensHostnameUsedis the actual NS hostname that answered (e.g.,"ns1.example.com"), used asAuthoritativeSourcein the split-horizon conflict error message (FR-035). - Explicit mode timeout (FR-029):
resolveExplicitusesqueryAwithconfig.Timeoutinstead ofNew().LookupIP()to respect the user's-timeoutflag. - Private TLD hint: only added when resolution fails entirely (FR-025).
resolveLocalandresolveGatewaydo NOT fall back to public resolvers (FR-026, FR-027).- Verbose output (
config.Verbose): emit toos.Stderrusingfmt.Fprintf. Add verbose logging at key stages if Verbose is true. This can be deferred to the T027 polish task.
- CNAME depth tracking (FR-019):
- Acceptance signal:
go1.20 test ./resolver/... -run TestResolvepasses for all default mode test cases.
- Depends on: All prior resolver functions:
CLI Integration
-
T019 [US1] Update
main.goto support optional-server, new-timeout/-verboseflags, and mode dispatchContext:
- Existing code references: Current
runAddfunction inmain.go(lines 42-70):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.gocurrently hasrunAddthat requires-serverand usesresolver.New().LookupIP()directly. It needs to:- Remove the
-serverrequired check (make it optional). - Add
-timeout(int, default 3) and-verbose(bool, default false) flags. - Parse
-serverwithresolver.ParseServerFlag(). - Validate
-timeout(must be positive). - Dispatch to
resolver.Resolve()instead ofresolver.New().LookupIP(). - Create a platform
NetworkDiscoverer— need a concrete discoverer. For now, import the platform-specific one (T020-T022 provide it). - Update
printUsage()with new examples.
- Remove the
- Expected changes to
runAdd: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 stubNewNetworkDiscoverer()that returns&stubNetworkDiscoverer{}(returns empty NetworkInfo, no error). The real implementations (T020-T022) replace the stub bodies in Phase 4. This waymain.gocompiles cleanly in Phase 3. - Update
printUsage()to match the CLI contract fromcontracts/cli.md. - The
-serverflag is now optional — remove the empty check that returns error. - Existing import of
"dns-helper/resolver"and"dns-helper/platform"already present.
- Must add
- Acceptance signal:
go1.20 build .succeeds. Runningdns-helper add -host www.example.com(no-server) invokes the new smart default resolution path. Running with-server 8.8.8.8still uses the existing explicit IP path.
- Existing code references: Current
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:
NetworkDiscovererinterface fromplatform/network.go:type NetworkDiscoverer interface { Discover() (NetworkInfo, error) }NetworkInfofromplatform/network.go:type NetworkInfo struct { DNSServers []string; Gateway string; Interface string }
- Codebase conventions to follow: Platform files use
//go:build <os>build tags (seeplatform_windows.go,platform_linux.go,platform_darwin.go). Useos/exec.Command()for external commands. Validate IPs withnet.ParseIP(). Wrap errors with context usingfmt.Errorf.
Windows Network Discovery
-
T020 [P] [US2] Implement
WindowsNetworkDiscovererinplatform/network_windows.goContext:
- Target file state:
platform/platform_windows.goexists with//go:build windowsand onlyGetHostsFilePath(). The new discoverer struct andDiscover()method go in a new fileplatform/network_windows.gowith//go:build windows. - Expected implementation pattern:
//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 withnet.ParseIP(). - Always quote the interface name in the
netshcommand:name="vEthernet (Bridge)". - If any
exec.Commandfails, return partialNetworkInfowith a wrapped error.
- Step 1 parsing: Scan each line for literal
- 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 matchipconfig /alloutput.
- Target file state:
Linux Network Discovery
-
T021 [P] [US2] Implement
LinuxNetworkDiscovererinplatform/network_linux.goContext:
- Target file state:
platform/platform_linux.goexists with//go:build linuxand onlyGetHostsFilePath(). New fileplatform/network_linux.gowith//go:build linux. - Expected implementation pattern:
//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 defaultoutput:default via 192.168.1.1 dev eth0 proto dhcp metric 100. Parsevia <IP>anddev <iface>by keyword matching.viaanddevare not localized — they are fixed iniproute2./etc/resolv.conf: read lines, skip#and;comments, extract IP afternameserverkeyword. Validate withnet.ParseIP().- Stub resolver detection: if all nameservers are in
{127.0.0.53, 127.0.0.1, 127.0.1.1}, tryresolvectl status <interface>. ParseDNS Servers:line and indented continuation lines for IPs. - If
resolvectlis not found (exec.LookPathfails), skip — use the stub addresses as-is (they're still functional). - If
/etc/resolv.confdoesn'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.
- Target file state:
macOS Network Discovery
-
T022 [P] [US2] Implement
DarwinNetworkDiscovererinplatform/network_darwin.goContext:
- Target file state:
platform/platform_darwin.goexists with//go:build darwinand onlyGetHostsFilePath(). New fileplatform/network_darwin.gowith//go:build darwin. - Expected implementation pattern:
//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 defaultoutput: scan forgateway: <IP>andinterface: <name>. Keywords are fixed (not localized). Validate gateway withnet.ParseIP().scutil --dnsparsing: split into resolver blocks byresolver #Nlines. For each block, extractnameserver[N] : <IP>lines andif_index : N (<iface>)line. Match the block whoseif_indexcontains 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 configurationheader). - Fallback: if no interface match, use
resolver #1from the top section.
- Acceptance signal:
GOOS=darwin go1.20 build ./platform/...succeeds.go1.20 vet ./platform/...clean.
- Target file state:
Platform Constructor
-
T023 [US2] Update
NewNetworkDiscoverer()stubs (from T004b) to return real platform discoverers inplatform/discoverer_<os>.goContext:
- Target file state:
platform/discoverer_windows.go,platform/discoverer_linux.go, andplatform/discoverer_darwin.goalready exist from T004b with stub discoverers. Now replace the stub with the real implementation. - Expected implementation pattern: In each file, remove the
stubNetworkDiscoverertype and updateNewNetworkDiscoverer()to return the real discoverer://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 allowsmain.goto callplatform.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.gocan callplatform.NewNetworkDiscoverer()and get real platform discovery results.
- Target file state:
Local Mode Tests
-
T024 [US2] Add local mode tests to
resolver/modes_test.goContext:
- Target file state:
resolver/modes_test.goexists from T017 with default mode tests. - Expected implementation pattern: Add tests for local mode:
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_LocalModepasses.
- Target file state:
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()fromresolver/modes.go,NetworkDiscovererfromplatform/network.go,FakeNetworkDiscovererfor testing - Codebase conventions to follow: Same error message patterns as local mode. Gateway discovery reuses the
Discover()implementations from Phase 4.
-
T025 [US3] Add gateway mode tests to
resolver/modes_test.goContext:
- Target file state:
resolver/modes_test.goexists from T017/T024 with default and local mode tests. - Expected implementation pattern:
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
resolveGatewayimplementation inmodes.go(T018) already handles this. These tests verify the integration. - Acceptance signal:
go1.20 test ./resolver/... -run TestResolve_GatewayModepasses.
- Target file state:
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()fromresolver/modes.go,ServerModefromresolver/parse.go - Codebase conventions to follow: Backward compatibility is critical — existing
LookupIPbehavior must be preserved exactly.
-
T026 [US4] Add explicit mode tests to
resolver/modes_test.goContext:
- Target file state:
resolver/modes_test.goexists with default, local, and gateway mode tests. - Expected implementation pattern:
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
queryAwithconfig.Timeoutinstead ofresolver.New().LookupIP()to ensure the user's-timeoutflag is respected (FR-029). The existingresolver_test.gotests 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_ExplicitModepasses. Existing tests inresolver_test.gocontinue to pass.
- Target file state:
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.Verboseflag fromresolver/parse.go - Codebase conventions to follow: Verbose output goes to
os.Stderrusingfmt.Fprintf. Prefix all verbose lines with[dns]. Seecontracts/cli.mdfor exact verbose output format. README updates per copilot-instructions.md (must be done after every implementation change).
-
T027 [P] Add verbose diagnostic output to resolver pipeline in
resolver/modes.go,resolver/authority.go,resolver/fallback.go,resolver/splithorizon.goContext:
- Target file state: All four files exist from Phases 2-3. Verbose output needs to be threaded through the resolution pipeline via an
io.Writerparameter. - Expected implementation pattern: Add verbose logging at key decision points per
contracts/cli.mdverbose 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.Writerthrough the pipeline. Passio.Discardwhen verbose is off,os.Stderrwhen on. The following signatures gain aw io.Writerparameter:The// 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.Resolve()entry point constructs the writer:var w io.Writer = io.Discard if config.Verbose { w = os.Stderr } - Key decisions and gotchas:
- Use
fmt.Fprintf(w, ...)— noslog(Go 1.21+). FR-030 says silent by default; FR-031 says verbose to stderr. io.Writeris more testable than abool— tests can pass abytes.Bufferand assert on verbose output content.- Prefix all lines with
[dns]for easy grep-ability. - Writing to
io.Discardis 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.
- Use
- Acceptance signal: Run
dns-helper add -host www.example.com -verboseand verify stderr shows resolution trace. Run without-verboseand verify stderr is silent.
- Target file state: All four files exist from Phases 2-3. Verbose output needs to be threaded through the resolution pipeline via an
-
T028 [P] Update
printUsage()inmain.gowith new flags and examplesContext:
- Existing code references: Current
printUsage()inmain.go(lines 282-290):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-helperwith no args and verify updated usage text.
- Existing code references: Current
-
T029 [P] Update
README.mdwith new CLI flags, resolution modes, program flow, and examplesContext:
- Target file state:
README.mdexists 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:
- Document
-server(now optional with keyword modes),-timeout,-verboseflags. - Add a "Resolution Modes" section explaining the four modes (smart default, local, gateway, explicit IP) with examples for each.
- 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.
- Add a "Error Messages" section explaining context-aware errors: private TLD hints, split-horizon conflict reports, CNAME loop detection.
- Note the per-platform network discovery behavior (Windows: netsh, Linux: ip route + resolv.conf, macOS: route + scutil).
- Document
- Acceptance signal:
README.mdaccurately reflects all new CLI capabilities and includes a clear description of the smart default resolution program flow.
- Target file state:
-
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.ps1to confirm everything compiles and all tests pass. - Expected commands:
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.
- Purpose: Final validation per copilot-instructions.md — run
-
T031 Run quickstart.md manual validation scenarios
Context:
- Purpose: Execute the manual verification scenarios from
quickstart.mdto confirm end-to-end behavior:.\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
deletecommands unaffected.
- Purpose: Execute the manual verification scenarios from
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 (needsNewNetworkDiscoverer()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
NetworkDiscovererinterface). 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)
- Complete Phase 1: Setup (T001-T004)
- Complete Phase 2: Foundational (T005-T008)
- Complete Phase 3: User Story 1 — Smart Default Resolution (T009-T019)
- STOP and VALIDATE: Run
go1.20 test ./...and.\build.ps1. Test US1 by runningdns-helper add -host www.example.com(no-server). - The tool works for public hostnames via bootstrap resolvers even without platform discovery.
Incremental Delivery
- Setup + Foundational → Foundation ready
- Add US1 (Smart Default) → Test independently → MVP! Tool works without
-serverfor public hostnames - Add US2 (Local Mode) → Platform discovery unlocks local resolvers for both US1 and US2
- Add US3 (Gateway Mode) → Adds gateway resolution keyword
- Add US4 (Explicit Mode) → Verifies backward compatibility through new dispatch path
- 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 |