feat: complete Phase 4.1 error handling and robustness (HAN-385)
CI / test (pull_request) Failing after 2m41s

- Fast mode cache isolation: TraverserConfig.Fast=false gives each
  referral its own independent InfoCache with no cross-branch glue
  inheritance; Fast=true (default) retains the shared root cache so
  earlier branch discoveries are reused
- Wire cfg.Fast from CLI config into TraverserConfig in main.go
- IDN/Punycode: NewReferral now converts unicode domain labels to their
  ACE/punycode form via golang.org/x/net/idna before querying, with a
  graceful fallback when conversion fails
- DNSSEC: hasFinalAnswer() now skips RRSIG records alongside CNAME so
  a signed referral does not prevent CNAME following
- Tests: DNSSEC RRSIG does not block CNAME follow, fast/non-fast cache
  isolation, 12-NS referral, IDN conversion, wildcard answer, long CNAME
  chain depth limit, partial branch failure with graceful degradation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Gary Hansen
2026-06-08 03:22:21 +10:00
co-authored by Copilot multica-agent
parent 368f200d23
commit 6e9aefc47e
7 changed files with 451 additions and 11 deletions
+26 -2
View File
@@ -8,6 +8,7 @@ import (
"github.com/hits/ExploreDNS/internal/dns"
miekgdns "github.com/miekg/dns"
"golang.org/x/net/idna"
)
type ResolutionState int
@@ -46,12 +47,35 @@ type Referral struct {
Prob float64
}
// idnaLookup is the IDN lookup profile used to convert internationalised domain
// names (unicode labels) to their ACE/punycode equivalents before querying.
var idnaLookup = idna.New(
idna.MapForLookup(),
idna.BidiRule(),
idna.StrictDomainName(false),
)
// toASCII converts a domain name that may contain unicode labels to its
// punycode (ACE) representation. Pure-ASCII names are returned unchanged.
// On conversion errors the original name is returned so the caller can still
// attempt a query (the server will reject it if truly invalid).
func toASCII(name string) string {
if name == "" || name == "." {
return name
}
ascii, err := idnaLookup.ToASCII(name)
if err != nil {
return name
}
return ascii
}
func NewReferral(name string, qtype uint16, bailiwick string, depth int, prob float64, parent *Referral) *Referral {
return &Referral{
Name: miekgdns.Fqdn(strings.ToLower(name)),
Name: miekgdns.Fqdn(strings.ToLower(toASCII(name))),
Qtype: qtype,
Qclass: miekgdns.ClassINET,
Bailiwick: miekgdns.Fqdn(strings.ToLower(bailiwick)),
Bailiwick: miekgdns.Fqdn(strings.ToLower(toASCII(bailiwick))),
Depth: depth,
Prob: prob,
Parent: parent,
+5 -4
View File
@@ -121,10 +121,11 @@ func (r *Response) classify() ResponseType {
func (r *Response) hasFinalAnswer() bool {
for _, rr := range r.Decoded.Answers {
if _, ok := rr.(*miekgdns.CNAME); ok {
continue
}
if _, ok := rr.(*miekgdns.DNAME); ok {
switch rr.(type) {
case *miekgdns.CNAME, *miekgdns.DNAME, *miekgdns.RRSIG:
// CNAME and DNAME are redirect records, not final answers.
// RRSIG is a DNSSEC signature record — it covers the CNAME/DNAME
// but is not itself the answer to the original question type.
continue
}
return true
+392
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"net"
"sync/atomic"
"testing"
"github.com/miekg/dns"
@@ -374,3 +375,394 @@ func TestMalformedResponseNoPanic(t *testing.T) {
}
}
}
// TestDNSSECRRSIGDoesNotBlockCNAMEFollow verifies that a DNSSEC RRSIG record
// accompanying a CNAME in the answer section is treated as metadata and does
// NOT prevent the traversal from following the CNAME.
func TestDNSSECRRSIGDoesNotBlockCNAMEFollow(t *testing.T) {
// Server returns CNAME + RRSIG (DNSSEC-signed zone response).
cnameWithRRSIG := new(dns.Msg)
cnameWithRRSIG.SetReply(new(dns.Msg))
cnameWithRRSIG.Answer = append(cnameWithRRSIG.Answer,
&dns.CNAME{
Hdr: dns.RR_Header{Name: "www.example.com.", Rrtype: dns.TypeCNAME, Class: dns.ClassINET, Ttl: 300},
Target: "example.com.",
},
&dns.RRSIG{
Hdr: dns.RR_Header{Name: "www.example.com.", Rrtype: dns.TypeRRSIG, Class: dns.ClassINET, Ttl: 300},
TypeCovered: dns.TypeCNAME,
},
)
finalAnswer := new(dns.Msg)
finalAnswer.SetReply(new(dns.Msg))
finalAnswer.Answer = append(finalAnswer.Answer, &dns.A{
Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dnsTypeA, Class: dns.ClassINET, Ttl: 300},
A: net.ParseIP("93.184.216.34"),
})
tr := NewTraverser(&TraverserConfig{
MaxDepth: 10,
QueryType: dnsTypeA,
RootAddrs: []net.IP{net.ParseIP("198.41.0.4")},
Fast: true,
})
tr.SetExchange(func(ctx context.Context, server string, msg *dns.Msg, useTCP bool) (*dns.Msg, error) {
q := msg.Question[0]
if q.Name == "www.example.com." {
return cnameWithRRSIG.Copy(), nil
}
return finalAnswer.Copy(), nil
})
ctx := context.Background()
results, err := tr.Traverse(ctx, "www.example.com")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
foundCNAMEFollow := false
foundAnswer := false
for _, r := range results {
if r.Response != nil {
switch r.Response.Type {
case RespCNAMEFollow:
foundCNAMEFollow = true
case RespAnswer:
foundAnswer = true
}
}
}
if !foundCNAMEFollow {
t.Error("expected RespCNAMEFollow: RRSIG should not block CNAME following")
}
if !foundAnswer {
t.Error("expected final RespAnswer after CNAME follow")
}
}
// TestFastModeOn verifies that Fast=true uses the shared root cache (default
// behaviour): a child branch can see glue stored by the root referral.
func TestFastModeOn(t *testing.T) {
// Root referral returns two nameservers with glue. Each NS branch returns
// an answer. We verify both branches are queried.
referralMsg := new(dns.Msg)
referralMsg.Rcode = dns.RcodeSuccess
referralMsg.Authoritative = false
referralMsg.Ns = append(referralMsg.Ns,
&dns.NS{Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dnsTypeNS}, Ns: "ns1.example.com."},
&dns.NS{Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dnsTypeNS}, Ns: "ns2.example.com."},
)
referralMsg.Extra = append(referralMsg.Extra,
&dns.A{Hdr: dns.RR_Header{Name: "ns1.example.com.", Rrtype: dnsTypeA}, A: net.ParseIP("10.0.0.1")},
&dns.A{Hdr: dns.RR_Header{Name: "ns2.example.com.", Rrtype: dnsTypeA}, A: net.ParseIP("10.0.0.2")},
)
answerMsg := new(dns.Msg)
answerMsg.SetReply(new(dns.Msg))
answerMsg.Answer = append(answerMsg.Answer, &dns.A{
Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dnsTypeA, Class: dns.ClassINET, Ttl: 300},
A: net.ParseIP("93.184.216.34"),
})
tr := NewTraverser(&TraverserConfig{
MaxDepth: 5,
QueryType: dnsTypeA,
RootAddrs: []net.IP{net.ParseIP("198.41.0.4")},
Fast: true,
})
tr.SetExchange(func(ctx context.Context, server string, msg *dns.Msg, useTCP bool) (*dns.Msg, error) {
if server == "198.41.0.4" {
return referralMsg.Copy(), nil
}
return answerMsg.Copy(), nil
})
ctx := context.Background()
results, err := tr.Traverse(ctx, "example.com")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
answers := 0
for _, r := range results {
if r.Response != nil && r.Response.Type == RespAnswer {
answers++
}
}
if answers == 0 {
t.Error("expected at least one answer with Fast=true")
}
}
// TestFastModeOff verifies that Fast=false gives each referral its own
// independent cache — no cross-branch glue contamination.
func TestFastModeOff(t *testing.T) {
referralMsg := new(dns.Msg)
referralMsg.Rcode = dns.RcodeSuccess
referralMsg.Authoritative = false
referralMsg.Ns = append(referralMsg.Ns,
&dns.NS{Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dnsTypeNS}, Ns: "ns1.example.com."},
&dns.NS{Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dnsTypeNS}, Ns: "ns2.example.com."},
)
referralMsg.Extra = append(referralMsg.Extra,
&dns.A{Hdr: dns.RR_Header{Name: "ns1.example.com.", Rrtype: dnsTypeA}, A: net.ParseIP("10.0.0.1")},
&dns.A{Hdr: dns.RR_Header{Name: "ns2.example.com.", Rrtype: dnsTypeA}, A: net.ParseIP("10.0.0.2")},
)
answerMsg := new(dns.Msg)
answerMsg.SetReply(new(dns.Msg))
answerMsg.Answer = append(answerMsg.Answer, &dns.A{
Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dnsTypeA, Class: dns.ClassINET, Ttl: 300},
A: net.ParseIP("93.184.216.34"),
})
tr := NewTraverser(&TraverserConfig{
MaxDepth: 5,
QueryType: dnsTypeA,
RootAddrs: []net.IP{net.ParseIP("198.41.0.4")},
Fast: false,
})
tr.SetExchange(func(ctx context.Context, server string, msg *dns.Msg, useTCP bool) (*dns.Msg, error) {
if server == "198.41.0.4" {
return referralMsg.Copy(), nil
}
return answerMsg.Copy(), nil
})
ctx := context.Background()
results, err := tr.Traverse(ctx, "example.com")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Traversal must complete without panic and produce results.
if len(results) == 0 {
t.Fatal("expected at least one result with Fast=false")
}
}
// TestFastModeDefaultIsTrue verifies that DefaultTraverserConfig has Fast=true.
func TestFastModeDefaultIsTrue(t *testing.T) {
cfg := DefaultTraverserConfig()
if !cfg.Fast {
t.Error("DefaultTraverserConfig().Fast should be true")
}
}
// TestManyNSRecords verifies that a referral with more than 10 nameservers is
// handled gracefully — no panics, results are produced.
func TestManyNSRecords(t *testing.T) {
referralMsg := new(dns.Msg)
referralMsg.Rcode = dns.RcodeSuccess
referralMsg.Authoritative = false
for i := 1; i <= 12; i++ {
ns := &dns.NS{
Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dnsTypeNS},
Ns: net.ParseIP(string(rune('a'+i-1))).String() + ".ns.example.com.",
}
// Use a distinct IP for each NS so glue is resolved.
ip := net.IP{10, 0, 0, byte(i)}
referralMsg.Ns = append(referralMsg.Ns, &dns.NS{
Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dnsTypeNS},
Ns: ns.Ns,
})
referralMsg.Extra = append(referralMsg.Extra, &dns.A{
Hdr: dns.RR_Header{Name: ns.Ns, Rrtype: dnsTypeA},
A: ip,
})
}
answerMsg := new(dns.Msg)
answerMsg.SetReply(new(dns.Msg))
answerMsg.Answer = append(answerMsg.Answer, &dns.A{
Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dnsTypeA, Class: dns.ClassINET, Ttl: 300},
A: net.ParseIP("93.184.216.34"),
})
var queries int64
tr := NewTraverser(&TraverserConfig{
MaxDepth: 5,
QueryType: dnsTypeA,
RootAddrs: []net.IP{net.ParseIP("198.41.0.4")},
Fast: true,
})
tr.SetExchange(func(ctx context.Context, server string, msg *dns.Msg, useTCP bool) (*dns.Msg, error) {
atomic.AddInt64(&queries, 1)
if server == "198.41.0.4" {
return referralMsg.Copy(), nil
}
return answerMsg.Copy(), nil
})
ctx := context.Background()
results, err := tr.Traverse(ctx, "example.com")
if err != nil {
t.Fatalf("unexpected error with 12 NS records: %v", err)
}
if len(results) == 0 {
t.Fatal("expected results with many NS records")
}
foundAnswer := false
for _, r := range results {
if r.Response != nil && r.Response.Type == RespAnswer {
foundAnswer = true
}
}
if !foundAnswer {
t.Error("expected at least one answer from the 12-NS referral")
}
}
// TestIDNPunycodeConversion verifies that a unicode (IDN) domain name is
// converted to its punycode/ACE form before querying.
func TestIDNPunycodeConversion(t *testing.T) {
// "münchen.de" → "xn--mnchen-3ya.de" (after punycode encoding)
ref := NewReferral("münchen.de", dnsTypeA, ".", 0, 1.0, nil)
if ref.Name == "münchen.de." {
t.Errorf("IDN name was not converted to punycode: got %q", ref.Name)
}
// Verify it starts with the expected punycode label.
if ref.Name != "xn--mnchen-3ya.de." {
t.Errorf("unexpected punycode result: got %q, want %q", ref.Name, "xn--mnchen-3ya.de.")
}
}
// TestASCIIDomainUnchanged verifies that a plain ASCII domain is not mangled
// by the IDN conversion path.
func TestASCIIDomainUnchanged(t *testing.T) {
ref := NewReferral("example.com", dnsTypeA, ".", 0, 1.0, nil)
if ref.Name != "example.com." {
t.Errorf("ASCII domain was mangled: got %q, want %q", ref.Name, "example.com.")
}
}
// TestWildcardResponse verifies that a wildcard answer (e.g. *.example.com
// returning an A record for sub.example.com) is handled as a regular answer.
func TestWildcardResponse(t *testing.T) {
wildcardAnswer := new(dns.Msg)
wildcardAnswer.SetReply(new(dns.Msg))
wildcardAnswer.Authoritative = true
wildcardAnswer.Answer = append(wildcardAnswer.Answer, &dns.A{
Hdr: dns.RR_Header{Name: "sub.example.com.", Rrtype: dnsTypeA, Class: dns.ClassINET, Ttl: 300},
A: net.ParseIP("1.2.3.4"),
})
tr := NewTraverser(&TraverserConfig{
MaxDepth: 5,
QueryType: dnsTypeA,
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 wildcardAnswer.Copy(), nil
})
ctx := context.Background()
results, err := tr.Traverse(ctx, "sub.example.com")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(results) == 0 {
t.Fatal("expected results for wildcard response")
}
if results[0].Response.Type != RespAnswer {
t.Errorf("Type = %s, want answer", results[0].Response.Type)
}
}
// TestLongCNAMEChainDepthLimit verifies that a very long CNAME chain is
// terminated by the MaxDepth limit without infinite recursion or a panic.
func TestLongCNAMEChainDepthLimit(t *testing.T) {
// Every query returns a CNAME to the next label. The MaxDepth setting
// must stop the chain.
counter := 0
tr := NewTraverser(&TraverserConfig{
MaxDepth: 5,
QueryType: dnsTypeA,
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) {
counter++
q := msg.Question[0]
resp := new(dns.Msg)
resp.SetReply(msg)
next := "next" + q.Name
resp.Answer = append(resp.Answer, &dns.CNAME{
Hdr: dns.RR_Header{Name: q.Name, Rrtype: dnsTypeCNAME, Class: dns.ClassINET},
Target: next,
})
return resp, nil
})
ctx := context.Background()
results, err := tr.Traverse(ctx, "start.example.com")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(results) == 0 {
t.Fatal("expected results")
}
// Traversal must have stopped — counter should not be unbounded.
if counter > 50 {
t.Errorf("too many exchange calls (%d): chain depth limit not enforced", counter)
}
}
// TestPartialBranchFailureReturnsResults verifies the graceful degradation
// requirement: when some NS branches fail completely, the partial results from
// successful branches are still returned.
func TestPartialBranchFailureReturnsResults(t *testing.T) {
// Three nameservers: first two error, third succeeds.
referralMsg := new(dns.Msg)
referralMsg.Rcode = dns.RcodeSuccess
referralMsg.Authoritative = false
referralMsg.Ns = append(referralMsg.Ns,
&dns.NS{Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dnsTypeNS}, Ns: "ns1.example.com."},
&dns.NS{Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dnsTypeNS}, Ns: "ns2.example.com."},
&dns.NS{Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dnsTypeNS}, Ns: "ns3.example.com."},
)
referralMsg.Extra = append(referralMsg.Extra,
&dns.A{Hdr: dns.RR_Header{Name: "ns1.example.com.", Rrtype: dnsTypeA}, A: net.ParseIP("10.0.0.1")},
&dns.A{Hdr: dns.RR_Header{Name: "ns2.example.com.", Rrtype: dnsTypeA}, A: net.ParseIP("10.0.0.2")},
&dns.A{Hdr: dns.RR_Header{Name: "ns3.example.com.", Rrtype: dnsTypeA}, A: net.ParseIP("10.0.0.3")},
)
answerMsg := new(dns.Msg)
answerMsg.SetReply(new(dns.Msg))
answerMsg.Answer = append(answerMsg.Answer, &dns.A{
Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dnsTypeA, Class: dns.ClassINET, Ttl: 300},
A: net.ParseIP("93.184.216.34"),
})
tr := NewTraverser(&TraverserConfig{
MaxDepth: 5,
QueryType: dnsTypeA,
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) {
switch server {
case "198.41.0.4":
return referralMsg.Copy(), nil
case "10.0.0.1", "10.0.0.2":
return nil, errors.New("server unreachable")
case "10.0.0.3":
return answerMsg.Copy(), nil
}
return nil, errors.New("unexpected server")
})
ctx := context.Background()
results, err := tr.Traverse(ctx, "example.com")
if err != nil {
t.Fatalf("traversal must not return a top-level error: %v", err)
}
foundAnswer := false
for _, r := range results {
if r.Response != nil && r.Response.Type == RespAnswer {
foundAnswer = true
}
}
if !foundAnswer {
t.Error("expected an answer from the third (reachable) nameserver despite others failing")
}
}
+20 -3
View File
@@ -18,6 +18,12 @@ type TraverserConfig struct {
QueryConfig *dns.QueryConfig
RootAddrs []net.IP
Hooks *TraverserHooks
// Fast controls cache sharing across branches. When true (default), child
// branches inherit glue discovered by earlier branches via the shared root
// cache, trading accuracy for speed. When false, each branch gets a
// completely independent cache — slower but results are not contaminated by
// sibling branch observations.
Fast bool
}
func DefaultTraverserConfig() *TraverserConfig {
@@ -27,6 +33,7 @@ func DefaultTraverserConfig() *TraverserConfig {
RootConfig: nil,
QueryConfig: nil,
RootAddrs: nil,
Fast: true,
}
}
@@ -98,9 +105,19 @@ func (t *Traverser) Traverse(ctx context.Context, name string) ([]TraversalResul
break
}
cache := rootCache
if ref.Parent != nil {
cache = rootCache.Child()
var cache *InfoCache
if t.config.Fast {
// Fast mode: inherit glue from the shared root cache so earlier
// branch discoveries are visible to later branches.
cache = rootCache
if ref.Parent != nil {
cache = rootCache.Child()
}
} else {
// Non-fast mode: every referral gets its own independent cache so
// no cross-branch glue is reused, ensuring each path is resolved
// from scratch.
cache = NewInfoCache(nil)
}
if t.config.Hooks != nil {