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:
Gary Hansen
2026-07-07 21:42:06 +10:00
co-authored by Claude Fable 5
parent af15c9c2d4
commit d71c7fbef2
53 changed files with 6685 additions and 9366 deletions
+244 -147
View File
@@ -1,73 +1,98 @@
// Package dns provides the low-level DNS query primitives used by ExploreDNS.
//
// It wraps the github.com/miekg/dns library to provide retrying, TCP fallback,
// EDNS0 buffer size negotiation, and root server discovery. The package is
// intentionally narrow: it sends iterative (non-recursive) queries and returns
// the raw responses for the traversal engine to interpret.
// It wraps the github.com/miekg/dns library behind a single query path
// (Client) that sends non-recursive (RD=0) queries with retrying, TCP
// fallback on truncation, EDNS0 negotiation and a per-run packet cache, plus
// root server discovery. Production uses the real wire exchange; tests inject
// a mock ExchangeFunc into the exact same path.
package dns
import (
"context"
"errors"
"fmt"
"net"
"sync"
"time"
"github.com/miekg/dns"
)
// QueryConfig controls transport parameters for the single query path.
type QueryConfig struct {
UDPSize int
Timeout time.Duration
Retries int
UseTCP bool
// UDPSize is the EDNS0 UDP payload size. An OPT record is only attached
// when UDPSize > 512, mirroring dnstraverse's caching_resolver.rb.
UDPSize int
// Timeout is the per-attempt packet timeout (dnsruby packet_timeout,
// dnstraverse default 2s).
Timeout time.Duration
// Retries is the total number of send attempts, matching dnsruby
// retry_times: Resolver#generate_timeouts schedules retry_times
// transmissions in total — the first immediately and retry k at
// retry_delay*2^k seconds after the first. Values below 1 are clamped to
// 1 so exactly one query is still sent (dnsruby with retry_times 0 would
// send nothing and hang; this also fixes the old
// "failed after 0 retries: %!w(<nil>)" error).
Retries int
// RetryDelay is dnsruby's retry_delay (dnstraverse default 2s).
RetryDelay time.Duration
// UseTCP forces every query over TCP (--always-tcp).
UseTCP bool
// AllowTCP enables the UDP→TCP retry when a response is truncated.
AllowTCP bool
}
func DefaultQueryConfig() *QueryConfig {
return &QueryConfig{
UDPSize: DefaultEDNS0UDPSize(),
Timeout: 5 * time.Second,
Retries: 3,
UseTCP: false,
AllowTCP: true,
UDPSize: DefaultEDNS0UDPSize(),
Timeout: 2 * time.Second,
Retries: 2,
RetryDelay: 2 * time.Second,
UseTCP: false,
AllowTCP: true,
}
}
// withDefaults returns a copy of cfg with zero values replaced by defaults.
func (cfg *QueryConfig) withDefaults() *QueryConfig {
if cfg == nil {
return DefaultQueryConfig()
}
out := *cfg
if out.UDPSize <= 0 {
out.UDPSize = DefaultEDNS0UDPSize()
}
if out.Timeout <= 0 {
out.Timeout = 2 * time.Second
}
if out.Retries < 1 {
out.Retries = 1
}
if out.RetryDelay <= 0 {
out.RetryDelay = 2 * time.Second
}
return &out
}
// ExchangeFunc performs one wire exchange. server is either a bare host/IP
// (port 53 implied) or an explicit host:port.
type ExchangeFunc func(ctx context.Context, server string, msg *dns.Msg, useTCP bool) (*dns.Msg, error)
func Query(ctx context.Context, server net.IP, name string, qtype uint16, cfg *QueryConfig) (*dns.Msg, error) {
if cfg == nil {
cfg = DefaultQueryConfig()
}
if cfg.UDPSize <= 0 {
cfg.UDPSize = DefaultEDNS0UDPSize()
}
if cfg.Timeout <= 0 {
cfg.Timeout = 5 * time.Second
}
return QueryWithExchange(ctx, server, name, qtype, cfg, realExchange)
}
func realExchange(ctx context.Context, server string, msg *dns.Msg, useTCP bool) (*dns.Msg, error) {
addr := net.JoinHostPort(server, "53")
var c *dns.Client
if useTCP {
c = &dns.Client{
Net: "tcp",
ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
}
} else {
c = &dns.Client{
Net: "udp",
ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
}
addr := server
if _, _, err := net.SplitHostPort(server); err != nil {
addr = net.JoinHostPort(server, "53")
}
proto := "udp"
if useTCP {
proto = "tcp"
}
c := &dns.Client{
Net: proto,
ReadTimeout: 2 * time.Second,
WriteTimeout: 2 * time.Second,
}
if deadline, ok := ctx.Deadline(); ok {
c.ReadTimeout = time.Until(deadline)
c.WriteTimeout = time.Until(deadline)
@@ -75,150 +100,222 @@ func realExchange(ctx context.Context, server string, msg *dns.Msg, useTCP bool)
r, _, err := c.ExchangeContext(ctx, msg, addr)
if err != nil {
return nil, fmt.Errorf("dns exchange (%s) with %s: %w", c.Net, addr, err)
return nil, fmt.Errorf("dns exchange (%s) with %s: %w", proto, addr, err)
}
return r, nil
}
func QueryWithExchange(ctx context.Context, server net.IP, name string, qtype uint16, cfg *QueryConfig, exchangeFn ExchangeFunc) (*dns.Msg, error) {
if cfg == nil {
cfg = DefaultQueryConfig()
// Client is the single query path used identically by production and tests.
// Every query is non-recursive (RD=0) and deduplicated by a per-run packet
// cache keyed (server IP, qname, qclass, qtype, udpsize), mirroring
// dnstraverse's caching_resolver.rb: repeat askers replay the cached answer
// (or cached failure) without touching the wire.
type Client struct {
cfg *QueryConfig
exchange ExchangeFunc
mu sync.Mutex
cache map[packetKey]*packetEntry
requests int
cacheHits int
}
type packetKey struct {
server string
qname string
qclass uint16
qtype uint16
udpsize int
}
type packetEntry struct {
once sync.Once
msg *dns.Msg
err error
}
// NewClient creates a Client. A nil exchange means the real wire exchange;
// tests pass a mock so no packets leave the process.
func NewClient(cfg *QueryConfig, exchange ExchangeFunc) *Client {
if exchange == nil {
exchange = realExchange
}
if cfg.UDPSize <= 0 {
cfg.UDPSize = DefaultEDNS0UDPSize()
return &Client{
cfg: cfg.withDefaults(),
exchange: exchange,
cache: make(map[packetKey]*packetEntry),
}
}
// Requests reports how many queries were asked of the client (cache hits included).
func (c *Client) Requests() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.requests
}
// CacheHits reports how many queries were served from the packet cache.
func (c *Client) CacheHits() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.cacheHits
}
// Query sends a non-recursive query for name/qtype (class IN) to server and
// returns the response plus any warnings gathered along the way (EDNS0
// fallback, recursion offered, truncation). A non-nil error corresponds to
// dnstraverse's "exception" status (network failure after all retries).
func (c *Client) Query(ctx context.Context, server net.IP, name string, qtype uint16) (*dns.Msg, []string, error) {
msg, err := c.cachedExchange(ctx, server, name, qtype, c.cfg.UDPSize)
if err != nil {
return nil, nil, err
}
msg := buildQuery(name, qtype, cfg.UDPSize)
serverStr := server.String()
var warnings []string
// EDNS0 fallback (decoded_query.rb makequery_message): FORMERR/NOTIMP/
// SERVFAIL with udpsize > 512 may mean the server chokes on OPT; retry
// once at 512 and keep the retry only if it clears the error.
if c.cfg.UDPSize > MinEDNS0UDPSize() && ednsFailure(msg.Rcode) {
retryMsg, retryErr := c.cachedExchange(ctx, server, name, qtype, MinEDNS0UDPSize())
if retryErr == nil && !ednsFailure(retryMsg.Rcode) {
warnings = append(warnings, fmt.Sprintf("%s doesn't seem to support EDNS0", server))
msg = retryMsg
}
}
// msg_comment with want_recursion=false (message_utility.rb).
if msg.RecursionAvailable {
warnings = append(warnings, fmt.Sprintf("%s allows recursion", server))
}
if msg.Truncated {
warnings = append(warnings, fmt.Sprintf("%s sent truncated packet", server))
}
return msg, warnings, nil
}
func ednsFailure(rcode int) bool {
return rcode == dns.RcodeFormatError ||
rcode == dns.RcodeNotImplemented ||
rcode == dns.RcodeServerFailure
}
// cachedExchange sends at most one wire query per packet cache key; the
// outcome (response or error) is cached and replayed for repeat askers.
func (c *Client) cachedExchange(ctx context.Context, server net.IP, name string, qtype uint16, udpsize int) (*dns.Msg, error) {
key := packetKey{
server: server.String(),
qname: dns.CanonicalName(name),
qclass: dns.ClassINET,
qtype: qtype,
udpsize: udpsize,
}
c.mu.Lock()
c.requests++
entry, ok := c.cache[key]
if ok {
c.cacheHits++
} else {
entry = &packetEntry{}
c.cache[key] = entry
}
c.mu.Unlock()
entry.once.Do(func() {
entry.msg, entry.err = exchangeWithRetry(ctx, c.exchange, key.server, buildQuery(name, qtype, udpsize), c.cfg)
})
return copyMsg(entry.msg), entry.err
}
// exchangeWithRetry implements dnsruby's retry schedule (resolver.rb
// generate_timeouts): cfg.Retries is the TOTAL number of transmissions — the
// first goes immediately and retry k is sent retry_delay*2^k seconds after
// the first (so gaps of 2d, 2d, 4d, 8d, ...). Each attempt gets its own
// cfg.Timeout (dnsruby packet_timeout). A truncated UDP reply is retried over
// TCP within the same attempt when cfg.AllowTCP.
func exchangeWithRetry(ctx context.Context, exchange ExchangeFunc, server string, msg *dns.Msg, cfg *QueryConfig) (*dns.Msg, error) {
attempts := cfg.Retries
if attempts < 1 {
attempts = 1
}
var lastErr error
for attempt := 0; attempt < cfg.Retries; attempt++ {
for attempt := 0; attempt < attempts; attempt++ {
if attempt > 0 {
select {
case <-ctx.Done():
return nil, fmt.Errorf("query retries cancelled: %w", ctx.Err())
case <-time.After(backoffDelay(attempt)):
case <-time.After(retryGap(cfg.RetryDelay, attempt)):
}
}
if cfg.UseTCP {
resp, err := exchangeFn(ctx, serverStr, msg, true)
if err != nil {
lastErr = err
continue
}
return resp, nil
}
resp, err := exchangeFn(ctx, serverStr, msg, false)
resp, err := exchangeOnce(ctx, exchange, server, msg, cfg)
if err != nil {
lastErr = err
continue
}
if resp.Truncated && cfg.AllowTCP {
resp, err = exchangeFn(ctx, serverStr, msg, true)
if err != nil {
lastErr = err
continue
}
return resp, nil
}
return resp, nil
}
return nil, fmt.Errorf("query %s %s failed after %d retries: %w", name, QNameType(qtype), cfg.Retries, lastErr)
q := msg.Question[0]
return nil, fmt.Errorf("query %s %s to %s failed after %d attempts: %w",
q.Name, QNameType(q.Qtype), server, attempts, lastErr)
}
func IterativeQuery(ctx context.Context, server net.IP, name string, qtype uint16, cfg *QueryConfig) (*dns.Msg, error) {
if cfg == nil {
cfg = DefaultQueryConfig()
// retryGap returns the wait before retry number `retry` (1-based). dnsruby
// sends retry k at absolute time retry_delay*2^k, so the gap is 2d before the
// first retry and d*2^(k-1) for each retry after that.
func retryGap(d time.Duration, retry int) time.Duration {
if retry <= 1 {
return 2 * d
}
if cfg.UDPSize <= 0 {
cfg.UDPSize = DefaultEDNS0UDPSize()
}
return IterativeQueryWithExchange(ctx, server, name, qtype, cfg, realExchange)
return d << uint(retry-1)
}
func IterativeQueryWithExchange(ctx context.Context, server net.IP, name string, qtype uint16, cfg *QueryConfig, exchangeFn ExchangeFunc) (*dns.Msg, error) {
if cfg == nil {
cfg = DefaultQueryConfig()
func exchangeOnce(ctx context.Context, exchange ExchangeFunc, server string, msg *dns.Msg, cfg *QueryConfig) (*dns.Msg, error) {
actx, cancel := context.WithTimeout(ctx, cfg.Timeout)
defer cancel()
resp, err := exchange(actx, server, msg, cfg.UseTCP)
if err != nil {
return nil, err
}
if cfg.UDPSize <= 0 {
cfg.UDPSize = DefaultEDNS0UDPSize()
if resp == nil {
return nil, errors.New("nil response")
}
msg := buildQuery(name, qtype, cfg.UDPSize)
msg.RecursionDesired = false
serverStr := server.String()
var lastErr error
for attempt := 0; attempt < cfg.Retries; attempt++ {
if attempt > 0 {
select {
case <-ctx.Done():
return nil, fmt.Errorf("query retries cancelled: %w", ctx.Err())
case <-time.After(backoffDelay(attempt)):
}
}
if cfg.UseTCP {
resp, err := exchangeFn(ctx, serverStr, msg, true)
if err != nil {
lastErr = err
continue
}
return resp, nil
}
resp, err := exchangeFn(ctx, serverStr, msg, false)
if !cfg.UseTCP && resp.Truncated && cfg.AllowTCP {
resp, err = exchange(actx, server, msg, true)
if err != nil {
lastErr = err
continue
return nil, err
}
if resp == nil {
lastErr = fmt.Errorf("nil response")
continue
return nil, errors.New("nil response")
}
if resp.Truncated && cfg.AllowTCP {
resp, err = exchangeFn(ctx, serverStr, msg, true)
if err != nil {
lastErr = err
continue
}
return resp, nil
}
return resp, nil
}
return nil, fmt.Errorf("iterative query %s %s failed after %d retries: %w", name, QNameType(qtype), cfg.Retries, lastErr)
return resp, nil
}
// backoffDelay computes the wait duration before the given retry attempt (1-indexed).
// Delays: attempt=1 → 100ms, attempt=2 → 200ms, attempt=3 → 400ms, capped at 2s.
func backoffDelay(attempt int) time.Duration {
if attempt <= 0 {
return 0
}
delay := time.Duration(uint(1)<<uint(attempt-1)) * 100 * time.Millisecond
const maxDelay = 2 * time.Second
if delay > maxDelay {
return maxDelay
}
return delay
}
func buildQuery(name string, qtype uint16, udpSize int) *dns.Msg {
// buildQuery constructs a non-recursive (RD=0) class IN query. The EDNS0 OPT
// record is attached only when udpsize > 512 (caching_resolver.rb adds OPT
// under the same condition), with the DO bit off.
func buildQuery(name string, qtype uint16, udpsize int) *dns.Msg {
m := new(dns.Msg)
m.SetQuestion(dns.Fqdn(name), qtype)
m.RecursionDesired = true
m.SetEdns0(uint16(udpSize), false)
m.RecursionDesired = false
if udpsize > MinEDNS0UDPSize() {
m.SetEdns0(uint16(udpsize), false)
}
return m
}
func copyMsg(m *dns.Msg) *dns.Msg {
if m == nil {
return nil
}
return m.Copy()
}