From beefaf44e0252c09423e851d103008deb9a05553 Mon Sep 17 00:00:00 2001 From: Gary Hansen Date: Mon, 8 Jun 2026 15:43:47 +1000 Subject: [PATCH 1/2] output: distinguish nameserver resolution failures from domain errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an in-bailiwick nameserver cannot be resolved (no glue records and iterative resolution fails), the traversal previously returned a generic RespError. This made the Results section show many identical '6.7% resulted in an error' lines and the Summary show '100% resulted in an error' — even though the domain itself may be perfectly reachable in practice via glue held by the registry. Add a new RespNSResolutionFailed response type (string: ns_error) for this specific case. Changes: - traverse/response.go: add RespNSResolutionFailed, include it in IsTerminal() and String() - traverse/traverser.go: use RespNSResolutionFailed with a descriptive ErrorMessage when ResolveNS fails in processReferral - output/stats.go: map ns_error to nameserver lookup failed in summaryTypeLabel - output/text.go: handle RespNSResolutionFailed in formatResultLine Before: Results: 6.7% resulted in an error (x15) Summary: 100% resulted in an error After: Results: 6.7% nameserver ns-a.hansenits.com.au could not be resolved (x15) Summary: 100% nameserver lookup failed Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: multica-agent --- internal/output/stats.go | 2 ++ internal/output/stats_test.go | 1 + internal/output/text.go | 6 ++++++ internal/output/text_test.go | 1 + internal/traverse/response.go | 9 ++++++++- internal/traverse/response_test.go | 2 ++ internal/traverse/traverser.go | 19 +++++++++++++++---- 7 files changed, 35 insertions(+), 5 deletions(-) diff --git a/internal/output/stats.go b/internal/output/stats.go index a6b0456..b7c1aa2 100644 --- a/internal/output/stats.go +++ b/internal/output/stats.go @@ -144,6 +144,8 @@ func summaryTypeLabel(respType string) string { return "query type not implemented by server" case "cname_loop": return "resulted in a CNAME loop" + case "ns_error": + return "nameserver lookup failed" case "error": return "resulted in an error" case "referral": diff --git a/internal/output/stats_test.go b/internal/output/stats_test.go index 90f9875..25ca90a 100644 --- a/internal/output/stats_test.go +++ b/internal/output/stats_test.go @@ -88,6 +88,7 @@ func TestSummaryTypeLabelAllTypes(t *testing.T) { "refused": "query refused by server", "notimp": "query type not implemented by server", "cname_loop": "resulted in a CNAME loop", + "ns_error": "nameserver lookup failed", "error": "resulted in an error", "referral": "resulted in a referral", "unknown_type": "unknown_type", diff --git a/internal/output/text.go b/internal/output/text.go index dd43c0c..53b657a 100644 --- a/internal/output/text.go +++ b/internal/output/text.go @@ -220,6 +220,12 @@ func (f *textFormatter) formatResultLine(result traverse.TraversalResult) string msg = result.Response.ErrorMessage } return f.colorize(fmt.Sprintf("%s %s", prob, msg), colorRed) + case traverse.RespNSResolutionFailed: + msg := "nameserver lookup failed" + if result.Response.ErrorMessage != "" { + msg = result.Response.ErrorMessage + } + return f.colorize(fmt.Sprintf("%s %s", prob, msg), colorYellow) case traverse.RespError: msg := "resulted in an error" if result.Response.ErrorMessage != "" { diff --git a/internal/output/text_test.go b/internal/output/text_test.go index 6cc95e5..d760599 100644 --- a/internal/output/text_test.go +++ b/internal/output/text_test.go @@ -121,6 +121,7 @@ Answers: []miekgdns.RR{ {"refused", traverse.RespREFUSED, nil, ""}, {"notimp", traverse.RespNOTIMPL, nil, ""}, {"cname_loop", traverse.RespCNAMELoop, nil, "loop detected"}, +{"ns_error", traverse.RespNSResolutionFailed, nil, "nameserver ns1.example.com could not be resolved"}, {"error", traverse.RespError, nil, "something went wrong"}, } diff --git a/internal/traverse/response.go b/internal/traverse/response.go index 81620f4..6803777 100644 --- a/internal/traverse/response.go +++ b/internal/traverse/response.go @@ -20,6 +20,11 @@ const ( RespNOTIMPL RespCNAMELoop RespError + // RespNSResolutionFailed indicates that the traversal could not resolve the + // IP address of an in-bailiwick nameserver. The domain may still be + // reachable in practice (e.g. via glue records held by the registry), but + // the iterative traversal could not complete that path. + RespNSResolutionFailed ) func (rt ResponseType) String() string { @@ -44,6 +49,8 @@ func (rt ResponseType) String() string { return "cname_loop" case RespError: return "error" + case RespNSResolutionFailed: + return "ns_error" default: return "unknown" } @@ -231,7 +238,7 @@ func (r *Response) resolveGlue(child *Referral) { func (r *Response) IsTerminal() bool { switch r.Type { - case RespAnswer, RespNODATA, RespNXDOMAIN, RespSERVFAIL, RespREFUSED, RespNOTIMPL, RespCNAMELoop, RespError: + case RespAnswer, RespNODATA, RespNXDOMAIN, RespSERVFAIL, RespREFUSED, RespNOTIMPL, RespCNAMELoop, RespError, RespNSResolutionFailed: return true default: return false diff --git a/internal/traverse/response_test.go b/internal/traverse/response_test.go index efdcc69..a7bfac1 100644 --- a/internal/traverse/response_test.go +++ b/internal/traverse/response_test.go @@ -246,6 +246,7 @@ func TestResponseIsTerminal(t *testing.T) { {RespNXDOMAIN, true}, {RespSERVFAIL, true}, {RespError, true}, + {RespNSResolutionFailed, true}, {RespReferral, false}, {RespCNAMEFollow, false}, } @@ -272,6 +273,7 @@ func TestResponseTypeString(t *testing.T) { {RespNXDOMAIN, "nxdomain"}, {RespSERVFAIL, "servfail"}, {RespError, "error"}, + {RespNSResolutionFailed, "ns_error"}, } for _, tt := range tests { diff --git a/internal/traverse/traverser.go b/internal/traverse/traverser.go index b6baea2..aa6622e 100644 --- a/internal/traverse/traverser.go +++ b/internal/traverse/traverser.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net" + "strings" "sync" "time" @@ -232,18 +233,28 @@ func (t *Traverser) processReferral(ctx context.Context, ref *Referral, cache *I } else { addrs, err := t.ResolveNS(ctx, ref.Name, cache, visitedCopy, t.depth) if err != nil { + nsName := strings.TrimSuffix(ref.Bailiwick, ".") + if nsName == "" || nsName == "." { + nsName = strings.TrimSuffix(ref.Name, ".") + } return &Response{ - Referral: ref, - Type: RespError, + Referral: ref, + Type: RespNSResolutionFailed, + ErrorMessage: fmt.Sprintf("nameserver %s could not be resolved", nsName), } } if len(addrs) > 0 { ref.Addresses = addrs ref.State = StateResolved } else { + nsName := strings.TrimSuffix(ref.Bailiwick, ".") + if nsName == "" || nsName == "." { + nsName = strings.TrimSuffix(ref.Name, ".") + } return &Response{ - Referral: ref, - Type: RespError, + Referral: ref, + Type: RespNSResolutionFailed, + ErrorMessage: fmt.Sprintf("nameserver %s could not be resolved", nsName), } } } -- 2.54.0 From b48190584815424eb1943405262fcf66e31cc06c Mon Sep 17 00:00:00 2001 From: Gary Hansen Date: Mon, 8 Jun 2026 15:57:23 +1000 Subject: [PATCH 2/2] traverse: apply code review fixes in ResolveNS dispatch loop - Add RespNSResolutionFailed to the error-type guard in ResolveNS so nameserver resolution failures are treated consistently with SERVFAIL and RespError (skip + continue rather than fall through). - Deduplicate nsName extraction before the err/len branches so it is computed once instead of twice. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: multica-agent --- internal/traverse/traverser.go | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/internal/traverse/traverser.go b/internal/traverse/traverser.go index aa6622e..e139db1 100644 --- a/internal/traverse/traverser.go +++ b/internal/traverse/traverser.go @@ -232,11 +232,11 @@ func (t *Traverser) processReferral(ctx context.Context, ref *Referral, cache *I ref.State = StateResolved } else { addrs, err := t.ResolveNS(ctx, ref.Name, cache, visitedCopy, t.depth) + nsName := strings.TrimSuffix(ref.Bailiwick, ".") + if nsName == "" || nsName == "." { + nsName = strings.TrimSuffix(ref.Name, ".") + } if err != nil { - nsName := strings.TrimSuffix(ref.Bailiwick, ".") - if nsName == "" || nsName == "." { - nsName = strings.TrimSuffix(ref.Name, ".") - } return &Response{ Referral: ref, Type: RespNSResolutionFailed, @@ -247,10 +247,6 @@ func (t *Traverser) processReferral(ctx context.Context, ref *Referral, cache *I ref.Addresses = addrs ref.State = StateResolved } else { - nsName := strings.TrimSuffix(ref.Bailiwick, ".") - if nsName == "" || nsName == "." { - nsName = strings.TrimSuffix(ref.Name, ".") - } return &Response{ Referral: ref, Type: RespNSResolutionFailed, @@ -371,7 +367,7 @@ func (t *Traverser) ResolveNS(ctx context.Context, nsName string, cache *InfoCac break } - if resp.Type == RespSERVFAIL || resp.Type == RespError { + if resp.Type == RespSERVFAIL || resp.Type == RespError || resp.Type == RespNSResolutionFailed { lastErr = fmt.Errorf("server error resolving %s: %s", nsName, resp.Type) continue } -- 2.54.0