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>
306 lines
9.4 KiB
Go
306 lines
9.4 KiB
Go
// Package integration provides end-to-end tests for ExploreDNS using a mock
|
|
// DNS exchange that allows deterministic, network-independent testing of the
|
|
// full engine through its exported API.
|
|
package integration
|
|
|
|
import (
|
|
"context"
|
|
"math"
|
|
"net"
|
|
"testing"
|
|
"time"
|
|
|
|
dnsinternal "gitea.hansenits.com.au/hits/ExploreDNS/internal/dns"
|
|
"gitea.hansenits.com.au/hits/ExploreDNS/internal/traverse"
|
|
"github.com/miekg/dns"
|
|
)
|
|
|
|
// mockNet maps (server IP, qname, qtype) to a canned response, mirroring how
|
|
// distinct nameservers answer differently for the same question.
|
|
type mockNet struct {
|
|
responses map[string]*dns.Msg
|
|
}
|
|
|
|
func newMockNet() *mockNet {
|
|
return &mockNet{responses: make(map[string]*dns.Msg)}
|
|
}
|
|
|
|
func key(server, qname string, qtype uint16) string {
|
|
return server + "|" + dns.Fqdn(qname) + "|" + dns.TypeToString[qtype]
|
|
}
|
|
|
|
func (m *mockNet) on(server, qname string, qtype uint16, msg *dns.Msg) {
|
|
m.responses[key(server, qname, qtype)] = msg
|
|
}
|
|
|
|
func (m *mockNet) exchange(_ context.Context, server string, msg *dns.Msg, _ bool) (*dns.Msg, error) {
|
|
host := server
|
|
if h, _, err := net.SplitHostPort(server); err == nil {
|
|
host = h
|
|
}
|
|
q := msg.Question[0]
|
|
resp, ok := m.responses[key(host, q.Name, q.Qtype)]
|
|
if !ok {
|
|
// Unknown question: NXDOMAIN, like an authoritative miss.
|
|
out := new(dns.Msg)
|
|
out.SetReply(msg)
|
|
out.Rcode = dns.RcodeNameError
|
|
return out, nil
|
|
}
|
|
out := resp.Copy()
|
|
out.SetReply(msg)
|
|
out.Answer, out.Ns, out.Extra = resp.Answer, resp.Ns, resp.Extra
|
|
out.Rcode = resp.Rcode
|
|
return out, nil
|
|
}
|
|
|
|
func aRR(name, ip string) dns.RR {
|
|
return &dns.A{
|
|
Hdr: dns.RR_Header{Name: dns.Fqdn(name), Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 300},
|
|
A: net.ParseIP(ip).To4(),
|
|
}
|
|
}
|
|
|
|
func nsRR(zone, target string) dns.RR {
|
|
return &dns.NS{
|
|
Hdr: dns.RR_Header{Name: dns.Fqdn(zone), Rrtype: dns.TypeNS, Class: dns.ClassINET, Ttl: 300},
|
|
Ns: dns.Fqdn(target),
|
|
}
|
|
}
|
|
|
|
func cnameRR(name, target string) dns.RR {
|
|
return &dns.CNAME{
|
|
Hdr: dns.RR_Header{Name: dns.Fqdn(name), Rrtype: dns.TypeCNAME, Class: dns.ClassINET, Ttl: 300},
|
|
Target: dns.Fqdn(target),
|
|
}
|
|
}
|
|
|
|
func answerMsg(rrs ...dns.RR) *dns.Msg {
|
|
m := new(dns.Msg)
|
|
m.Answer = rrs
|
|
return m
|
|
}
|
|
|
|
func referralMsg(nsRRs []dns.RR, glue ...dns.RR) *dns.Msg {
|
|
m := new(dns.Msg)
|
|
m.Ns = nsRRs
|
|
m.Extra = glue
|
|
return m
|
|
}
|
|
|
|
func newTraverser(maxDepth int) *traverse.Traverser {
|
|
return traverse.NewTraverser(&traverse.TraverserConfig{
|
|
MaxDepth: maxDepth,
|
|
QueryType: dnsinternal.TypeA,
|
|
RootAddrs: []net.IP{net.ParseIP("198.41.0.4")},
|
|
QueryConfig: &dnsinternal.QueryConfig{
|
|
Retries: 1,
|
|
Timeout: time.Second,
|
|
RetryDelay: time.Millisecond,
|
|
},
|
|
})
|
|
}
|
|
|
|
func run(t *testing.T, tr *traverse.Traverser, m *mockNet, qname string) *traverse.Referral {
|
|
t.Helper()
|
|
tr.SetExchange(m.exchange)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
root, err := tr.Run(ctx, qname)
|
|
if err != nil {
|
|
t.Fatalf("Run(%q): %v", qname, err)
|
|
}
|
|
assertProbabilityInvariant(t, root)
|
|
return root
|
|
}
|
|
|
|
// assertProbabilityInvariant checks the engine ground rule: aggregated leaf
|
|
// probabilities at the root sum to 1.0.
|
|
func assertProbabilityInvariant(t *testing.T, root *traverse.Referral) {
|
|
t.Helper()
|
|
sum := 0.0
|
|
for _, leaf := range root.StatsList() {
|
|
sum += leaf.Prob
|
|
}
|
|
if math.Abs(sum-1.0) > 1e-9 {
|
|
t.Errorf("leaf probabilities sum to %v, want 1.0", sum)
|
|
}
|
|
}
|
|
|
|
func statuses(root *traverse.Referral) map[traverse.Status]float64 {
|
|
out := make(map[traverse.Status]float64)
|
|
for _, leaf := range root.StatsList() {
|
|
out[leaf.Response.Status] += leaf.Prob
|
|
}
|
|
return out
|
|
}
|
|
|
|
// TestIntegrationReferralChain verifies the classic delegation walk:
|
|
// root → com → example.com with per-server responses.
|
|
func TestIntegrationReferralChain(t *testing.T) {
|
|
m := newMockNet()
|
|
m.on("198.41.0.4", "www.example.com", dns.TypeA, referralMsg(
|
|
[]dns.RR{nsRR("com", "a.gtld-servers.test")},
|
|
aRR("a.gtld-servers.test", "192.5.6.30"),
|
|
))
|
|
m.on("192.5.6.30", "www.example.com", dns.TypeA, referralMsg(
|
|
[]dns.RR{nsRR("example.com", "ns1.example.com")},
|
|
aRR("ns1.example.com", "1.2.3.4"),
|
|
))
|
|
m.on("1.2.3.4", "www.example.com", dns.TypeA, answerMsg(aRR("www.example.com", "93.184.216.34")))
|
|
|
|
root := run(t, newTraverser(10), m, "www.example.com")
|
|
got := statuses(root)
|
|
if math.Abs(got[traverse.StatusAnswered]-1.0) > 1e-9 {
|
|
t.Errorf("statuses = %v, want 100%% answered", got)
|
|
}
|
|
}
|
|
|
|
// TestIntegrationCNAMERestart verifies that an out-of-zone CNAME target
|
|
// restarts the traversal from the branch cache.
|
|
func TestIntegrationCNAMERestart(t *testing.T) {
|
|
m := newMockNet()
|
|
m.on("198.41.0.4", "www.example.com", dns.TypeA, referralMsg(
|
|
[]dns.RR{nsRR("example.com", "ns1.example.com")},
|
|
aRR("ns1.example.com", "1.2.3.4"),
|
|
))
|
|
m.on("1.2.3.4", "www.example.com", dns.TypeA, answerMsg(cnameRR("www.example.com", "cdn.example.net")))
|
|
m.on("198.41.0.4", "cdn.example.net", dns.TypeA, referralMsg(
|
|
[]dns.RR{nsRR("example.net", "ns1.example.net")},
|
|
aRR("ns1.example.net", "5.6.7.8"),
|
|
))
|
|
m.on("5.6.7.8", "cdn.example.net", dns.TypeA, answerMsg(aRR("cdn.example.net", "93.184.216.35")))
|
|
|
|
root := run(t, newTraverser(10), m, "www.example.com")
|
|
got := statuses(root)
|
|
if math.Abs(got[traverse.StatusAnswered]-1.0) > 1e-9 {
|
|
t.Errorf("statuses = %v, want 100%% answered via restart", got)
|
|
}
|
|
for _, leaf := range root.StatsList() {
|
|
if leaf.Response.Status == traverse.StatusAnswered && leaf.Response.Qname != "cdn.example.net" {
|
|
t.Errorf("answered qname = %q, want the CNAME target", leaf.Response.Qname)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestIntegrationNXDOMAIN verifies rcode errors surface as error leaves with
|
|
// the reference wording.
|
|
func TestIntegrationNXDOMAIN(t *testing.T) {
|
|
m := newMockNet()
|
|
// mock returns NXDOMAIN for anything unmocked
|
|
root := run(t, newTraverser(10), m, "nonexistent.example.test")
|
|
got := statuses(root)
|
|
if math.Abs(got[traverse.StatusError]-1.0) > 1e-9 {
|
|
t.Errorf("statuses = %v, want 100%% error", got)
|
|
}
|
|
for _, leaf := range root.StatsList() {
|
|
if leaf.Response.DQ.ErrorMessage != "No such domain (NXDOMAIN)" {
|
|
t.Errorf("error message = %q", leaf.Response.DQ.ErrorMessage)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestIntegrationSERVFAIL verifies SERVFAIL classification.
|
|
func TestIntegrationSERVFAIL(t *testing.T) {
|
|
m := newMockNet()
|
|
sf := new(dns.Msg)
|
|
sf.Rcode = dns.RcodeServerFailure
|
|
m.on("198.41.0.4", "example.com", dns.TypeA, sf)
|
|
|
|
root := run(t, newTraverser(10), m, "example.com")
|
|
for _, leaf := range root.StatsList() {
|
|
if leaf.Response.Status != traverse.StatusError {
|
|
t.Errorf("status = %q, want error", leaf.Response.Status)
|
|
}
|
|
if leaf.Response.DQ.ErrorMessage != "Server failure (SERVFAIL)" {
|
|
t.Errorf("error message = %q", leaf.Response.DQ.ErrorMessage)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestIntegrationCNAMELoop verifies cross-response CNAME loops terminate as
|
|
// cname_loop leaves.
|
|
func TestIntegrationCNAMELoop(t *testing.T) {
|
|
m := newMockNet()
|
|
m.on("198.41.0.4", "www.a.test", dns.TypeA, answerMsg(cnameRR("www.a.test", "www.b.test")))
|
|
m.on("198.41.0.4", "www.b.test", dns.TypeA, answerMsg(cnameRR("www.b.test", "www.a.test")))
|
|
|
|
root := run(t, newTraverser(10), m, "www.a.test")
|
|
got := statuses(root)
|
|
if math.Abs(got[traverse.StatusCNAMELoop]-1.0) > 1e-9 {
|
|
t.Errorf("statuses = %v, want 100%% cname_loop", got)
|
|
}
|
|
}
|
|
|
|
// TestIntegrationMaxDepthExceeded verifies that an endless referral chain is
|
|
// cut off with a "Maxdepth N exceeded" exception leaf.
|
|
func TestIntegrationMaxDepthExceeded(t *testing.T) {
|
|
m := newMockNet()
|
|
// Each hop delegates one label deeper: the node at depth 3 (refid 1.1.1)
|
|
// is never queried because MaxDepth 3 injects the exception first.
|
|
m.on("198.41.0.4", "www.d2.d1", dns.TypeA, referralMsg(
|
|
[]dns.RR{nsRR("d1", "ns.d1")},
|
|
aRR("ns.d1", "10.0.0.1"),
|
|
))
|
|
m.on("10.0.0.1", "www.d2.d1", dns.TypeA, referralMsg(
|
|
[]dns.RR{nsRR("d2.d1", "ns.d2.d1")},
|
|
aRR("ns.d2.d1", "10.0.0.2"),
|
|
))
|
|
|
|
root := run(t, newTraverser(3), m, "www.d2.d1")
|
|
|
|
foundMaxdepth := false
|
|
for _, leaf := range root.StatsList() {
|
|
if leaf.Response.Status == traverse.StatusException &&
|
|
leaf.Response.DQ.ExceptionMessage == "Maxdepth 3 exceeded" {
|
|
foundMaxdepth = true
|
|
}
|
|
}
|
|
if !foundMaxdepth {
|
|
t.Errorf("expected a Maxdepth 3 exceeded exception leaf, got %v", statuses(root))
|
|
}
|
|
}
|
|
|
|
// TestIntegrationHooksReceiveEvents verifies start/answer events pair up.
|
|
func TestIntegrationHooksReceiveEvents(t *testing.T) {
|
|
m := newMockNet()
|
|
m.on("198.41.0.4", "example.com", dns.TypeA, answerMsg(aRR("example.com", "93.184.216.34")))
|
|
|
|
tr := newTraverser(10)
|
|
var startEvents, answerEvents int
|
|
tr.SetHooks(&traverse.TraverserHooks{
|
|
OnEvent: func(e traverse.TraversalEvent) {
|
|
switch e.Stage {
|
|
case traverse.StageStart:
|
|
startEvents++
|
|
case traverse.StageAnswer:
|
|
answerEvents++
|
|
}
|
|
},
|
|
})
|
|
run(t, tr, m, "example.com")
|
|
|
|
if startEvents == 0 {
|
|
t.Error("expected at least one start event")
|
|
}
|
|
if startEvents != answerEvents {
|
|
t.Errorf("start events (%d) != answer events (%d)", startEvents, answerEvents)
|
|
}
|
|
}
|
|
|
|
// TestIntegrationContextCancellation verifies that the traversal respects
|
|
// context cancellation and returns an appropriate error.
|
|
func TestIntegrationContextCancellation(t *testing.T) {
|
|
m := newMockNet()
|
|
m.on("198.41.0.4", "example.com", dns.TypeA, answerMsg(aRR("example.com", "93.184.216.34")))
|
|
|
|
tr := newTraverser(10)
|
|
tr.SetExchange(m.exchange)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel() // cancel before traversal starts
|
|
|
|
if _, err := tr.Run(ctx, "example.com"); err == nil {
|
|
t.Fatal("expected error when context is cancelled")
|
|
}
|
|
}
|