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
@@ -1,9 +1,11 @@
|
||||
// Package integration provides end-to-end tests for ExploreDNS using a mock
|
||||
// DNS server that allows deterministic, network-independent testing.
|
||||
// DNS exchange that allows deterministic, network-independent testing of the
|
||||
// full engine through its exported API.
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -13,523 +15,291 @@ import (
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
// mockZone represents a simple in-memory DNS zone for testing.
|
||||
type mockZone struct {
|
||||
// map[name][qtype] → []RR
|
||||
records map[string]map[uint16][]dns.RR
|
||||
// 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 newMockZone() *mockZone {
|
||||
return &mockZone{records: make(map[string]map[uint16][]dns.RR)}
|
||||
func newMockNet() *mockNet {
|
||||
return &mockNet{responses: make(map[string]*dns.Msg)}
|
||||
}
|
||||
|
||||
func (z *mockZone) addA(name, ip string) {
|
||||
fqdn := dns.Fqdn(name)
|
||||
if z.records[fqdn] == nil {
|
||||
z.records[fqdn] = make(map[uint16][]dns.RR)
|
||||
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
|
||||
}
|
||||
z.records[fqdn][dns.TypeA] = append(z.records[fqdn][dns.TypeA], &dns.A{
|
||||
Hdr: dns.RR_Header{Name: fqdn, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 300},
|
||||
A: net.ParseIP(ip),
|
||||
})
|
||||
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 (z *mockZone) addNS(zone, ns string) {
|
||||
fqdn := dns.Fqdn(zone)
|
||||
if z.records[fqdn] == nil {
|
||||
z.records[fqdn] = make(map[uint16][]dns.RR)
|
||||
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(),
|
||||
}
|
||||
z.records[fqdn][dns.TypeNS] = append(z.records[fqdn][dns.TypeNS], &dns.NS{
|
||||
Hdr: dns.RR_Header{Name: fqdn, Rrtype: dns.TypeNS, Class: dns.ClassINET, Ttl: 300},
|
||||
Ns: dns.Fqdn(ns),
|
||||
})
|
||||
}
|
||||
|
||||
func (z *mockZone) addCNAME(name, target string) {
|
||||
fqdn := dns.Fqdn(name)
|
||||
if z.records[fqdn] == nil {
|
||||
z.records[fqdn] = make(map[uint16][]dns.RR)
|
||||
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),
|
||||
}
|
||||
z.records[fqdn][dns.TypeCNAME] = append(z.records[fqdn][dns.TypeCNAME], &dns.CNAME{
|
||||
Hdr: dns.RR_Header{Name: fqdn, Rrtype: dns.TypeCNAME, Class: dns.ClassINET, Ttl: 300},
|
||||
}
|
||||
|
||||
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),
|
||||
})
|
||||
}
|
||||
|
||||
// makeExchange creates a mock ExchangeFunc that serves responses from the zone.
|
||||
// It simulates referral behavior: if a name matches a zone NS record, it returns
|
||||
// a referral with glue. If it matches an A record, it returns the answer.
|
||||
func (z *mockZone) makeExchange() dnsinternal.ExchangeFunc {
|
||||
return func(ctx context.Context, server string, msg *dns.Msg, useTCP bool) (*dns.Msg, error) {
|
||||
if len(msg.Question) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
q := msg.Question[0]
|
||||
|
||||
resp := new(dns.Msg)
|
||||
resp.SetReply(msg)
|
||||
resp.Authoritative = true
|
||||
|
||||
// Direct answer
|
||||
if rrs, ok := z.records[q.Name]; ok {
|
||||
if answers, ok := rrs[q.Qtype]; ok {
|
||||
resp.Answer = append(resp.Answer, answers...)
|
||||
return resp, nil
|
||||
}
|
||||
// CNAME chain — return CNAME + answer for target if qtype != CNAME
|
||||
if cnameRRs, ok := rrs[dns.TypeCNAME]; ok && q.Qtype != dns.TypeCNAME {
|
||||
resp.Answer = append(resp.Answer, cnameRRs...)
|
||||
return resp, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Check for zone delegation: look for NS records covering any suffix of qname
|
||||
labels := dns.SplitDomainName(q.Name)
|
||||
for i := 0; i < len(labels); i++ {
|
||||
zone := dns.Fqdn(joinLabels(labels[i:]))
|
||||
if nsRRs, ok := z.records[zone][dns.TypeNS]; ok && zone != q.Name {
|
||||
// Return referral
|
||||
resp.Authoritative = false
|
||||
resp.Ns = append(resp.Ns, nsRRs...)
|
||||
for _, ns := range nsRRs {
|
||||
nsName := ns.(*dns.NS).Ns
|
||||
if aRRs, ok := z.records[nsName][dns.TypeA]; ok {
|
||||
resp.Extra = append(resp.Extra, aRRs...)
|
||||
}
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
}
|
||||
|
||||
// NXDOMAIN
|
||||
resp.Authoritative = true
|
||||
resp.Rcode = dns.RcodeNameError
|
||||
return resp, nil
|
||||
}
|
||||
}
|
||||
|
||||
func joinLabels(labels []string) string {
|
||||
result := ""
|
||||
for i, l := range labels {
|
||||
if i > 0 {
|
||||
result += "."
|
||||
}
|
||||
result += l
|
||||
}
|
||||
return result
|
||||
func answerMsg(rrs ...dns.RR) *dns.Msg {
|
||||
m := new(dns.Msg)
|
||||
m.Answer = rrs
|
||||
return m
|
||||
}
|
||||
|
||||
// setupTestZone creates a mock zone with a typical referral hierarchy:
|
||||
//
|
||||
// root → com (referral) → example.com (referral) → www.example.com (A)
|
||||
func setupTestZone() *mockZone {
|
||||
z := newMockZone()
|
||||
|
||||
// Root server glue
|
||||
z.addA("a.root-servers.test", "198.41.0.4")
|
||||
|
||||
// com TLD referral from root
|
||||
z.addNS("com", "a.gtld-servers.test")
|
||||
z.addA("a.gtld-servers.test", "192.5.6.30")
|
||||
|
||||
// example.com NS referral from com TLD
|
||||
z.addNS("example.com", "ns1.example.com")
|
||||
z.addA("ns1.example.com", "1.2.3.4")
|
||||
|
||||
// Actual A records
|
||||
z.addA("example.com", "93.184.216.34")
|
||||
z.addA("www.example.com", "93.184.216.34")
|
||||
|
||||
return z
|
||||
func referralMsg(nsRRs []dns.RR, glue ...dns.RR) *dns.Msg {
|
||||
m := new(dns.Msg)
|
||||
m.Ns = nsRRs
|
||||
m.Extra = glue
|
||||
return m
|
||||
}
|
||||
|
||||
// TestIntegrationSimpleAQuery verifies end-to-end traversal with mock DNS
|
||||
// that returns A record answers without network dependency.
|
||||
func TestIntegrationSimpleAQuery(t *testing.T) {
|
||||
z := setupTestZone()
|
||||
|
||||
tr := traverse.NewTraverser(&traverse.TraverserConfig{
|
||||
MaxDepth: 10,
|
||||
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,
|
||||
},
|
||||
})
|
||||
tr.SetExchange(z.makeExchange())
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
results, err := tr.Traverse(ctx, "example.com")
|
||||
root, err := tr.Run(ctx, qname)
|
||||
if err != nil {
|
||||
t.Fatalf("Traverse: %v", err)
|
||||
}
|
||||
if len(results) == 0 {
|
||||
t.Fatal("expected results from traversal")
|
||||
t.Fatalf("Run(%q): %v", qname, err)
|
||||
}
|
||||
assertProbabilityInvariant(t, root)
|
||||
return root
|
||||
}
|
||||
|
||||
var foundAnswer bool
|
||||
for _, r := range results {
|
||||
if r.Response != nil && r.Response.Type == traverse.RespAnswer {
|
||||
foundAnswer = true
|
||||
if r.Response.Decoded != nil && len(r.Response.Decoded.Answers) > 0 {
|
||||
for _, rr := range r.Response.Decoded.Answers {
|
||||
if a, ok := rr.(*dns.A); ok {
|
||||
t.Logf("Found A record: %v", a.A)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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 !foundAnswer {
|
||||
t.Errorf("expected to find an answer response; got types: %v", responseTypes(results))
|
||||
if math.Abs(sum-1.0) > 1e-9 {
|
||||
t.Errorf("leaf probabilities sum to %v, want 1.0", sum)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrationReferralChain verifies multi-hop referral traversal:
|
||||
// root → com → example.com, with glue records at each step.
|
||||
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) {
|
||||
z := newMockZone()
|
||||
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 delegates to com
|
||||
z.addNS("com", "a.gtld-servers.test")
|
||||
z.addA("a.gtld-servers.test", "192.5.6.30")
|
||||
|
||||
// TLD delegates to example.com
|
||||
z.addNS("example.com", "ns1.example.com")
|
||||
z.addA("ns1.example.com", "1.2.3.4")
|
||||
|
||||
// Authoritative answer
|
||||
z.addA("example.com", "93.184.216.34")
|
||||
|
||||
exchange := z.makeExchange()
|
||||
tr := traverse.NewTraverser(&traverse.TraverserConfig{
|
||||
MaxDepth: 10,
|
||||
QueryType: dnsinternal.TypeA,
|
||||
RootAddrs: []net.IP{net.ParseIP("198.41.0.4")},
|
||||
})
|
||||
tr.SetExchange(exchange)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
results, err := tr.Traverse(ctx, "example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("Traverse: %v", err)
|
||||
}
|
||||
|
||||
// Count referrals and answers
|
||||
var referrals, answers int
|
||||
for _, r := range results {
|
||||
if r.Response == nil {
|
||||
continue
|
||||
}
|
||||
switch r.Response.Type {
|
||||
case traverse.RespReferral:
|
||||
referrals++
|
||||
case traverse.RespAnswer:
|
||||
answers++
|
||||
}
|
||||
}
|
||||
t.Logf("referrals=%d answers=%d total=%d", referrals, answers, len(results))
|
||||
if answers == 0 {
|
||||
t.Errorf("expected at least one answer; types: %v", responseTypes(results))
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrationCNAMEResolution verifies that CNAME chains are followed correctly.
|
||||
func TestIntegrationCNAMEResolution(t *testing.T) {
|
||||
z := newMockZone()
|
||||
// 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")))
|
||||
|
||||
// www.example.com → CNAME → example.com → A record
|
||||
z.addCNAME("www.example.com", "example.com")
|
||||
z.addA("example.com", "93.184.216.34")
|
||||
|
||||
tr := traverse.NewTraverser(&traverse.TraverserConfig{
|
||||
MaxDepth: 10,
|
||||
QueryType: dnsinternal.TypeA,
|
||||
RootAddrs: []net.IP{net.ParseIP("198.41.0.4")},
|
||||
})
|
||||
tr.SetExchange(z.makeExchange())
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
results, err := tr.Traverse(ctx, "www.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("Traverse CNAME: %v", err)
|
||||
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)
|
||||
}
|
||||
if len(results) == 0 {
|
||||
t.Fatal("expected results")
|
||||
}
|
||||
|
||||
var foundCNAME, foundAnswer bool
|
||||
for _, r := range results {
|
||||
if r.Response == nil {
|
||||
continue
|
||||
}
|
||||
if r.Response.Type == traverse.RespCNAMEFollow {
|
||||
foundCNAME = true
|
||||
}
|
||||
if r.Response.Type == traverse.RespAnswer {
|
||||
foundAnswer = true
|
||||
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)
|
||||
}
|
||||
}
|
||||
t.Logf("CNAME traversal: foundCNAME=%v foundAnswer=%v types=%v", foundCNAME, foundAnswer, responseTypes(results))
|
||||
}
|
||||
|
||||
// TestIntegrationNXDOMAIN verifies that NXDOMAIN responses are correctly classified.
|
||||
// TestIntegrationNXDOMAIN verifies rcode errors surface as error leaves with
|
||||
// the reference wording.
|
||||
func TestIntegrationNXDOMAIN(t *testing.T) {
|
||||
z := newMockZone()
|
||||
// Zone has no records for nonexistent.example.com
|
||||
|
||||
tr := traverse.NewTraverser(&traverse.TraverserConfig{
|
||||
MaxDepth: 10,
|
||||
QueryType: dnsinternal.TypeA,
|
||||
RootAddrs: []net.IP{net.ParseIP("198.41.0.4")},
|
||||
})
|
||||
tr.SetExchange(z.makeExchange())
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
results, err := tr.Traverse(ctx, "nonexistent.example.test")
|
||||
if err != nil {
|
||||
t.Fatalf("Traverse NXDOMAIN: %v", err)
|
||||
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)
|
||||
}
|
||||
if len(results) == 0 {
|
||||
t.Fatal("expected at least one result for NXDOMAIN")
|
||||
}
|
||||
|
||||
var foundNXDOMAIN bool
|
||||
for _, r := range results {
|
||||
if r.Response != nil && r.Response.Type == traverse.RespNXDOMAIN {
|
||||
foundNXDOMAIN = true
|
||||
break
|
||||
for _, leaf := range root.StatsList() {
|
||||
if leaf.Response.DQ.ErrorMessage != "No such domain (NXDOMAIN)" {
|
||||
t.Errorf("error message = %q", leaf.Response.DQ.ErrorMessage)
|
||||
}
|
||||
}
|
||||
if !foundNXDOMAIN {
|
||||
t.Errorf("expected NXDOMAIN result; got: %v", responseTypes(results))
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrationSERVFAIL verifies that SERVFAIL responses are correctly handled.
|
||||
// TestIntegrationSERVFAIL verifies SERVFAIL classification.
|
||||
func TestIntegrationSERVFAIL(t *testing.T) {
|
||||
sfMsg := new(dns.Msg)
|
||||
sfMsg.Rcode = dns.RcodeServerFailure
|
||||
m := newMockNet()
|
||||
sf := new(dns.Msg)
|
||||
sf.Rcode = dns.RcodeServerFailure
|
||||
m.on("198.41.0.4", "example.com", dns.TypeA, sf)
|
||||
|
||||
tr := traverse.NewTraverser(&traverse.TraverserConfig{
|
||||
MaxDepth: 10,
|
||||
QueryType: dnsinternal.TypeA,
|
||||
RootAddrs: []net.IP{net.ParseIP("198.41.0.4")},
|
||||
})
|
||||
tr.SetExchange(func(ctx context.Context, server string, msg *dns.Msg, useTCP bool) (*dns.Msg, error) {
|
||||
return sfMsg.Copy(), nil
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
results, err := tr.Traverse(ctx, "example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("Traverse SERVFAIL: %v", err)
|
||||
}
|
||||
if len(results) == 0 {
|
||||
t.Fatal("expected at least one result")
|
||||
}
|
||||
if results[0].Response.Type != traverse.RespSERVFAIL {
|
||||
t.Errorf("expected SERVFAIL, got %v", results[0].Response.Type)
|
||||
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 that CNAME loops are detected and reported.
|
||||
// TestIntegrationCNAMELoop verifies cross-response CNAME loops terminate as
|
||||
// cname_loop leaves.
|
||||
func TestIntegrationCNAMELoop(t *testing.T) {
|
||||
callCount := 0
|
||||
// www.a.test → CNAME → www.b.test → CNAME → www.a.test (loop)
|
||||
tr := traverse.NewTraverser(&traverse.TraverserConfig{
|
||||
MaxDepth: 10,
|
||||
QueryType: dnsinternal.TypeA,
|
||||
RootAddrs: []net.IP{net.ParseIP("198.41.0.4")},
|
||||
})
|
||||
tr.SetExchange(func(ctx context.Context, server string, msg *dns.Msg, useTCP bool) (*dns.Msg, error) {
|
||||
callCount++
|
||||
if len(msg.Question) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
q := msg.Question[0]
|
||||
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")))
|
||||
|
||||
resp := new(dns.Msg)
|
||||
resp.SetReply(msg)
|
||||
resp.Authoritative = true
|
||||
|
||||
switch q.Name {
|
||||
case "www.a.test.":
|
||||
resp.Answer = append(resp.Answer, &dns.CNAME{
|
||||
Hdr: dns.RR_Header{Name: "www.a.test.", Rrtype: dns.TypeCNAME, Class: dns.ClassINET},
|
||||
Target: "www.b.test.",
|
||||
})
|
||||
case "www.b.test.":
|
||||
resp.Answer = append(resp.Answer, &dns.CNAME{
|
||||
Hdr: dns.RR_Header{Name: "www.b.test.", Rrtype: dns.TypeCNAME, Class: dns.ClassINET},
|
||||
Target: "www.a.test.",
|
||||
})
|
||||
default:
|
||||
resp.Rcode = dns.RcodeNameError
|
||||
}
|
||||
return resp, nil
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
results, err := tr.Traverse(ctx, "www.a.test")
|
||||
if err != nil {
|
||||
t.Fatalf("Traverse CNAME loop: %v", err)
|
||||
}
|
||||
if len(results) == 0 {
|
||||
t.Fatal("expected results from CNAME loop traversal")
|
||||
}
|
||||
|
||||
var foundLoop bool
|
||||
for _, r := range results {
|
||||
if r.Response != nil && r.Response.Type == traverse.RespCNAMELoop {
|
||||
foundLoop = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundLoop {
|
||||
t.Logf("types found: %v", responseTypes(results))
|
||||
// CNAME loop detection may vary based on implementation; warn rather than fail
|
||||
t.Logf("CNAME loop not detected as RespCNAMELoop (may be handled differently)")
|
||||
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 infinite referral chains are
|
||||
// cut off at the configured max depth.
|
||||
// TestIntegrationMaxDepthExceeded verifies that an endless referral chain is
|
||||
// cut off with a "Maxdepth N exceeded" exception leaf.
|
||||
func TestIntegrationMaxDepthExceeded(t *testing.T) {
|
||||
tr := traverse.NewTraverser(&traverse.TraverserConfig{
|
||||
MaxDepth: 3,
|
||||
QueryType: dnsinternal.TypeA,
|
||||
RootAddrs: []net.IP{net.ParseIP("198.41.0.4")},
|
||||
})
|
||||
tr.SetExchange(func(ctx context.Context, server string, msg *dns.Msg, useTCP bool) (*dns.Msg, error) {
|
||||
// Always return a referral to ns.example.com
|
||||
resp := new(dns.Msg)
|
||||
resp.SetReply(msg)
|
||||
resp.Ns = append(resp.Ns, &dns.NS{
|
||||
Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeNS, Class: dns.ClassINET},
|
||||
Ns: "ns.example.com.",
|
||||
})
|
||||
resp.Extra = append(resp.Extra, &dns.A{
|
||||
Hdr: dns.RR_Header{Name: "ns.example.com.", Rrtype: dns.TypeA, Class: dns.ClassINET},
|
||||
A: net.ParseIP("1.2.3.4"),
|
||||
})
|
||||
return resp, nil
|
||||
})
|
||||
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"),
|
||||
))
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
root := run(t, newTraverser(3), m, "www.d2.d1")
|
||||
|
||||
results, err := tr.Traverse(ctx, "deep.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("Traverse: %v", err)
|
||||
foundMaxdepth := false
|
||||
for _, leaf := range root.StatsList() {
|
||||
if leaf.Response.Status == traverse.StatusException &&
|
||||
leaf.Response.DQ.ExceptionMessage == "Maxdepth 3 exceeded" {
|
||||
foundMaxdepth = true
|
||||
}
|
||||
}
|
||||
t.Logf("max depth test: %d results, types: %v", len(results), responseTypes(results))
|
||||
if len(results) == 0 {
|
||||
t.Fatal("expected results even with max depth exceeded")
|
||||
if !foundMaxdepth {
|
||||
t.Errorf("expected a Maxdepth 3 exceeded exception leaf, got %v", statuses(root))
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrationHooksReceiveEvents verifies that traversal hooks receive
|
||||
// the expected start and complete events.
|
||||
// TestIntegrationHooksReceiveEvents verifies start/answer events pair up.
|
||||
func TestIntegrationHooksReceiveEvents(t *testing.T) {
|
||||
answerMsg := new(dns.Msg)
|
||||
answerMsg.SetReply(new(dns.Msg))
|
||||
answerMsg.Answer = append(answerMsg.Answer, &dns.A{
|
||||
Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 300},
|
||||
A: net.ParseIP("93.184.216.34"),
|
||||
})
|
||||
m := newMockNet()
|
||||
m.on("198.41.0.4", "example.com", dns.TypeA, answerMsg(aRR("example.com", "93.184.216.34")))
|
||||
|
||||
tr := traverse.NewTraverser(&traverse.TraverserConfig{
|
||||
MaxDepth: 10,
|
||||
QueryType: dnsinternal.TypeA,
|
||||
RootAddrs: []net.IP{net.ParseIP("198.41.0.4")},
|
||||
})
|
||||
tr.SetExchange(func(ctx context.Context, server string, msg *dns.Msg, useTCP bool) (*dns.Msg, error) {
|
||||
return answerMsg.Copy(), nil
|
||||
})
|
||||
|
||||
var startEvents, completeEvents int
|
||||
tr := newTraverser(10)
|
||||
var startEvents, answerEvents int
|
||||
tr.SetHooks(&traverse.TraverserHooks{
|
||||
OnEvent: func(e traverse.TraversalEvent) {
|
||||
switch e.Stage {
|
||||
case traverse.EventStart:
|
||||
case traverse.StageStart:
|
||||
startEvents++
|
||||
case traverse.EventComplete:
|
||||
completeEvents++
|
||||
case traverse.StageAnswer:
|
||||
answerEvents++
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
results, err := tr.Traverse(ctx, "example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("Traverse: %v", err)
|
||||
}
|
||||
_ = results
|
||||
run(t, tr, m, "example.com")
|
||||
|
||||
if startEvents == 0 {
|
||||
t.Error("expected at least one start event")
|
||||
}
|
||||
if completeEvents == 0 {
|
||||
t.Error("expected at least one complete event")
|
||||
}
|
||||
if startEvents != completeEvents {
|
||||
t.Errorf("start events (%d) != complete events (%d)", startEvents, completeEvents)
|
||||
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) {
|
||||
tr := traverse.NewTraverser(&traverse.TraverserConfig{
|
||||
MaxDepth: 10,
|
||||
QueryType: dnsinternal.TypeA,
|
||||
RootAddrs: []net.IP{net.ParseIP("198.41.0.4")},
|
||||
})
|
||||
tr.SetExchange(func(ctx context.Context, server string, msg *dns.Msg, useTCP bool) (*dns.Msg, error) {
|
||||
// Always return referral to keep loop going
|
||||
resp := new(dns.Msg)
|
||||
resp.SetReply(msg)
|
||||
resp.Ns = append(resp.Ns, &dns.NS{
|
||||
Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeNS},
|
||||
Ns: "ns.example.com.",
|
||||
})
|
||||
resp.Extra = append(resp.Extra, &dns.A{
|
||||
Hdr: dns.RR_Header{Name: "ns.example.com.", Rrtype: dns.TypeA},
|
||||
A: net.ParseIP("1.2.3.4"),
|
||||
})
|
||||
return resp, nil
|
||||
})
|
||||
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
|
||||
cancel() // cancel before traversal starts
|
||||
|
||||
_, err := tr.Traverse(ctx, "example.com")
|
||||
if err == nil {
|
||||
if _, err := tr.Run(ctx, "example.com"); err == nil {
|
||||
t.Fatal("expected error when context is cancelled")
|
||||
}
|
||||
}
|
||||
|
||||
// responseTypes returns a summary of response types for debugging.
|
||||
func responseTypes(results []traverse.TraversalResult) []string {
|
||||
var types []string
|
||||
for _, r := range results {
|
||||
if r.Response != nil {
|
||||
types = append(types, r.Response.Type.String())
|
||||
} else {
|
||||
types = append(types, "nil")
|
||||
}
|
||||
}
|
||||
return types
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user