Files
Gary HansenandClaude Fable 5 d71c7fbef2 feat: rework engine and CLI for dnstraverse parity
Port the traversal engine to the Ruby dnstraverse model so behaviour and
output match dns.squish.net:

- dns: single RD=0 query path (RD=1 only for upstream root discovery),
  per-run packet cache, EDNS0 512-fallback with warnings, UDP->TCP on
  truncation; fix --retries 0 and --root-server IP-literal handling;
  drop all hardcoded 127.0.0.1:53 resolvers
- traverse: hierarchical per-branch InfoCache, 7-step response
  classification with the full 10-status vocabulary, bailiwick
  partitioning, strictly-deeper lame-referral rule, refid grammar with
  .0 resolve subtrees and childset digits, per-IP branching at 1/n
  weight, cache-based glue resolution with noglue/loop dead ends, CNAME
  restarts from the deepest cached zone, fast-mode memoization,
  probability aggregation with Ruby-identical stats keys (sums to 1.0)
- output: byte-for-byte reference text format pinned by a golden test,
  reference CLI defaults, working --quiet/--show-X=false, TTY-aware
  colour, deduplicated deterministic JSON
- web: adapt API/SPA to the new engine, SSE events carry refid/status,
  fix subscribe/snapshot duplicate-event race and a statusCls TDZ bug,
  align SPA type list with the backend
- delete the old engine and dead code (net -4,350 lines)

Verified against live runs of the reference Ruby engine across five
domains (answers, NXDOMAIN, null MX, CNAME restart, glueless resolve)
with no divergences beyond the documented typo fixes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 21:42:06 +10:00

621 lines
18 KiB
Go

package traverse
import (
"context"
"fmt"
"net"
"sort"
"strings"
"gitea.hansenits.com.au/hits/ExploreDNS/internal/dns"
miekgdns "github.com/miekg/dns"
"golang.org/x/net/idna"
)
// DefaultMaxDepth is the default maximum referral depth (non-zero refid
// components) before a "Maxdepth N exceeded" exception is injected.
const DefaultMaxDepth = 20
// ReferralStatus is the resolve-phase status of a Referral node itself
// (referral.rb @status), distinct from the per-IP response statuses.
type ReferralStatus string
const (
RefStatusNormal ReferralStatus = "normal"
RefStatusNoGlue ReferralStatus = "noglue"
RefStatusLoop ReferralStatus = "loop"
)
// StatsEntry is one aggregated leaf statistic: the probability mass that
// ended in Response's outcome at Referral (referral.rb @stats values).
type StatsEntry struct {
Key string
Prob float64
Response *ServerResponse
Referral *Referral
}
// Referral represents one referral to a specific server for qname/qclass/
// qtype (referral.rb). The synthetic top node ("rootroot") has Server == ""
// and is never displayed; its children are the root servers.
type Referral struct {
RefID string
Parent *Referral
Qname string
Qclass uint16
Qtype uint16
// NSAType is the record type used to resolve nameserver addresses
// (always A; the reference is IPv4-only for transport).
NSAType uint16
// Server is the NS hostname this referral queries ("" for rootroot).
Server string
// ServerIPs is nil when the server still needs resolving. After a
// resolve it may also contain "key:..." pseudo entries carrying the
// probability of failed resolutions.
ServerIPs []string
Bailiwick string
ParentIP string
InfoCache *InfoCache
Status ReferralStatus
// Responses holds the classified response for each real IP queried.
Responses map[string]*ServerResponse
// Children holds child referrals keyed by the parent IP that produced
// them ("rootroot" for the synthetic top node).
Children map[string][]*Referral
// Resolves is the glue-resolution subtree (refid ".0." components).
Resolves []*Referral
ServerWeights map[string]float64
Warnings []string
// Stats is the post-order aggregation of leaf outcomes below this node.
Stats map[string]*StatsEntry
// StatsResolve aggregates the outcomes of the resolve subtree.
StatsResolve map[string]*StatsEntry
// ReplacedBy points at the earlier completed referral that fast mode
// substituted for this node.
ReplacedBy *Referral
// summaryStats memoises SummaryStats() (referral.rb summary_stats).
summaryStats *SummaryStats
client *dns.Client
maxdepth int
referralResolution bool
processed bool
calculated bool
}
// idnaLookup is the IDN lookup profile used to convert internationalised
// domain names (unicode labels) to their ACE/punycode equivalents.
var idnaLookup = idna.New(
idna.MapForLookup(),
idna.BidiRule(),
idna.StrictDomainName(false),
)
// toASCII converts a domain name that may contain unicode labels to its
// punycode (ACE) representation. On conversion errors the original name is
// returned so the caller can still attempt a query.
func toASCII(name string) string {
if name == "" || name == "." {
return name
}
ascii, err := idnaLookup.ToASCII(name)
if err != nil {
return name
}
return ascii
}
// referralArgs are the per-child overrides for makeReferral; zero values
// inherit from the parent (referral.rb make_referral merge semantics).
type referralArgs struct {
qname string
qtype uint16
server string
serverIPs []string
bailiwick string
infoCache *InfoCache
refid string
parentIP string
referralResolution bool
}
func (r *Referral) makeReferral(a referralArgs) *Referral {
child := &Referral{
RefID: a.refid,
Parent: r,
Qname: r.Qname,
Qclass: r.Qclass,
Qtype: r.Qtype,
NSAType: r.NSAType,
Server: canonicalName(a.server),
ServerIPs: a.serverIPs,
Bailiwick: canonicalName(a.bailiwick),
ParentIP: a.parentIP,
InfoCache: r.InfoCache,
Status: RefStatusNormal,
Responses: make(map[string]*ServerResponse),
Children: make(map[string][]*Referral),
ServerWeights: make(map[string]float64),
client: r.client,
maxdepth: r.maxdepth,
referralResolution: a.referralResolution || r.referralResolution,
}
if a.qname != "" {
child.Qname = canonicalName(a.qname)
}
if a.qtype != 0 {
child.Qtype = a.qtype
}
if a.infoCache != nil {
child.InfoCache = a.infoCache
}
// serverweight = 1/len(ips) per IP when the addresses are known.
if child.ServerIPs != nil {
for _, ip := range child.ServerIPs {
child.ServerWeights[ip] = 1.0 / float64(len(child.ServerIPs))
}
}
return child
}
// IsRootRoot reports whether this is the synthetic top node representing an
// automatic referral to the root servers.
func (r *Referral) IsRootRoot() bool {
return r.Server == ""
}
// IsResolve reports whether this node is part of a glue-resolution subtree.
func (r *Referral) IsResolve() bool {
return r.referralResolution
}
// Resolved reports whether the server addresses are known (rootroot is
// always resolved).
func (r *Referral) Resolved() bool {
return r.IsRootRoot() || r.ServerIPs != nil
}
// Depth counts the non-zero refid components; resolve subtrees (".0.") do
// not count against the depth limit.
func (r *Referral) Depth() int {
return refidDepth(r.RefID)
}
func refidDepth(refid string) int {
if refid == "" {
return 0
}
n := 0
for _, part := range strings.Split(refid, ".") {
if part != "0" {
n++
}
}
return n
}
// IPsAsArray returns the real IP addresses known for this referral,
// excluding "key:" pseudo entries.
func (r *Referral) IPsAsArray() []string {
var out []string
for _, ip := range r.ServerIPs {
if !strings.HasPrefix(ip, "key:") {
out = append(out, ip)
}
}
return out
}
// TxtIPsVerbose renders the per-IP weights, sorted, e.g.
// "50.0%=1.2.3.4,50.0%=noglue:1.2.3.4" (referral.rb txt_ips_verbose). It is
// part of the fast-mode memo key.
func (r *Referral) TxtIPsVerbose() string {
if r.ServerIPs == nil {
return ""
}
parts := make([]string, 0, len(r.ServerIPs))
for _, ip := range r.ServerIPs {
label := ip
if rest, ok := strings.CutPrefix(ip, "key:"); ok {
// keep the first two colon-separated fields, like Ruby's
// /^key:([^:]+(:[^:]*)?)/ capture.
fields := strings.SplitN(rest, ":", 3)
if len(fields) > 2 {
fields = fields[:2]
}
label = strings.Join(fields, ":")
}
parts = append(parts, fmt.Sprintf("%.1f%%=%s", 100*r.ServerWeights[ip], label))
}
sort.Strings(parts)
return strings.Join(parts, ",")
}
// TxtIPs renders the addresses for progress display; failed-resolve pseudo
// entries render as their response description (referral.rb txt_ips).
func (r *Referral) TxtIPs() string {
if r.ServerIPs == nil {
return ""
}
parts := make([]string, 0, len(r.ServerIPs))
for _, ip := range r.ServerIPs {
if strings.HasPrefix(ip, "key:") {
if e, ok := r.StatsResolve[ip]; ok && e.Response != nil {
parts = append(parts, e.Response.String())
continue
}
}
parts = append(parts, ip)
}
sort.Strings(parts)
return strings.Join(parts, ",")
}
func (r *Referral) String() string {
return fmt.Sprintf("%s [%s/%s/%s] server=%s server_ips=%s bailiwick=%s",
r.RefID, r.Qname, ClassToString(r.Qclass), TypeToString(r.Qtype),
r.Server, r.TxtIPs(), r.Bailiwick)
}
// OverallStatus summarises the node's outcome for event consumers: a resolve
// dead end (noglue/loop), the shared status of every per-IP response, or
// "mixed" when the responses disagree ("" before anything was queried).
func (r *Referral) OverallStatus() Status {
switch r.Status {
case RefStatusNoGlue:
return StatusNoGlue
case RefStatusLoop:
return StatusLoop
}
var s Status
for _, resp := range r.Responses {
if s == "" {
s = resp.Status
} else if s != resp.Status {
return "mixed"
}
}
return s
}
// StatsList returns the aggregated leaf statistics sorted by stats key.
func (r *Referral) StatsList() []*StatsEntry {
out := make([]*StatsEntry, 0, len(r.Stats))
for _, e := range r.Stats {
out = append(out, e)
}
sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key })
return out
}
// isNoGlue reports a dead end: the server is inside the current bailiwick,
// so its address should have come as glue, but none was provided and no
// deeper zone can name it (referral.rb noglue?).
func (r *Referral) isNoGlue() bool {
return r.ServerIPs == nil && insideBailiwick(r.Server, r.Bailiwick)
}
// isLoop reports a resolve loop: an ancestor referral asks the same
// qname/qclass/qtype of the same still-unresolved server (referral.rb loop?),
// e.g. b NS c.d while d NS a.b.
func (r *Referral) isLoop() bool {
if r.ServerIPs != nil {
return false
}
for p := r.Parent; p != nil; p = p.Parent {
if p.Qname == r.Qname && p.Qclass == r.Qclass && p.Qtype == r.Qtype &&
p.Server == r.Server && p.ServerIPs == nil {
return true
}
}
return false
}
// chainHasQuery reports whether this referral or any ancestor already asks
// qname with the same qclass/qtype. Used to stop cross-response CNAME chains
// (restart loops) that Ruby only catches via the depth limit.
func (r *Referral) chainHasQuery(qname string) bool {
name := canonicalName(qname)
for p := r; p != nil; p = p.Parent {
if p.Qname == name && p.Qclass == r.Qclass && p.Qtype == r.Qtype {
return true
}
}
return false
}
// resolve turns an address-less referral into either a dead end (noglue/
// loop) or a resolve subtree querying A <server> from this branch's cache
// (referral.rb resolve). It returns the referrals to process.
func (r *Referral) resolve() ([]*Referral, error) {
if r.isNoGlue() {
r.Status = RefStatusNoGlue
return nil, nil
}
if r.isLoop() {
r.Status = RefStatusLoop
return nil, nil
}
starters, newbailiwick, err := r.InfoCache.GetStartServers(r.Server)
if err != nil {
return nil, err
}
for i, st := range starters {
child := r.makeReferral(referralArgs{
qname: r.Server,
qtype: r.NSAType,
server: st.Name,
serverIPs: st.IPs,
bailiwick: newbailiwick,
refid: fmt.Sprintf("%s.0.%d", r.RefID, i+1),
referralResolution: true,
})
r.Resolves = append(r.Resolves, child)
}
return r.Resolves, nil
}
// resolveCalculate folds the resolve subtree's statistics into per-IP server
// weights (referral.rb resolve_calculate): each answered leaf distributes
// its probability evenly across the A records it returned; every other leaf
// keeps its probability under its "key:" stats key so failures surface in
// the results.
func (r *Referral) resolveCalculate() {
r.StatsResolve = make(map[string]*StatsEntry)
switch r.Status {
case RefStatusNoGlue:
resp := NewNoGlueResponse(r.Qname, r.Qclass, r.Qtype, r.ParentIP, r.Server, r.Bailiwick)
key := resp.StatsKey()
r.StatsResolve[key] = &StatsEntry{Key: key, Prob: 1.0, Response: resp, Referral: r}
case RefStatusLoop:
resp := NewLoopResponse(r.Qname, r.Qclass, r.Qtype, r.ParentIP, r.Server, r.Bailiwick)
key := resp.StatsKey()
r.StatsResolve[key] = &StatsEntry{Key: key, Prob: 1.0, Response: resp, Referral: r}
default:
statsCalculateChildren(r.StatsResolve, r.Resolves, 1.0)
}
r.ServerWeights = make(map[string]float64)
r.ServerIPs = []string{}
keys := make([]string, 0, len(r.StatsResolve))
for key := range r.StatsResolve {
keys = append(keys, key)
}
sort.Strings(keys)
addWeight := func(ip string, prob float64) {
if _, ok := r.ServerWeights[ip]; !ok {
r.ServerIPs = append(r.ServerIPs, ip)
}
r.ServerWeights[ip] += prob
}
for _, key := range keys {
data := r.StatsResolve[key]
if data.Response.Status == StatusAnswered {
var addrs []string
for _, rr := range data.Response.DQ.Answers {
if a, ok := rr.(*miekgdns.A); ok {
addrs = append(addrs, a.A.String())
}
}
for _, addr := range addrs {
addWeight(addr, data.Prob/float64(len(addrs)))
}
if len(addrs) == 0 {
// answered but no A records (e.g. AAAA-only): carry the
// probability as a failure key so mass is not lost.
addWeight(key, data.Prob)
}
} else {
addWeight(key, data.Prob)
}
}
}
// statsCalculateChildren merges the children's statistics into stats with an
// equal split of weight among them (referral.rb stats_calculate_children).
func statsCalculateChildren(stats map[string]*StatsEntry, children []*Referral, weight float64) {
if len(children) == 0 {
return
}
percent := (1.0 / float64(len(children))) * weight
for _, child := range children {
for key, data := range child.Stats {
if e, ok := stats[key]; ok {
e.Prob += data.Prob * percent
} else {
stats[key] = &StatsEntry{
Key: key,
Prob: data.Prob * percent,
Response: data.Response,
Referral: data.Referral,
}
}
}
}
}
// answerCalculate computes this node's aggregated statistics from its
// children, responses and resolve failures (referral.rb answer_calculate).
// Unlike the Ruby source, duplicate stats keys across a referral's IPs merge
// by summing probability (Ruby computes the sum then discards it — a source
// bug that breaks the probabilities-sum-to-1 invariant).
func (r *Referral) answerCalculate() {
r.Stats = make(map[string]*StatsEntry)
if r.IsRootRoot() {
statsCalculateChildren(r.Stats, r.Children["rootroot"], 1.0)
r.calculated = true
return
}
for _, ip := range r.ServerIPs {
serverweight := r.ServerWeights[ip]
if strings.HasPrefix(ip, "key:") {
// resolve failed for some reason - copy the resolve statistics
src := r.StatsResolve[ip]
if e, ok := r.Stats[ip]; ok {
e.Prob += src.Prob
} else {
r.Stats[ip] = &StatsEntry{Key: ip, Prob: src.Prob, Response: src.Response, Referral: src.Referral}
}
continue
}
if children := r.Children[ip]; len(children) > 0 {
statsCalculateChildren(r.Stats, children, serverweight)
continue
}
resp := r.Responses[ip]
if resp == nil {
continue
}
key := resp.StatsKey()
if e, ok := r.Stats[key]; ok {
e.Prob += serverweight
} else {
r.Stats[key] = &StatsEntry{Key: key, Prob: serverweight, Response: resp, Referral: r}
}
}
r.calculated = true
}
// process queries every real IP of this referral through the packet cache,
// classifies each response, and creates one child per NS name (including
// glueless ones) for referral/restart statuses (referral.rb process/
// process_normal). It returns one set of children per IP that produced any.
func (r *Referral) process(ctx context.Context) ([][]*Referral, error) {
r.processed = true
if r.IsRootRoot() {
children, err := r.processAddRoots()
if err != nil {
return nil, err
}
return [][]*Referral{children}, nil
}
// Phase one: query and classify, counting childsets so refids can grow
// an extra childset digit when more than one IP produces children.
childsets := 0
var order []string
for _, ip := range r.ServerIPs {
if strings.HasPrefix(ip, "key:") {
continue
}
var dq *DecodedQuery
if r.Depth() >= r.maxdepth {
err := fmt.Errorf("Maxdepth %d exceeded", r.maxdepth)
dq = NewDecodedQuery(nil, err, r.Qname, r.Qclass, r.Qtype, ip, r.Bailiwick)
} else {
msg, warnings, err := r.client.Query(ctx, net.ParseIP(ip), r.Qname, r.Qtype)
dq = NewDecodedQuery(msg, err, r.Qname, r.Qclass, r.Qtype, ip, r.Bailiwick)
dq.WarningsAdd(warnings...)
}
resp, err := NewServerResponse(dq, r.Server, r.ParentIP, r.InfoCache)
if err != nil {
return nil, err
}
if resp.Status == StatusRestart {
// Cross-response CNAME loop: any target in the chain that we
// (or an ancestor) are already querying is a dead end.
for _, target := range dq.ChainTargets {
if r.chainHasQuery(target) {
resp.Status = StatusCNAMELoop
break
}
}
}
r.Warnings = append(r.Warnings, dq.Warnings...)
r.Responses[ip] = resp
order = append(order, ip)
if resp.Status == StatusRestart || resp.Status == StatusReferral {
childsets++
}
}
// Phase two: create the children.
childset := 0
var sets [][]*Referral
for _, ip := range order {
resp := r.Responses[ip]
if resp.Status != StatusRestart && resp.Status != StatusReferral {
continue
}
childset++
refid := r.RefID
if childsets > 1 {
refid = fmt.Sprintf("%s.%d", r.RefID, childset)
}
children := r.makeReferrals(resp, refid, ip)
r.Children[ip] = children
sets = append(sets, children)
}
return sets, nil
}
// processAddRoots creates one child per root server with equal weight
// (referral.rb process_add_roots); the roots come from the info cache hints.
func (r *Referral) processAddRoots() ([]*Referral, error) {
starters, _, err := r.InfoCache.GetStartServers("")
if err != nil {
return nil, err
}
dot := ""
if r.RefID != "" {
dot = "."
}
var children []*Referral
for i, root := range starters {
child := r.makeReferral(referralArgs{
server: root.Name,
serverIPs: root.IPs,
refid: fmt.Sprintf("%s%s%d", r.RefID, dot, i+1),
})
children = append(children, child)
}
r.Children["rootroot"] = children
return children, nil
}
// makeReferrals creates one child per start server for a referral/restart
// response (referral.rb make_referrals): qname moves to the response's
// endname (the CNAME target on restart), the bailiwick and cache come from
// the response.
func (r *Referral) makeReferrals(resp *ServerResponse, refid, parentIP string) []*Referral {
var children []*Referral
for i, st := range resp.Starters {
children = append(children, r.makeReferral(referralArgs{
qname: resp.DQ.Endname,
server: st.Name,
serverIPs: st.IPs,
bailiwick: resp.StartersBailiwick,
infoCache: resp.Cache,
refid: fmt.Sprintf("%s.%d", refid, i+1),
parentIP: parentIP,
}))
}
return children
}
// replaceChild swaps before for after in the children/resolves lists (fast
// mode substitution); before keeps a pointer to its replacement.
func (r *Referral) replaceChild(before, after *Referral) {
before.ReplacedBy = after
for ip := range r.Children {
for i, c := range r.Children[ip] {
if c == before {
r.Children[ip][i] = after
}
}
}
for i, c := range r.Resolves {
if c == before {
r.Resolves[i] = after
}
}
}