Files
ExploreDNS/internal/dns/query.go
T
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

322 lines
9.3 KiB
Go

// Package dns provides the low-level DNS query primitives used by ExploreDNS.
//
// 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 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: 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 realExchange(ctx context.Context, server string, msg *dns.Msg, useTCP bool) (*dns.Msg, error) {
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)
}
r, _, err := c.ExchangeContext(ctx, msg, addr)
if err != nil {
return nil, fmt.Errorf("dns exchange (%s) with %s: %w", proto, addr, err)
}
return r, nil
}
// 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
}
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
}
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 < attempts; attempt++ {
if attempt > 0 {
select {
case <-ctx.Done():
return nil, fmt.Errorf("query retries cancelled: %w", ctx.Err())
case <-time.After(retryGap(cfg.RetryDelay, attempt)):
}
}
resp, err := exchangeOnce(ctx, exchange, server, msg, cfg)
if err != nil {
lastErr = err
continue
}
return resp, nil
}
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)
}
// 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
}
return d << uint(retry-1)
}
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 resp == nil {
return nil, errors.New("nil response")
}
if !cfg.UseTCP && resp.Truncated && cfg.AllowTCP {
resp, err = exchange(actx, server, msg, true)
if err != nil {
return nil, err
}
if resp == nil {
return nil, errors.New("nil response")
}
}
return resp, nil
}
// 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 = 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()
}