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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
af15c9c2d4
commit
d71c7fbef2
@@ -0,0 +1,259 @@
|
||||
package traverse
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
miekgdns "github.com/miekg/dns"
|
||||
)
|
||||
|
||||
// Status is the classification of one query outcome. The first eight values
|
||||
// come from decoded_query.rb / response.rb; noglue and loop are synthesised by
|
||||
// the resolve phase without sending a query.
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusAnswered Status = "answered"
|
||||
StatusNoData Status = "nodata"
|
||||
StatusReferral Status = "referral"
|
||||
StatusRestart Status = "restart"
|
||||
StatusReferralLame Status = "referral_lame"
|
||||
StatusError Status = "error"
|
||||
StatusException Status = "exception"
|
||||
StatusCNAMELoop Status = "cname_loop"
|
||||
StatusNoGlue Status = "noglue"
|
||||
StatusLoop Status = "loop"
|
||||
)
|
||||
|
||||
// DecodedQuery classifies one DNS response (or network failure) against the
|
||||
// query that produced it, mirroring decoded_query.rb. Names are canonical
|
||||
// (lowercase, no trailing dot); Bailiwick "" means root.
|
||||
type DecodedQuery struct {
|
||||
Msg *miekgdns.Msg
|
||||
Err error
|
||||
|
||||
Qname string
|
||||
Qclass uint16
|
||||
Qtype uint16
|
||||
IP string
|
||||
Bailiwick string
|
||||
|
||||
Status Status
|
||||
Endname string
|
||||
// ChainTargets lists every CNAME target the in-message chain went
|
||||
// through (including the final unfollowed target when the chain leaves
|
||||
// the bailiwick); used for cross-response restart loop detection.
|
||||
ChainTargets []string
|
||||
|
||||
CacheableGood []miekgdns.RR
|
||||
CacheableBad []miekgdns.RR
|
||||
|
||||
AuthNS []miekgdns.RR
|
||||
AuthSOA []miekgdns.RR
|
||||
AuthOther []miekgdns.RR
|
||||
|
||||
Answers []miekgdns.RR
|
||||
AuthorityNames []string
|
||||
|
||||
ErrorMessage string
|
||||
ExceptionMessage string
|
||||
Warnings []string
|
||||
}
|
||||
|
||||
// NewDecodedQuery decodes and classifies a response. Pass err non-nil for a
|
||||
// network-level failure (dnstraverse's "exception"); msg is ignored then.
|
||||
func NewDecodedQuery(msg *miekgdns.Msg, err error, qname string, qclass, qtype uint16, ip, bailiwick string) *DecodedQuery {
|
||||
dq := &DecodedQuery{
|
||||
Msg: msg,
|
||||
Err: err,
|
||||
Qname: canonicalName(qname),
|
||||
Qclass: qclass,
|
||||
Qtype: qtype,
|
||||
IP: ip,
|
||||
Bailiwick: canonicalName(bailiwick),
|
||||
}
|
||||
dq.process()
|
||||
return dq
|
||||
}
|
||||
|
||||
func (dq *DecodedQuery) WarningsAdd(warnings ...string) {
|
||||
dq.Warnings = append(dq.Warnings, warnings...)
|
||||
}
|
||||
|
||||
// process implements the classification order of decoded_query.rb#process
|
||||
// exactly (the 7 steps in the design doc).
|
||||
func (dq *DecodedQuery) process() {
|
||||
if dq.Err == nil && dq.Msg == nil {
|
||||
dq.Err = fmt.Errorf("nil DNS response")
|
||||
}
|
||||
if dq.Err != nil {
|
||||
dq.Status = StatusException
|
||||
dq.ExceptionMessage = dq.Err.Error()
|
||||
return
|
||||
}
|
||||
dq.AuthNS, dq.AuthSOA, dq.AuthOther = msgAuthority(dq.Msg)
|
||||
dq.CacheableGood, dq.CacheableBad = msgCacheable(dq.Msg, dq.Bailiwick)
|
||||
endname, targets, ok := msgFollowCNAMEs(dq.Msg, dq.Qname, dq.Qtype, dq.Bailiwick)
|
||||
if !ok {
|
||||
dq.Status = StatusCNAMELoop
|
||||
return
|
||||
}
|
||||
dq.Endname = endname
|
||||
dq.ChainTargets = targets
|
||||
if dq.Msg.Rcode != miekgdns.RcodeSuccess {
|
||||
dq.Status = StatusError
|
||||
dq.ErrorMessage = rcodeErrorMessage(dq.Msg.Rcode)
|
||||
return
|
||||
}
|
||||
if answers := msgAnswers(dq.Msg, dq.Endname, dq.Qclass, dq.Qtype); len(answers) > 0 {
|
||||
dq.Answers = answers
|
||||
dq.Status = StatusAnswered
|
||||
return
|
||||
}
|
||||
if dq.Endname != dq.Qname {
|
||||
dq.Status = StatusRestart
|
||||
return
|
||||
}
|
||||
if len(dq.AuthSOA) > 0 || len(dq.AuthNS) == 0 {
|
||||
dq.Status = StatusNoData
|
||||
return
|
||||
}
|
||||
dq.Status = StatusReferral
|
||||
for _, rr := range dq.AuthNS {
|
||||
if ns, ok := rr.(*miekgdns.NS); ok {
|
||||
dq.AuthorityNames = append(dq.AuthorityNames, canonicalName(ns.Ns))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// rcodeErrorMessage renders the exact error strings of decoded_query.rb
|
||||
// process_error ("Format error" deliberately fixes the Ruby "Formate" typo —
|
||||
// documented deviation).
|
||||
func rcodeErrorMessage(rcode int) string {
|
||||
switch rcode {
|
||||
case miekgdns.RcodeFormatError:
|
||||
return "Format error (FORMERR)"
|
||||
case miekgdns.RcodeServerFailure:
|
||||
return "Server failure (SERVFAIL)"
|
||||
case miekgdns.RcodeNameError:
|
||||
return "No such domain (NXDOMAIN)"
|
||||
case miekgdns.RcodeNotImplemented:
|
||||
return "Not implemented (NOTIMP)"
|
||||
case miekgdns.RcodeRefused:
|
||||
return "Refused"
|
||||
default:
|
||||
if s, ok := miekgdns.RcodeToString[rcode]; ok {
|
||||
return s
|
||||
}
|
||||
return fmt.Sprintf("RCODE%d", rcode)
|
||||
}
|
||||
}
|
||||
|
||||
// insideBailiwick reports whether name is at or below the bailiwick zone:
|
||||
// bailiwick "" (root), equal fold, or name ends with "."+bailiwick.
|
||||
func insideBailiwick(name, bailiwick string) bool {
|
||||
bw := canonicalName(bailiwick)
|
||||
if bw == "" {
|
||||
return true
|
||||
}
|
||||
n := canonicalName(name)
|
||||
return n == bw || strings.HasSuffix(n, "."+bw)
|
||||
}
|
||||
|
||||
// msgAnswers returns the answer-section records matching qname/qclass/qtype
|
||||
// (message_utility.rb msg_answers?). qtype ANY matches every type.
|
||||
func msgAnswers(msg *miekgdns.Msg, qname string, qclass, qtype uint16) []miekgdns.RR {
|
||||
name := canonicalName(qname)
|
||||
any := qtype == miekgdns.TypeANY
|
||||
var out []miekgdns.RR
|
||||
for _, rr := range msg.Answer {
|
||||
h := rr.Header()
|
||||
if canonicalName(h.Name) == name && h.Class == qclass && (any || h.Rrtype == qtype) {
|
||||
out = append(out, rr)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// msgAuthority partitions the authority section into IN NS, IN SOA and other
|
||||
// records (message_utility.rb msg_authority).
|
||||
func msgAuthority(msg *miekgdns.Msg) (ns, soa, other []miekgdns.RR) {
|
||||
for _, rr := range msg.Ns {
|
||||
h := rr.Header()
|
||||
switch {
|
||||
case h.Rrtype == miekgdns.TypeNS && h.Class == miekgdns.ClassINET:
|
||||
ns = append(ns, rr)
|
||||
case h.Rrtype == miekgdns.TypeSOA && h.Class == miekgdns.ClassINET:
|
||||
soa = append(soa, rr)
|
||||
default:
|
||||
other = append(other, rr)
|
||||
}
|
||||
}
|
||||
return ns, soa, other
|
||||
}
|
||||
|
||||
// msgCacheable partitions ALL sections (answer, authority, additional — in
|
||||
// that order) into in-bailiwick records worth caching and out-of-bailiwick
|
||||
// records to discard. OPT pseudo-records are dropped entirely.
|
||||
func msgCacheable(msg *miekgdns.Msg, bailiwick string) (good, bad []miekgdns.RR) {
|
||||
for _, section := range [][]miekgdns.RR{msg.Answer, msg.Ns, msg.Extra} {
|
||||
for _, rr := range section {
|
||||
if rr.Header().Rrtype == miekgdns.TypeOPT {
|
||||
continue
|
||||
}
|
||||
if insideBailiwick(rr.Header().Name, bailiwick) {
|
||||
good = append(good, rr)
|
||||
} else {
|
||||
bad = append(bad, rr)
|
||||
}
|
||||
}
|
||||
}
|
||||
return good, bad
|
||||
}
|
||||
|
||||
// msgFollowCNAMEs follows a CNAME chain within one message and returns the
|
||||
// final name plus every target passed through (message_utility.rb
|
||||
// msg_follow_cnames). Following stops — the target is returned unfollowed —
|
||||
// as soon as the CURRENT owner name is not strictly below the bailiwick
|
||||
// (Ruby tests `name !~ /\.#{bailiwick}$/i`, so an owner exactly equal to the
|
||||
// bailiwick also stops the chain). An in-message loop returns ok=false
|
||||
// (cname_loop).
|
||||
func msgFollowCNAMEs(msg *miekgdns.Msg, qname string, qtype uint16, bailiwick string) (endname string, targets []string, ok bool) {
|
||||
name := canonicalName(qname)
|
||||
bw := canonicalName(bailiwick)
|
||||
seen := make(map[string]bool)
|
||||
for {
|
||||
seen[name] = true
|
||||
if len(msgAnswers(msg, name, miekgdns.ClassINET, qtype)) > 0 {
|
||||
return name, targets, true
|
||||
}
|
||||
cnames := msgAnswers(msg, name, miekgdns.ClassINET, miekgdns.TypeCNAME)
|
||||
if len(cnames) == 0 {
|
||||
return name, targets, true
|
||||
}
|
||||
cname, isCNAME := cnames[0].(*miekgdns.CNAME)
|
||||
if !isCNAME {
|
||||
return name, targets, true
|
||||
}
|
||||
target := canonicalName(cname.Target)
|
||||
targets = append(targets, target)
|
||||
if bw != "" && !strings.HasSuffix(name, "."+bw) {
|
||||
return target, targets, true
|
||||
}
|
||||
name = target
|
||||
if seen[name] {
|
||||
return "", targets, false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// isLameReferral implements the response.rb lame rule: a referral is lame
|
||||
// unless the current bailiwick is root ("") or the new zone is STRICTLY
|
||||
// deeper than the current bailiwick (equal or sideways zones are lame).
|
||||
func isLameReferral(bailiwick, newBailiwick string) bool {
|
||||
bw := canonicalName(bailiwick)
|
||||
if bw == "" {
|
||||
return false
|
||||
}
|
||||
return !strings.HasSuffix(canonicalName(newBailiwick), "."+bw)
|
||||
}
|
||||
Reference in New Issue
Block a user