Reconstructed behaviour spec of dns.squish.net / Ruby dnstraverse 0.1.14 (inputs, traversal semantics, probability model, verbatim output formats, sourced from the live site, Wayback captures, and the Ruby source), the engine rework design that maps it onto Go, a point-in-time codebase review, golden reference captures, and tools/golden/run-reference.sh for running the reference Ruby engine locally (clone is gitignored, GPL-3 dev-only). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
11 KiB
ExploreDNS Engine Rework — Design
Goal: make ExploreDNS's traversal semantics and CLI output match the Ruby
dnstraverse 0.1.14 engine (the engine behind dns.squish.net). The authoritative
behaviour spec is dnstraverse-reference-spec.md;
the current-state review is codebase-review-2026-07-07.md.
The reference Ruby source is at tools/dnstraverse-ruby/ (run it via
tools/golden/run-reference.sh). Golden captures live in docs/captures/.
When this document and the Ruby source disagree, the Ruby source wins —
read the relevant .rb file before implementing each piece.
Ground rules
- Every traversal query is non-recursive (RD=0). RD=1 is permitted only for
the initial root discovery against the configured upstream resolver.
Delete the current split where production takes
dns.Query(RD=1) and tests takeIterativeQueryWithExchange; there must be exactly one query path, used by both production and tests (tests inject a mock exchange into that path). RemoveensureRDFalseand the hardcoded127.0.0.1:53shortcuts entirely. - Packet cache: within one run, each (server IP, qname, qclass, qtype,
udpsize) is sent at most once (
caching_resolver.rb). This is separate from fast mode. - EDNS0: OPT added when udp-size > 512 (default 2048). On FORMERR/NOTIMP/
SERVFAIL with udpsize > 512, retry once at 512 and record warning
<server> doesn't seem to support EDNS0. UDP→TCP retry on truncation when allow-tcp (default true). Timeout 2s, retries default 2 (mirror dnsruby retry semantics — read how dnsruby uses retry_times before implementing). - IPv4 only for server selection, like the reference. AAAA records still decode and display in answers; they are never used for transport.
- Probabilities at the root must sum to 1.0 across aggregated leaves. Add a test asserting this invariant on mock topologies.
Type mapping (Ruby → Go, all in internal/traverse unless noted)
| Ruby file | Go file | Notes |
|---|---|---|
referral.rb |
referral.go |
The core. Rewrite, keep the name. |
info_cache.rb |
cache.go |
Hierarchical per-branch cache. Rewrite. |
decoded_query.rb + decoded_query_cache.rb |
decoded_query.go |
Classification + packet cache. |
response*.rb |
response.go |
Response wrapper + noglue/loop variants. |
summary_stats.rb |
stats.go (move from internal/output) |
Leaf aggregation + summary. |
traverser.rb |
traverser.go |
Stack loop, roots, resolve orchestration. |
caching_resolver.rb |
internal/dns |
Packet-level dedupe. |
Referral
- Fields:
refid string,parent *Referral,qname/qclass/qtype,server string(NS hostname; a synthetic "rootroot" node has server="" and is never displayed),serverIPs []string(nil ⇒ needs resolving),bailiwick string,infoCache *InfoCache(per-branch),children, per-IP responses,serverWeights map[string]float64, warnings. - RefID grammar: dotted path, children numbered from 1 (
1,1.1,1.1.2). A glue-resolution subtree inserts a.0component (1.2.0.1); nested resolves nest further. If more than one IP of a server produced children, an extra childset digit is appended. Depth = count of non-0components; exceeding max-depth (default 20) injects an exception responseMaxdepth N exceeded. process(): query each IP in serverIPs (weight 1/len(ips) each) through the packet cache; classify each response; statusesreferralandrestartproduce children (one child per NS name in the referral, including glueless NS with serverIPs=nil).
Classification (decoded_query.rb — mirror the order exactly)
- network exception →
exception - follow CNAMEs within the message (
msg_follow_cnames); chain leaving the bailiwick stops following and returns the target; in-message loop →cname_loop - rcode != NOERROR →
errorwith messages exactly:Format error (FORMERR),Server failure (SERVFAIL),No such domain (NXDOMAIN),Not implemented (NOTIMP),Refused, else the rcode string. (The Ruby source has a typo "Formate error" — we deliberately fix it; this is a documented deviation.) - answers exist for endname/qtype →
answered - endname != qname (CNAME landed elsewhere) →
restart - SOA in authority, or no NS in authority →
nodata - NS in authority →
referral; elserestart
Full status vocabulary: answered, nodata, referral, restart, referral_lame, error, exception, cname_loop, noglue, loop.
Bailiwick + InfoCache
insideBailiwick(name): bailiwick == "" (root), or equal fold, or name ends with "." + bailiwick.msgCacheable: partition all sections (answer/authority/additional; OPT dropped) into in-bailiwick (cached) vs out-of-bailiwick (discarded).- InfoCache is hierarchical: each Response wraps a child cache
(
InfoCache{parent});add()replaces any existing same name:class:type key; lookups recurse to parent.getStartServers(domain)walks labels upward to the nearest cached NS RRset; returns[{name, ips-or-nil}]plus newbailiwick = the NS owner name. - Lame referral: a
referralbecomesreferral_lameunless the new zone is strictly deeper than the current bailiwick.
Glue resolution (no local resolver — ever)
Child with serverIPs == nil:
- NS name inside the current bailiwick with no glue →
nogluedead end (probability retained on the failure). - An ancestor referral with the same qname/qclass/qtype/server still
unresolved →
loopdead end. - Otherwise: resolve subtree for
A <servername>(refid.0.component), starting fromgetStartServers(servername)in this branch's cache. Everyansweredleaf distributes its probability evenly across the returned A records intoserverWeights[ip]; failed leaves carry their probability as pseudo-IPkey:...entries so failures surface in Results.
CNAME restarts
restart children get qname = CNAME target, starters from the branch cache
(deepest cached zone — root only if nothing deeper cached), and the new
bailiwick from getStartServers. Loop check against the ancestor chain must
cover every target in a multi-record chain, not just the last.
Probability model (summary_stats.rb — exact)
- serverweight = 1/len(serverIPs) per IP at referral creation.
percent = (1.0/len(children)) * weight; child probability accumulates multiplied down the tree.- Leaf aggregation key:
key:<status>:<ip>:<server>:<qname>:<qclass>:<qtype>(+ exception message for exception; + parent_ip for referral_lame; NoGlue/ Loop use their own field order — read the Ruby). Identical keys merge by summing probability. - Summary groups by status; answered additionally by sorted rdata strings, so one summary line per distinct RRset content.
Fast mode (default on)
Global memo keyed
"<qname>:<qclass>:<qtype>:<server>:<txt_ips_verbose>" (lowercased;
txt_ips_verbose embeds per-IP weights). A completed referral with no
referral_lame response is stored; a hit replaces the child before processing
and is reported as completed earlier (<original refid>). Non-fast mode
re-walks every branch.
Roots
- Default: ask the upstream resolver (
--dns-upstream, else system resolver from /etc/resolv.conf — not hardcoded 127.0.0.1) for. NS, pick ONE root.--all-root-servers: fetch the full set, create one top-level child per root with equal weight. --root-server VALUE: accept hostname or IP literal. Hostname → resolve to A via upstream; IP → use directly. Fix the current IP-looked-up-as-hostname bug.
CLI output (text) — match the reference byte-for-byte where shown in spec §4.5
- Header block (suppressed by
--quiet):# Using fast mode,# Limiting traverse to one root,# UDP size N (EDNS0 is <on|off>)(fix the Ruby always-on bug — documented deviation),# Retries N, max depth N,# Allow TCP is <bool>, always TCP is <bool>, thenUsing <root> (<ip>) as initial root,Running query <domain> type <t>. - Progress:
<refid> <server> (<ip1>,<ip2>); verbose adds[qname]and<bailiwick>; markers-- resolving,-- completed earlier (<refid>). - Results (spec §4.5 wording catalogue,
%5.1f%%with trailing.0trimmed):Answer from <server> (<ip>)+ dig-style RRs indented 12 spaces;No glue at <parent> (<ip>) for <server>;Lame referral from <parent> (<ip>) to <server> (<ip>);Loop encountered at <server>;CNAME loop encountered at <server>;NODATA (for this type) at <server> (<ip>);<error message> at <server> (<ip>);<exception message> at <server> (<ip>); plusWhile querying <qname>/<qclass>/<qtype>when the failing query differs. - Summary Results:
%5.1f%% answered with <RR whitespace-collapsed>/resulted in a lame referral/resulted in an exception/resulted in an error/found no such record/found no glue/resulted in a loop/resulted in a CNAME loop. - Servers (
--show-servers):The following servers were encountered:, rows%*s: %-15s %s, sorted by lowercased reversed name. - Defaults change to match the reference CLI: show-progress true,
show-resolves false, show-servers false, show-versions true,
show-all-stats false, show-results true, show-summary-results true.
--show-X=falsemust work (fix the truthiness-override bug). - Colour: honour NO_COLOR and only colour when stdout is a TTY.
--json: keep, but emit each aggregated leaf exactly once:{domain, qtype, root, results: [...], summary: [...], servers: [...]}. No duplication between results and summary.
Documented deviations from the Ruby reference
We intentionally do NOT replicate these Ruby source bugs: the "Formate error"
typo, the EDNS0 banner always printing "on", the stray ) in Stopped at <server> (<ip>)), and the "CNANE loop" typo. Everything else matches.
Web (this pass: compile + function only)
Adapt web/api to the new engine API with minimal change: jobs still run
traversals and stream progress events (extend events with refid/status).
Full web output parity (Summary/Results sections, detail tree) is a later pass.
Remove SRV/CAA from the SPA type dropdown (backend rejects them) — align the
select to the supported list.
Testing
- Rewrite/port unit tests so mocks inject into the single query path.
- Invariant test: aggregated leaf probabilities sum to 1.0 (mock topologies: plain 2-NS answer, glueless NS, lame referral, CNAME restart, depth-limit).
- Golden harness
tools/golden/: runrun-reference.shandbin/explorednson the same domain, normalize (strip ANSI, sort aggregated result blocks, canonicalise whitespace) and diff percentages + statuses + RRsets. Network goldens are dev tools, not CI gates.
Out of scope (later passes)
fpdns-style fingerprint database, web UI output parity, geolocation/servers
map, IPv6 traversal (--follow-aaaa remains a documented no-op), SPF type,
distinct exit codes.