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
+23
-227
@@ -2,248 +2,44 @@ package output
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gitea.hansenits.com.au/hits/ExploreDNS/internal/dns"
|
||||
"gitea.hansenits.com.au/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()
|
||||
}
|
||||
}
|
||||
|
||||
// formatProbability renders txt_prob (summary_stats.rb): %5.1f%% with a
|
||||
// trailing ".0" trimmed, right-justified to width 5.
|
||||
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":
|
||||
// summaryStatusLabel returns the Summary Results wording per status
|
||||
// (summary_stats.rb text; answered is handled separately).
|
||||
func summaryStatusLabel(status traverse.Status) string {
|
||||
switch status {
|
||||
case traverse.StatusNoData:
|
||||
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":
|
||||
case traverse.StatusReferralLame:
|
||||
return "resulted in a lame referral"
|
||||
case traverse.StatusException:
|
||||
return "resulted in an exception"
|
||||
case traverse.StatusError:
|
||||
return "resulted in an error"
|
||||
case "referral":
|
||||
return "resulted in a referral"
|
||||
case traverse.StatusNoGlue:
|
||||
return "found no glue"
|
||||
case traverse.StatusLoop:
|
||||
return "resulted in a loop"
|
||||
case traverse.StatusCNAMELoop:
|
||||
return "resulted in a CNAME loop"
|
||||
default:
|
||||
return respType
|
||||
return string(status)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// DeduplicateResults collapses terminal results that represent the same
|
||||
// outcome from the same server into a single entry with summed probability.
|
||||
// This prevents the same nameserver failure (or answer) from appearing once
|
||||
// per delegation path when several parent servers all refer to the same child.
|
||||
func DeduplicateResults(results []traverse.TraversalResult) []traverse.TraversalResult {
|
||||
type entry struct {
|
||||
result traverse.TraversalResult
|
||||
prob float64
|
||||
}
|
||||
keys := make(map[string]*entry)
|
||||
var order []string
|
||||
|
||||
for _, r := range results {
|
||||
if r.Response == nil || r.Referral == nil {
|
||||
continue
|
||||
}
|
||||
key := resultDeduplicationKey(r)
|
||||
if e, ok := keys[key]; ok {
|
||||
e.prob += r.Referral.Prob
|
||||
} else {
|
||||
keys[key] = &entry{result: r, prob: r.Referral.Prob}
|
||||
order = append(order, key)
|
||||
}
|
||||
}
|
||||
|
||||
deduped := make([]traverse.TraversalResult, 0, len(order))
|
||||
for _, key := range order {
|
||||
e := keys[key]
|
||||
refCopy := *e.result.Referral
|
||||
refCopy.Prob = e.prob
|
||||
deduped = append(deduped, traverse.TraversalResult{
|
||||
Referral: &refCopy,
|
||||
Response: e.result.Response,
|
||||
})
|
||||
}
|
||||
return deduped
|
||||
}
|
||||
|
||||
func resultDeduplicationKey(r traverse.TraversalResult) string {
|
||||
bailiwick := strings.TrimSuffix(r.Referral.Bailiwick, ".")
|
||||
switch r.Response.Type {
|
||||
case traverse.RespAnswer:
|
||||
key, _ := answerKey(r.Response)
|
||||
return "answer:" + bailiwick + ":" + key
|
||||
case traverse.RespNSResolutionFailed:
|
||||
return "ns_error:" + r.Response.ErrorMessage
|
||||
default:
|
||||
return r.Response.Type.String() + ":" + bailiwick + ":" + r.Response.ErrorMessage
|
||||
}
|
||||
// collapseWhitespace renders an RR on one line with runs of whitespace
|
||||
// collapsed to single spaces (summary_stats.rb text).
|
||||
func collapseWhitespace(s string) string {
|
||||
return strings.Join(strings.Fields(s), " ")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user