Files
ExploreDNS/internal/output/stats.go
T
beefaf44e0 output: distinguish nameserver resolution failures from domain errors
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 <github@multica.ai>
2026-06-08 15:43:47 +10:00

199 lines
4.1 KiB
Go

package output
import (
"fmt"
"sort"
"strings"
"github.com/hits/ExploreDNS/internal/dns"
"github.com/hits/ExploreDNS/internal/traverse"
miekgdns "github.com/miekg/dns"
)
type summaryEntry struct {
Type string
Prob float64
}
type answerEntry struct {
RData string
Prob float64
RRs []string
}
type SummaryStats struct {
ByType map[string]float64
Answers []answerEntry
}
func ComputeSummary(results []traverse.TraversalResult) *SummaryStats {
stats := &SummaryStats{
ByType: make(map[string]float64),
}
for _, result := range results {
if result.Response == nil || !result.Response.IsTerminal() {
continue
}
if result.Referral == nil {
continue
}
prob := result.Referral.Prob
respType := result.Response.Type.String()
switch result.Response.Type {
case traverse.RespAnswer:
key, rrs := answerKey(result.Response)
if key == "" {
stats.ByType[respType] += prob
continue
}
found := false
for i := range stats.Answers {
if stats.Answers[i].RData == key {
stats.Answers[i].Prob += prob
found = true
break
}
}
if !found {
stats.Answers = append(stats.Answers, answerEntry{
RData: key,
Prob: prob,
RRs: rrs,
})
}
default:
stats.ByType[respType] += prob
}
}
sort.Slice(stats.Answers, func(i, j int) bool {
return stats.Answers[i].RData < stats.Answers[j].RData
})
if len(stats.Answers) == 0 && len(stats.ByType) == 0 {
return nil
}
return stats
}
func answerKey(resp *traverse.Response) (string, []string) {
if resp == nil || resp.Decoded == nil {
return "", nil
}
var rdatas []string
var formatted []string
for _, rr := range resp.Decoded.Answers {
if _, ok := rr.(*miekgdns.CNAME); ok {
continue
}
rdata := rrDataString(rr)
if rdata == "" {
continue
}
rdatas = append(rdatas, rdata)
formatted = append(formatted, dns.FormatRecord(rr))
}
if len(rdatas) == 0 {
return "", nil
}
sort.Strings(rdatas)
return strings.Join(rdatas, " / "), formatted
}
func rrDataString(rr miekgdns.RR) string {
switch v := rr.(type) {
case *miekgdns.A:
return v.A.String()
case *miekgdns.AAAA:
return v.AAAA.String()
case *miekgdns.CNAME:
return v.Target
case *miekgdns.NS:
return v.Ns
case *miekgdns.MX:
return fmt.Sprintf("%d %s", v.Preference, v.Mx)
case *miekgdns.TXT:
return strings.Join(v.Txt, " ")
default:
return rr.String()
}
}
func formatProbability(prob float64) string {
text := fmt.Sprintf("%.1f%%", prob*100)
text = strings.Replace(text, ".0%", "%", 1)
return fmt.Sprintf("%5s", text)
}
func summaryTypeLabel(respType string) string {
switch respType {
case "nodata":
return "found no such record"
case "nxdomain":
return "name does not exist"
case "servfail":
return "resulted in SERVFAIL"
case "refused":
return "query refused by server"
case "notimp":
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":
return "resulted in a referral"
default:
return respType
}
}
func trimDomain(name string) string {
return strings.TrimSuffix(name, ".")
}
func collectServers(results []traverse.TraversalResult) map[string][]string {
servers := make(map[string][]string)
for _, result := range results {
if result.Response == nil || result.Response.Server == nil {
continue
}
name := serverName(result)
ip := result.Response.Server.String()
if containsString(servers[name], ip) {
continue
}
servers[name] = append(servers[name], ip)
}
return servers
}
func serverName(result traverse.TraversalResult) string {
if result.Referral != nil && result.Referral.Bailiwick != "" && result.Referral.Bailiwick != "." {
return trimDomain(result.Referral.Bailiwick)
}
if result.Referral != nil && result.Referral.NSName != "" {
return result.Referral.NSName
}
if result.Response != nil && result.Response.Server != nil {
return result.Response.Server.String()
}
return "unknown"
}
func containsString(items []string, target string) bool {
for _, item := range items {
if item == target {
return true
}
}
return false
}