# ExploreDNS Codebase Review ## 1. What it is and how it's architected ExploreDNS (module `gitea.hansenits.com.au/hits/ExploreDNS`) is a Go rewrite of the Ruby `dnstraverse` tool. Its stated purpose: walk the DNS delegation tree from the root servers down, exploring *every* resolution path a real iterative resolver might take, assigning each branch a probability, and reporting per-path answers/failures plus server software fingerprints. It ships two binaries. **CLI path.** `cmd/exploredns/main.go` parses ~30 stdlib flags into `config.Config` (`internal/config/config.go:25-51`), validates ranges (config.go:112-140), then fans out into three engine configs — `dns.QueryConfig`, `dns.RootDiscoveryConfig`, `traverse.TraverserConfig` (main.go:150-171) — plus an `output.Config` (main.go:199-214). `output.RunTraversal` (internal/output/runner.go:12-45) attaches progress hooks, runs the traversal, fingerprints every encountered server IP via `version.bind` CHAOS TXT probes (internal/fingerprint/fingerprint.go), and renders either a streaming colored text tree (internal/output/text.go) or a buffered JSON document (internal/output/json.go). **The engine.** `internal/traverse/traverser.go` runs a LIFO work-stack loop (Traverse, traverser.go:88-202): discover roots via an upstream recursive resolver (internal/dns/roots.go, hardcoded IANA hints as fallback), pop a referral, query its addresses (`processReferral`, traverser.go:221-274), classify the response (internal/dns/decode.go:86-120), and on a referral push one child branch per NS name with probability = parent/N (internal/traverse/response.go:143-185, 248-256). Glue-less NS names are resolved first via a hardcoded local resolver at 127.0.0.1:53, then via a secondary from-root traversal (`ResolveNS`, traverser.go:276-410). `internal/dns/query.go` does the wire work: UDP with exponential backoff, TCP fallback on truncation, EDNS0 at 2048 bytes. **Web path.** `cmd/server` + `web/api` wrap the same engine as an async job service: POST `/api/traverse` spawns a goroutine and returns a job ID (handler.go:206-243), GET polls, GET `.../stream` delivers SSE progress, and an embedded single-file SPA (`web/api/static/index.html`, served via `go:embed`) is the UI. Jobs live in an in-memory map with a 1-hour TTL. Importantly, the web path builds its **own** hooks and serialization (handler.go:357-450) and bypasses `internal/output` and `internal/fingerprint` entirely — no dedup, no summary, no fingerprints in web results. ## 2. Current health Green on the surface. `go build ./...`, `go vet ./...`, and the full test suite (`-race`, all seven packages) pass cleanly on go 1.26.4; `make build`/`build-server` produce working binaries and the repo stays clean. Live CLI runs work end-to-end (`./bin/exploredns example.com` produces a correct tree, fingerprints, and a 100%-answered summary in ~15-30s; NXDOMAIN and `--json` runs behave; exit 0). The web server serves the SPA, health endpoint, job creation, polling, and SSE correctly against real domains. CI (`.gitea/workflows/ci.yml`) runs vet/test/build with a pinned toolchain and Docker builds of both images; Dockerfiles, module path, and port config are all consistent. Two soft spots: the "coverage check" step (ci.yml:29-32) echoes an average but enforces nothing — it can never fail; and several tests hit the real network or 127.0.0.1:53 with self-skip guards (internal/traverse/coverage_test.go:328, internal/dns/resolver_test.go:466-471), so offline pass/skip counts silently differ. `internal/config/config_test.go:291-310` fails gofmt, uncaught because `make lint` is only `go vet` (Makefile:25). The critical caveat: the tests pass because **every traverse test injects a mock exchange**, which routes through a different code path (RD=0) than production traffic takes (RD=1) — see below. CI green does not mean the core behaviour is correct. ## 3. Where reality diverges from the README (ranked by user impact) 1. **"Full iterative traversal" is false — production queries are recursive.** `buildQuery` hardcodes `RecursionDesired=true` (internal/dns/query.go:221) and `dns.Query` never clears it. The RD=0 path (`IterativeQueryWithExchange`, query.go:146-155) is reached only when a test exchange is injected (traverser.go:416-419). `ensureRDFalse` (traverser.go:445-459) just flips the RD bit on the *response* — cosmetic — and its `t.exchange != nil` branch is unreachable. Any recursion-capable server on the path returns a final recursive answer, which `classify()` accepts as legitimate (decode.go:103-107), silently collapsing the traversal into a plain recursive lookup. This directly contradicts README:3-18 and the package doc (query.go:4-6), and is the single strongest candidate for "doesn't quite work the way I want." 2. **"Follows every referral exhaustively" — only per NS *name*, and fragile per IP.** `processReferral` iterates a referral's addresses in order and returns the **first non-SERVFAIL** response (traverser.go:263-268). Only SERVFAIL advances to the next IP; a timeout or network error on IP #1 kills the whole branch even if IP #2 works. There is no per-address branching at all. 3. **"Query all 13 root server sets in parallel" (README:19, 195-196) — neither all, nor parallel.** All root IPs are packed into ONE initial referral (traverser.go:96-97), so with `--all-root-servers` typically exactly one root is queried (first non-SERVFAIL wins). Nothing in the engine is concurrent — the mutexes at traverser.go:103-106 guard a single-threaded loop. Default runs use a single root chosen as `nsSet[0]` by the upstream resolver (roots.go:110-127), a different one each run. 4. **`--root-server ` is broken end-to-end.** Config accepts only IP literals (config.go:149-158), but `discoverRootOverride` treats the value as a DNS *name* and looks up the IP-as-hostname (roots.go:100-107), always failing with "no addresses for root server 198.41.0.4" — verified live. Unlike the other paths, this one has no hints fallback (roots.go:54-56), so the run aborts. 5. **Fast mode "shares glue across branches" (README:26-27, 352-355) — a no-op beyond the root.** Non-root referrals get a throwaway `rootCache.Child()` (traverser.go:120-127) and `InfoCache` writes never propagate to the parent (cache.go:66-81), so glue learned in one branch is discarded, not shared. `--fast=true` vs `false` differ only marginally. 6. **"No-glue resolution" works but is contaminated and lossy.** It first asks a hardcoded recursive resolver at `127.0.0.1:53`, A-records only (resolveGlueViaSystem, traverser.go:461-501, literal at :484), ignoring `--dns-upstream` and `/etc/resolv.conf`. The fallback `ResolveNS` queries TypeA only (traverser.go:305 — IPv6-only nameservers unresolvable) and its visited-guard (traverser.go:290, 385-387) prunes *every* glue-less child in the sub-traversal, so any NS whose resolution path itself contains a no-glue delegation fails with "resolution exhausted without answer" (traverser.go:406-409). 7. **`--follow-aaaa` (README:110) is a dead flag.** Parsed and stored (main.go:23,74) but never read anywhere in the repo. Related: `discoverRoots` hardcodes IPv4-only (traverser.go:216), discarding AAAA roots even with `--root-aaaa`. 8. **"Configurable... timeouts" (README:25) — no timeout is configurable.** No `--timeout` flag exists; `QueryConfig.Timeout` is hardcoded to 5s at main.go:152 and is *dead* anyway — `realExchange` uses its own hardcoded 5s timeouts (query.go:53-74). 9. **JSON output (README:254-264) is an object, not the documented array, and duplicates every result.** With defaults, each terminal result is appended twice — once by `WriteResult` (json.go:90-96), again by `WriteSummary` with no dedup (json.go:99-103) — so answer probabilities in `results[]` sum to ~2.0 while `summary` sums to 1.0 in the same document (verified: each example.com answer appears 26×). 10. **Smaller doc breaks:** `--quiet` suppresses only the banner line, not "supplementary information" (README:127; `output.Config.Quiet` is never read — formatter.go:40); `-dd` is behaviourally identical to `-d` (Debug>0 is the only check anywhere: main.go:175,224, formatter.go:89); "Requires Go 1.21" (README:43) is wrong — go.mod pins 1.24.0; README omits `--dns-upstream` while built-in `--help` omits `--json`; `--retries` means total *attempts*, and `--retries 0` (allowed per README and validation, config.go:125) makes every query fail with the malformed error `failed after 0 retries: %!w()` (query.go:97,133). What *does* match the README: fingerprinting (CLI only), the web endpoints/SSE replay/done semantics, the 1-hour job TTL, and the CLI flag inventory itself — every documented flag parses. ## 4. Notable issues, half-finished pieces, surprises (ranked) **Correctness — high impact:** 1. Resolve sub-traversal results pollute main output: `AttachHooks` fires `WriteResult` on every EventComplete with no `IsResolve` guard (internal/output/formatter.go:101), so NS hosts' own A records appear as answers in both text and JSON — verified live on hansenits.com.au, where answer probabilities summed to 2.29. 2. `Referral.Bailiwick` is overloaded to carry the NS **hostname**, not a zone (response.go:173-180; acknowledged comment at traverser.go:230-232), while the intended `NSName` field (referral.go:44) is never assigned yet is read by output (text.go:275, stats.go:182). This makes in-bailiwick filtering meaningless (response.go:154, masked by the accept-all fallback at :160-166), leaks "bailiwick":"m.gtld-servers.net" into the JSON/SSE public API, and lists root servers under their bare IPs. 3. `Traverser.visited` is never written and `Traverser.depth` never incremented (traverser.go:60-73, 223-228), so nested no-glue resolutions have no loop protection — circular NS dependencies recurse unboundedly, contained only by an accidental pruning bug and ctx timeouts. The seeding workaround (traverser.go:311-313) is a no-op because `StoreGlue` ignores empty slices (cache.go:67-69). 4. CNAME follows re-query the **same servers** instead of restarting from the root (response.go:200-204); out-of-zone targets hit the wrong authoritative servers. `--type CNAME` also misbehaves: a correct CNAME answer is classified as follow-me (response.go:129-141) and chased instead of reported. Loop detection checks only the final chain target (response.go:191). 5. `queryServer` swallows query errors entirely — a failed server yields `Response{Type: RespError}` with no message or server recorded (traverser.go:425-431); depth-limit rejections synthesize the same empty error with no hook events (traverser.go:157-167). Output can never say *why* a branch failed. 6. Root discovery silently degrades: any upstream failure falls back to hardcoded hints with the error discarded (roots.go:58-73); the discovery query path itself has no EDNS0, no retries, no TCP fallback (roots.go:204-224). **Web stack:** 7. Frontend type `