Files
ExploreDNS/internal/output/stats.go
T
368f200d23
CI / test (pull_request) Failing after 3m40s
Phase 4.1: Error handling, edge cases, and robustness
- Exponential backoff retry logic (100ms, 200ms, 400ms... capped at 2s)
  replacing fixed 100ms delay between retries
- Explicit REFUSED and NOTIMP response types (RespREFUSED, RespNOTIMPL)
  surfaced as terminal results with user-visible messages
- CNAME loop detection: walking the ancestor referral chain before
  following a CNAME prevents infinite recursion; produces RespCNAMELoop
- DNAME record support: synthesize CNAME target from DNAME mapping when
  the server omits the RFC 6672 synthesized CNAME record
- ErrorMessage field on Response for surfacing error details to users
- Fix resolveGlueViaSystem timeout bug: deadline.Sub(deadline) was
  always 0; replaced with time.Until(deadline)
- DNAME records excluded from hasFinalAnswer so DNAME-only responses
  are correctly classified as RespCNAMEFollow
- Text and JSON output updated with labels for all new response types
- Tests: CNAME loop (2-step and direct), REFUSED, NOTIMP, graceful
  degradation (partial and total server failure), DNAME synthesis,
  IsNameInChain, backoffDelay, ResponseClassification strings

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-08 03:11:46 +10:00

197 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 "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
}