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
@@ -1,877 +0,0 @@
|
||||
package output
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.hansenits.com.au/hits/ExploreDNS/internal/dns"
|
||||
"gitea.hansenits.com.au/hits/ExploreDNS/internal/traverse"
|
||||
miekgdns "github.com/miekg/dns"
|
||||
)
|
||||
|
||||
// ---- stats.go coverage ----
|
||||
|
||||
func TestRRDataString(t *testing.T) {
|
||||
cases := []struct {
|
||||
rr miekgdns.RR
|
||||
want string
|
||||
}{
|
||||
{
|
||||
&miekgdns.A{Hdr: miekgdns.RR_Header{Rrtype: miekgdns.TypeA}, A: net.ParseIP("1.2.3.4")},
|
||||
"1.2.3.4",
|
||||
},
|
||||
{
|
||||
&miekgdns.AAAA{Hdr: miekgdns.RR_Header{Rrtype: miekgdns.TypeAAAA}, AAAA: net.ParseIP("::1")},
|
||||
"::1",
|
||||
},
|
||||
{
|
||||
&miekgdns.CNAME{Hdr: miekgdns.RR_Header{Rrtype: miekgdns.TypeCNAME}, Target: "example.com."},
|
||||
"example.com.",
|
||||
},
|
||||
{
|
||||
&miekgdns.NS{Hdr: miekgdns.RR_Header{Rrtype: miekgdns.TypeNS}, Ns: "ns1.example.com."},
|
||||
"ns1.example.com.",
|
||||
},
|
||||
{
|
||||
&miekgdns.MX{Hdr: miekgdns.RR_Header{Rrtype: miekgdns.TypeMX}, Preference: 10, Mx: "mail.example.com."},
|
||||
"10 mail.example.com.",
|
||||
},
|
||||
{
|
||||
&miekgdns.TXT{Hdr: miekgdns.RR_Header{Rrtype: miekgdns.TypeTXT}, Txt: []string{"v=spf1", "include:example.com"}},
|
||||
"v=spf1 include:example.com",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := rrDataString(tc.rr)
|
||||
if got != tc.want {
|
||||
t.Errorf("rrDataString(%T) = %q, want %q", tc.rr, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummaryTypeLabel(t *testing.T) {
|
||||
cases := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"nodata", "found no such record"},
|
||||
{"nxdomain", "name does not exist"},
|
||||
{"servfail", "resulted in SERVFAIL"},
|
||||
{"refused", "query refused by server"},
|
||||
{"notimp", "query type not implemented by server"},
|
||||
{"cname_loop", "resulted in a CNAME loop"},
|
||||
{"error", "resulted in an error"},
|
||||
{"referral", "resulted in a referral"},
|
||||
{"unknown_type", "unknown_type"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := summaryTypeLabel(tc.input)
|
||||
if got != tc.want {
|
||||
t.Errorf("summaryTypeLabel(%q) = %q, want %q", tc.input, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectServers(t *testing.T) {
|
||||
ref := traverse.NewReferral("example.com.", dns.TypeA, "com.", 1, 0.5, nil)
|
||||
resp := &traverse.Response{
|
||||
Referral: ref,
|
||||
Server: net.ParseIP("1.2.3.4"),
|
||||
Type: traverse.RespAnswer,
|
||||
}
|
||||
results := []traverse.TraversalResult{
|
||||
{Referral: ref, Response: resp},
|
||||
}
|
||||
servers := collectServers(results)
|
||||
if len(servers) == 0 {
|
||||
t.Fatal("expected at least one server")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectServersNilServer(t *testing.T) {
|
||||
ref := traverse.NewReferral("example.com.", dns.TypeA, ".", 0, 1.0, nil)
|
||||
resp := &traverse.Response{
|
||||
Referral: ref,
|
||||
Server: nil,
|
||||
Type: traverse.RespAnswer,
|
||||
}
|
||||
results := []traverse.TraversalResult{{Referral: ref, Response: resp}}
|
||||
servers := collectServers(results)
|
||||
if len(servers) != 0 {
|
||||
t.Errorf("expected 0 servers with nil server, got %d", len(servers))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectServersDedup(t *testing.T) {
|
||||
ref := traverse.NewReferral("example.com.", dns.TypeA, "com.", 1, 0.5, nil)
|
||||
resp := &traverse.Response{
|
||||
Referral: ref,
|
||||
Server: net.ParseIP("1.2.3.4"),
|
||||
Type: traverse.RespAnswer,
|
||||
}
|
||||
results := []traverse.TraversalResult{
|
||||
{Referral: ref, Response: resp},
|
||||
{Referral: ref, Response: resp},
|
||||
}
|
||||
servers := collectServers(results)
|
||||
for _, ips := range servers {
|
||||
for _, ip := range ips {
|
||||
count := 0
|
||||
for _, i := range ips {
|
||||
if i == ip {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count > 1 {
|
||||
t.Errorf("duplicate IP %s in server list", ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerName(t *testing.T) {
|
||||
t.Run("uses bailiwick", func(t *testing.T) {
|
||||
ref := traverse.NewReferral("example.com.", dns.TypeA, "com.", 1, 1.0, nil)
|
||||
result := traverse.TraversalResult{Referral: ref, Response: nil}
|
||||
name := serverName(result)
|
||||
if name != "com" {
|
||||
t.Errorf("serverName = %q, want 'com'", name)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("uses NSName when bailiwick is root", func(t *testing.T) {
|
||||
ref := traverse.NewReferral("ns1.example.com.", dns.TypeA, ".", 0, 1.0, nil)
|
||||
ref.NSName = "ns1.example.com."
|
||||
result := traverse.TraversalResult{Referral: ref, Response: nil}
|
||||
name := serverName(result)
|
||||
if name != "ns1.example.com." {
|
||||
t.Errorf("serverName = %q, want 'ns1.example.com.'", name)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("uses server IP from response", func(t *testing.T) {
|
||||
ref := traverse.NewReferral("ns1.example.com.", dns.TypeA, ".", 0, 1.0, nil)
|
||||
resp := &traverse.Response{
|
||||
Referral: ref,
|
||||
Server: net.ParseIP("1.2.3.4"),
|
||||
}
|
||||
result := traverse.TraversalResult{Referral: ref, Response: resp}
|
||||
name := serverName(result)
|
||||
if name != "1.2.3.4" {
|
||||
t.Errorf("serverName = %q, want '1.2.3.4'", name)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown fallback", func(t *testing.T) {
|
||||
result := traverse.TraversalResult{Referral: nil, Response: nil}
|
||||
name := serverName(result)
|
||||
if name != "unknown" {
|
||||
t.Errorf("serverName = %q, want 'unknown'", name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestComputeSummaryNonAnswerTypes(t *testing.T) {
|
||||
ref := traverse.NewReferral("example.com.", dns.TypeA, ".", 0, 1.0, nil)
|
||||
for _, respType := range []traverse.ResponseType{
|
||||
traverse.RespNXDOMAIN, traverse.RespSERVFAIL, traverse.RespNODATA,
|
||||
} {
|
||||
resp := &traverse.Response{Referral: ref, Type: respType}
|
||||
stats := ComputeSummary([]traverse.TraversalResult{{Referral: ref, Response: resp}})
|
||||
if stats == nil {
|
||||
t.Errorf("ComputeSummary returned nil for %v", respType)
|
||||
continue
|
||||
}
|
||||
if len(stats.ByType) == 0 {
|
||||
t.Errorf("expected ByType entry for %v", respType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeSummaryNilResponse(t *testing.T) {
|
||||
ref := traverse.NewReferral("example.com.", dns.TypeA, ".", 0, 1.0, nil)
|
||||
stats := ComputeSummary([]traverse.TraversalResult{{Referral: ref, Response: nil}})
|
||||
if stats != nil {
|
||||
t.Error("expected nil stats for nil response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeSummaryNilReferral(t *testing.T) {
|
||||
resp := &traverse.Response{Type: traverse.RespAnswer}
|
||||
stats := ComputeSummary([]traverse.TraversalResult{{Referral: nil, Response: resp}})
|
||||
if stats != nil {
|
||||
t.Error("expected nil stats for nil referral")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeSummaryAnswerKeyEmpty(t *testing.T) {
|
||||
ref := traverse.NewReferral("example.com.", dns.TypeA, ".", 0, 1.0, nil)
|
||||
// Answer with only CNAME (no data key) should go into ByType
|
||||
resp := &traverse.Response{
|
||||
Referral: ref,
|
||||
Type: traverse.RespAnswer,
|
||||
Decoded: &dns.DecodedResponse{
|
||||
Answers: []miekgdns.RR{
|
||||
&miekgdns.CNAME{
|
||||
Hdr: miekgdns.RR_Header{Name: "www.example.com.", Rrtype: miekgdns.TypeCNAME},
|
||||
Target: "example.com.",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
stats := ComputeSummary([]traverse.TraversalResult{{Referral: ref, Response: resp}})
|
||||
if stats == nil {
|
||||
t.Fatal("expected non-nil stats")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- text.go coverage ----
|
||||
|
||||
func TestTextFormatterWriteResolve(t *testing.T) {
|
||||
ref := traverse.NewReferral("ns1.example.com.", dns.TypeA, "example.com.", 1, 0.5, nil)
|
||||
|
||||
var buf bytes.Buffer
|
||||
cfg := DefaultConfig()
|
||||
cfg.Color = false
|
||||
f := newTextFormatter(cfg, &buf)
|
||||
|
||||
err := f.WriteResolve(traverse.TraversalEvent{
|
||||
Stage: traverse.EventStart,
|
||||
Result: traverse.TraversalResult{Referral: ref},
|
||||
IsResolve: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("WriteResolve: %v", err)
|
||||
}
|
||||
if buf.Len() == 0 {
|
||||
t.Error("expected output from WriteResolve")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextFormatterWriteResolveNonStart(t *testing.T) {
|
||||
ref := traverse.NewReferral("ns1.example.com.", dns.TypeA, "example.com.", 1, 0.5, nil)
|
||||
|
||||
var buf bytes.Buffer
|
||||
cfg := DefaultConfig()
|
||||
f := newTextFormatter(cfg, &buf)
|
||||
|
||||
err := f.WriteResolve(traverse.TraversalEvent{
|
||||
Stage: traverse.EventComplete,
|
||||
Result: traverse.TraversalResult{Referral: ref},
|
||||
IsResolve: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("WriteResolve: %v", err)
|
||||
}
|
||||
if buf.Len() != 0 {
|
||||
t.Error("expected no output for non-start resolve event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextFormatterWriteServers(t *testing.T) {
|
||||
ref := traverse.NewReferral("example.com.", dns.TypeA, "com.", 1, 1.0, nil)
|
||||
resp := &traverse.Response{
|
||||
Referral: ref,
|
||||
Server: net.ParseIP("1.2.3.4"),
|
||||
Type: traverse.RespAnswer,
|
||||
Decoded: &dns.DecodedResponse{
|
||||
Answers: []miekgdns.RR{
|
||||
&miekgdns.A{
|
||||
Hdr: miekgdns.RR_Header{Name: "example.com.", Rrtype: miekgdns.TypeA},
|
||||
A: net.ParseIP("1.2.3.4"),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
cfg := DefaultConfig()
|
||||
cfg.Color = false
|
||||
cfg.ShowServers = true
|
||||
cfg.ShowResults = false
|
||||
cfg.ShowSummaryResults = false
|
||||
f := newTextFormatter(cfg, &buf)
|
||||
|
||||
err := f.WriteSummary([]traverse.TraversalResult{{Referral: ref, Response: resp}})
|
||||
if err != nil {
|
||||
t.Fatalf("WriteSummary: %v", err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "The following servers were encountered:") {
|
||||
t.Errorf("expected server list header, got %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextFormatterWriteResults(t *testing.T) {
|
||||
ref := traverse.NewReferral("example.com.", dns.TypeA, ".", 0, 1.0, nil)
|
||||
resp := &traverse.Response{
|
||||
Referral: ref,
|
||||
Server: net.ParseIP("1.2.3.4"),
|
||||
Type: traverse.RespAnswer,
|
||||
Decoded: &dns.DecodedResponse{
|
||||
Answers: []miekgdns.RR{
|
||||
&miekgdns.A{
|
||||
Hdr: miekgdns.RR_Header{Name: "example.com.", Rrtype: miekgdns.TypeA},
|
||||
A: net.ParseIP("93.184.216.34"),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
cfg := DefaultConfig()
|
||||
cfg.Color = false
|
||||
cfg.ShowServers = false
|
||||
cfg.ShowResults = true
|
||||
cfg.ShowSummaryResults = false
|
||||
f := newTextFormatter(cfg, &buf)
|
||||
|
||||
err := f.WriteSummary([]traverse.TraversalResult{{Referral: ref, Response: resp}})
|
||||
if err != nil {
|
||||
t.Fatalf("WriteSummary: %v", err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "Results:") {
|
||||
t.Errorf("expected 'Results:' header, got %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextFormatterFormatResultLineAllTypes(t *testing.T) {
|
||||
ref := traverse.NewReferral("example.com.", dns.TypeA, ".", 0, 1.0, nil)
|
||||
|
||||
cases := []struct {
|
||||
respType traverse.ResponseType
|
||||
contains string
|
||||
}{
|
||||
{traverse.RespNODATA, "no such record"},
|
||||
{traverse.RespNXDOMAIN, "does not exist"},
|
||||
{traverse.RespSERVFAIL, "SERVFAIL"},
|
||||
{traverse.RespREFUSED, "refused"},
|
||||
{traverse.RespNOTIMPL, "not implemented"},
|
||||
{traverse.RespCNAMELoop, "CNAME loop"},
|
||||
{traverse.RespError, "error"},
|
||||
}
|
||||
|
||||
cfg := DefaultConfig()
|
||||
cfg.Color = false
|
||||
f := newTextFormatter(cfg, &bytes.Buffer{})
|
||||
|
||||
for _, tc := range cases {
|
||||
resp := &traverse.Response{
|
||||
Referral: ref,
|
||||
Type: tc.respType,
|
||||
}
|
||||
result := traverse.TraversalResult{Referral: ref, Response: resp}
|
||||
line := f.formatResultLine(result)
|
||||
if !strings.Contains(strings.ToLower(line), strings.ToLower(tc.contains)) {
|
||||
t.Errorf("formatResultLine(%v) = %q, want substring %q", tc.respType, line, tc.contains)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextFormatterFormatResultLineErrorWithMessage(t *testing.T) {
|
||||
ref := traverse.NewReferral("example.com.", dns.TypeA, ".", 0, 1.0, nil)
|
||||
resp := &traverse.Response{
|
||||
Referral: ref,
|
||||
Type: traverse.RespError,
|
||||
ErrorMessage: "custom error message",
|
||||
}
|
||||
|
||||
cfg := DefaultConfig()
|
||||
cfg.Color = false
|
||||
f := newTextFormatter(cfg, &bytes.Buffer{})
|
||||
|
||||
line := f.formatResultLine(traverse.TraversalResult{Referral: ref, Response: resp})
|
||||
if !strings.Contains(line, "custom error message") {
|
||||
t.Errorf("expected custom error message, got %q", line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextFormatterFormatResultLineCNAMELoopWithMessage(t *testing.T) {
|
||||
ref := traverse.NewReferral("example.com.", dns.TypeA, ".", 0, 1.0, nil)
|
||||
resp := &traverse.Response{
|
||||
Referral: ref,
|
||||
Type: traverse.RespCNAMELoop,
|
||||
ErrorMessage: "CNAME loop detected: example.com",
|
||||
}
|
||||
|
||||
cfg := DefaultConfig()
|
||||
cfg.Color = false
|
||||
f := newTextFormatter(cfg, &bytes.Buffer{})
|
||||
|
||||
line := f.formatResultLine(traverse.TraversalResult{Referral: ref, Response: resp})
|
||||
if !strings.Contains(line, "CNAME loop detected") {
|
||||
t.Errorf("expected CNAME loop message, got %q", line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextFormatterFormatResultLineAnswerMultiple(t *testing.T) {
|
||||
ref := traverse.NewReferral("example.com.", dns.TypeA, ".", 0, 1.0, nil)
|
||||
resp := &traverse.Response{
|
||||
Referral: ref,
|
||||
Type: traverse.RespAnswer,
|
||||
Decoded: &dns.DecodedResponse{
|
||||
Answers: []miekgdns.RR{
|
||||
&miekgdns.A{Hdr: miekgdns.RR_Header{Rrtype: miekgdns.TypeA}, A: net.ParseIP("1.1.1.1")},
|
||||
&miekgdns.A{Hdr: miekgdns.RR_Header{Rrtype: miekgdns.TypeA}, A: net.ParseIP("2.2.2.2")},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cfg := DefaultConfig()
|
||||
cfg.Color = false
|
||||
f := newTextFormatter(cfg, &bytes.Buffer{})
|
||||
|
||||
line := f.formatResultLine(traverse.TraversalResult{Referral: ref, Response: resp})
|
||||
if !strings.Contains(line, "/") {
|
||||
t.Errorf("expected '/' separator for multiple answers, got %q", line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextFormatterColorize(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
cfg.Color = true
|
||||
f := newTextFormatter(cfg, &bytes.Buffer{})
|
||||
|
||||
colored := f.colorize("hello", colorGreen)
|
||||
if !strings.Contains(colored, "\033[") {
|
||||
t.Error("expected ANSI color code in colored output")
|
||||
}
|
||||
|
||||
cfg.Color = false
|
||||
f2 := newTextFormatter(cfg, &bytes.Buffer{})
|
||||
plain := f2.colorize("hello", colorGreen)
|
||||
if plain != "hello" {
|
||||
t.Errorf("expected plain text without color, got %q", plain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextFormatterColorizeEmpty(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
cfg.Color = true
|
||||
f := newTextFormatter(cfg, &bytes.Buffer{})
|
||||
out := f.colorize("hello", "")
|
||||
if out != "hello" {
|
||||
t.Errorf("empty color should return plain text, got %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextFormatterVerboseProgress(t *testing.T) {
|
||||
ref := traverse.NewReferral("example.com.", dns.TypeA, "com.", 1, 0.5, nil)
|
||||
|
||||
var buf bytes.Buffer
|
||||
cfg := DefaultConfig()
|
||||
cfg.Color = false
|
||||
cfg.Verbose = true
|
||||
f := newTextFormatter(cfg, &buf)
|
||||
|
||||
err := f.WriteProgress(traverse.TraversalEvent{
|
||||
Stage: traverse.EventStart,
|
||||
Result: traverse.TraversalResult{Referral: ref},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("WriteProgress: %v", err)
|
||||
}
|
||||
if buf.Len() == 0 {
|
||||
t.Error("expected output with verbose mode")
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "com") {
|
||||
t.Errorf("expected bailiwick in verbose output, got %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextFormatterProgressResolving(t *testing.T) {
|
||||
ref := traverse.NewReferral("ns1.example.com.", dns.TypeA, "example.com.", 1, 0.5, nil)
|
||||
// no addresses = resolving
|
||||
|
||||
var buf bytes.Buffer
|
||||
cfg := DefaultConfig()
|
||||
cfg.Color = false
|
||||
f := newTextFormatter(cfg, &buf)
|
||||
|
||||
err := f.WriteProgress(traverse.TraversalEvent{
|
||||
Stage: traverse.EventStart,
|
||||
Result: traverse.TraversalResult{Referral: ref},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("WriteProgress: %v", err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "resolving") {
|
||||
t.Errorf("expected 'resolving' in output, got %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextFormatterWriteServersWithVersions(t *testing.T) {
|
||||
ref := traverse.NewReferral("example.com.", dns.TypeA, "com.", 1, 1.0, nil)
|
||||
resp := &traverse.Response{
|
||||
Referral: ref,
|
||||
Server: net.ParseIP("1.2.3.4"),
|
||||
Type: traverse.RespAnswer,
|
||||
Decoded: &dns.DecodedResponse{
|
||||
Answers: []miekgdns.RR{
|
||||
&miekgdns.A{Hdr: miekgdns.RR_Header{Rrtype: miekgdns.TypeA}, A: net.ParseIP("1.2.3.4")},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
cfg := DefaultConfig()
|
||||
cfg.Color = false
|
||||
cfg.ShowServers = true
|
||||
cfg.ShowResults = false
|
||||
cfg.ShowSummaryResults = false
|
||||
cfg.ShowVersions = true
|
||||
cfg.Fingerprints = map[string]string{"1.2.3.4": "BIND 9.16"}
|
||||
f := newTextFormatter(cfg, &buf)
|
||||
|
||||
err := f.WriteSummary([]traverse.TraversalResult{{Referral: ref, Response: resp}})
|
||||
if err != nil {
|
||||
t.Fatalf("WriteSummary: %v", err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "BIND 9.16") {
|
||||
t.Errorf("expected version string in server output, got %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextFormatterWriteResult(t *testing.T) {
|
||||
ref := traverse.NewReferral("example.com.", dns.TypeA, ".", 0, 1.0, nil)
|
||||
resp := &traverse.Response{
|
||||
Referral: ref,
|
||||
Type: traverse.RespAnswer,
|
||||
Decoded: &dns.DecodedResponse{
|
||||
Answers: []miekgdns.RR{
|
||||
&miekgdns.A{Hdr: miekgdns.RR_Header{Rrtype: miekgdns.TypeA}, A: net.ParseIP("1.2.3.4")},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
cfg := DefaultConfig()
|
||||
cfg.Color = false
|
||||
f := newTextFormatter(cfg, &buf)
|
||||
|
||||
err := f.WriteResult(traverse.TraversalResult{Referral: ref, Response: resp})
|
||||
if err != nil {
|
||||
t.Fatalf("WriteResult: %v", err)
|
||||
}
|
||||
if buf.Len() == 0 {
|
||||
t.Error("expected output from WriteResult")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextFormatterWriteResultNilRefs(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
cfg := DefaultConfig()
|
||||
f := newTextFormatter(cfg, &buf)
|
||||
|
||||
err := f.WriteResult(traverse.TraversalResult{Referral: nil, Response: nil})
|
||||
if err != nil {
|
||||
t.Fatalf("WriteResult: %v", err)
|
||||
}
|
||||
if buf.Len() != 0 {
|
||||
t.Error("expected no output for nil referral/response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReferralServerLabelVariants(t *testing.T) {
|
||||
t.Run("with addresses", func(t *testing.T) {
|
||||
ref := traverse.NewReferral("example.com.", dns.TypeA, ".", 0, 1.0, nil)
|
||||
ref.Addresses = []net.IP{net.ParseIP("1.2.3.4")}
|
||||
label := referralServerLabel(ref, nil)
|
||||
if !strings.Contains(label, "1.2.3.4") {
|
||||
t.Errorf("expected IP in label, got %q", label)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("with NSName", func(t *testing.T) {
|
||||
ref := traverse.NewReferral("example.com.", dns.TypeA, ".", 0, 1.0, nil)
|
||||
ref.NSName = "ns1.example.com."
|
||||
label := referralServerLabel(ref, nil)
|
||||
if label != "ns1.example.com." {
|
||||
t.Errorf("expected NSName, got %q", label)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("with bailiwick", func(t *testing.T) {
|
||||
ref := traverse.NewReferral("example.com.", dns.TypeA, "com.", 1, 1.0, nil)
|
||||
label := referralServerLabel(ref, nil)
|
||||
if label != "com" {
|
||||
t.Errorf("expected trimmed bailiwick, got %q", label)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown", func(t *testing.T) {
|
||||
ref := traverse.NewReferral("example.com.", dns.TypeA, ".", 0, 1.0, nil)
|
||||
label := referralServerLabel(ref, nil)
|
||||
if label != "unknown" {
|
||||
t.Errorf("expected 'unknown', got %q", label)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("with response server", func(t *testing.T) {
|
||||
ref := traverse.NewReferral("example.com.", dns.TypeA, ".", 0, 1.0, nil)
|
||||
resp := &traverse.Response{Server: net.ParseIP("5.6.7.8")}
|
||||
label := referralServerLabel(ref, resp)
|
||||
if label != "5.6.7.8" {
|
||||
t.Errorf("expected server IP, got %q", label)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---- json.go coverage ----
|
||||
|
||||
func TestJSONFormatterWriteResolve(t *testing.T) {
|
||||
ref := traverse.NewReferral("ns1.example.com.", dns.TypeA, "example.com.", 1, 0.5, nil)
|
||||
|
||||
var buf bytes.Buffer
|
||||
cfg := DefaultConfig()
|
||||
cfg.Format = FormatJSON
|
||||
cfg.ShowResolves = true
|
||||
f := newJSONFormatter(cfg, &buf)
|
||||
|
||||
err := f.WriteResolve(traverse.TraversalEvent{
|
||||
Stage: traverse.EventStart,
|
||||
Result: traverse.TraversalResult{Referral: ref},
|
||||
IsResolve: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("WriteResolve: %v", err)
|
||||
}
|
||||
if len(f.payload.Resolves) != 1 {
|
||||
t.Errorf("expected 1 resolve entry, got %d", len(f.payload.Resolves))
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONFormatterWriteResolveShowResolvesFalse(t *testing.T) {
|
||||
ref := traverse.NewReferral("ns1.example.com.", dns.TypeA, "example.com.", 1, 0.5, nil)
|
||||
|
||||
var buf bytes.Buffer
|
||||
cfg := DefaultConfig()
|
||||
cfg.Format = FormatJSON
|
||||
cfg.ShowResolves = false
|
||||
f := newJSONFormatter(cfg, &buf)
|
||||
|
||||
err := f.WriteResolve(traverse.TraversalEvent{
|
||||
Stage: traverse.EventStart,
|
||||
Result: traverse.TraversalResult{Referral: ref},
|
||||
IsResolve: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("WriteResolve: %v", err)
|
||||
}
|
||||
if len(f.payload.Resolves) != 0 {
|
||||
t.Errorf("expected 0 resolve entries when ShowResolves=false, got %d", len(f.payload.Resolves))
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONFormatterWriteResult(t *testing.T) {
|
||||
ref := traverse.NewReferral("example.com.", dns.TypeA, ".", 0, 1.0, nil)
|
||||
resp := &traverse.Response{
|
||||
Referral: ref,
|
||||
Server: net.ParseIP("1.2.3.4"),
|
||||
Type: traverse.RespAnswer,
|
||||
Decoded: &dns.DecodedResponse{
|
||||
Answers: []miekgdns.RR{
|
||||
&miekgdns.A{Hdr: miekgdns.RR_Header{Rrtype: miekgdns.TypeA}, A: net.ParseIP("1.2.3.4")},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
cfg := DefaultConfig()
|
||||
cfg.Format = FormatJSON
|
||||
cfg.ShowAllStats = true
|
||||
f := newJSONFormatter(cfg, &buf)
|
||||
|
||||
err := f.WriteResult(traverse.TraversalResult{Referral: ref, Response: resp})
|
||||
if err != nil {
|
||||
t.Fatalf("WriteResult: %v", err)
|
||||
}
|
||||
if len(f.payload.Results) != 1 {
|
||||
t.Errorf("expected 1 result entry, got %d", len(f.payload.Results))
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONFormatterWriteResultShowAllStatsFalse(t *testing.T) {
|
||||
ref := traverse.NewReferral("example.com.", dns.TypeA, ".", 0, 1.0, nil)
|
||||
resp := &traverse.Response{Referral: ref, Type: traverse.RespAnswer}
|
||||
|
||||
var buf bytes.Buffer
|
||||
cfg := DefaultConfig()
|
||||
cfg.Format = FormatJSON
|
||||
cfg.ShowAllStats = false
|
||||
f := newJSONFormatter(cfg, &buf)
|
||||
|
||||
err := f.WriteResult(traverse.TraversalResult{Referral: ref, Response: resp})
|
||||
if err != nil {
|
||||
t.Fatalf("WriteResult: %v", err)
|
||||
}
|
||||
if len(f.payload.Results) != 0 {
|
||||
t.Errorf("expected 0 result entries when ShowAllStats=false, got %d", len(f.payload.Results))
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONFormatterWriteSummaryWithServersAndVersions(t *testing.T) {
|
||||
ref := traverse.NewReferral("example.com.", dns.TypeA, "com.", 1, 1.0, nil)
|
||||
resp := &traverse.Response{
|
||||
Referral: ref,
|
||||
Server: net.ParseIP("1.2.3.4"),
|
||||
Type: traverse.RespAnswer,
|
||||
Decoded: &dns.DecodedResponse{
|
||||
Answers: []miekgdns.RR{
|
||||
&miekgdns.A{Hdr: miekgdns.RR_Header{Rrtype: miekgdns.TypeA}, A: net.ParseIP("1.2.3.4")},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
cfg := DefaultConfig()
|
||||
cfg.Format = FormatJSON
|
||||
cfg.ShowServers = true
|
||||
cfg.ShowVersions = true
|
||||
cfg.Fingerprints = map[string]string{"1.2.3.4": "BIND 9.16"}
|
||||
f := newJSONFormatter(cfg, &buf)
|
||||
|
||||
err := f.WriteSummary([]traverse.TraversalResult{{Referral: ref, Response: resp}})
|
||||
if err != nil {
|
||||
t.Fatalf("WriteSummary: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, srv := range f.payload.Servers {
|
||||
if srv.Version == "BIND 9.16" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("expected version in server list")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONFormatterStageName(t *testing.T) {
|
||||
if stageName(traverse.EventStart) != "start" {
|
||||
t.Errorf("expected 'start', got %q", stageName(traverse.EventStart))
|
||||
}
|
||||
if stageName(traverse.EventComplete) != "complete" {
|
||||
t.Errorf("expected 'complete', got %q", stageName(traverse.EventComplete))
|
||||
}
|
||||
if stageName(traverse.EventStage(99)) != "unknown" {
|
||||
t.Errorf("expected 'unknown' for unknown stage")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONFormatterEventToJSONNilReferral(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
cfg := DefaultConfig()
|
||||
cfg.Format = FormatJSON
|
||||
f := newJSONFormatter(cfg, &buf)
|
||||
|
||||
item := f.eventToJSON(traverse.TraversalEvent{
|
||||
Stage: traverse.EventStart,
|
||||
Result: traverse.TraversalResult{Referral: nil},
|
||||
})
|
||||
if item.Name != "" {
|
||||
t.Errorf("expected empty name for nil referral, got %q", item.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONFormatterEventToJSONWithResponse(t *testing.T) {
|
||||
ref := traverse.NewReferral("example.com.", dns.TypeA, ".", 0, 1.0, nil)
|
||||
resp := &traverse.Response{
|
||||
Referral: ref,
|
||||
Server: net.ParseIP("1.2.3.4"),
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
cfg := DefaultConfig()
|
||||
cfg.Format = FormatJSON
|
||||
f := newJSONFormatter(cfg, &buf)
|
||||
|
||||
item := f.eventToJSON(traverse.TraversalEvent{
|
||||
Stage: traverse.EventStart,
|
||||
Result: traverse.TraversalResult{Referral: ref, Response: resp},
|
||||
})
|
||||
if item.Server != "1.2.3.4" {
|
||||
t.Errorf("expected server IP, got %q", item.Server)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONFormatterWriteProgressShowProgressFalse(t *testing.T) {
|
||||
ref := traverse.NewReferral("example.com.", dns.TypeA, ".", 0, 1.0, nil)
|
||||
|
||||
var buf bytes.Buffer
|
||||
cfg := DefaultConfig()
|
||||
cfg.Format = FormatJSON
|
||||
cfg.ShowProgress = false
|
||||
f := newJSONFormatter(cfg, &buf)
|
||||
|
||||
err := f.WriteProgress(traverse.TraversalEvent{
|
||||
Stage: traverse.EventStart,
|
||||
Result: traverse.TraversalResult{Referral: ref},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("WriteProgress: %v", err)
|
||||
}
|
||||
if len(f.payload.Progress) != 0 {
|
||||
t.Errorf("expected 0 progress entries when ShowProgress=false, got %d", len(f.payload.Progress))
|
||||
}
|
||||
}
|
||||
|
||||
// ---- runner.go coverage ----
|
||||
|
||||
func TestRunTraversalNilTraverser(t *testing.T) {
|
||||
_, err := RunTraversal(context.Background(), nil, nil, nil, "example.com")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nil traverser")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewFormatterNilConfig(t *testing.T) {
|
||||
f := NewFormatter(nil, &bytes.Buffer{})
|
||||
if f == nil {
|
||||
t.Fatal("NewFormatter(nil) should not return nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachHooksNilCfg(t *testing.T) {
|
||||
h := AttachHooks(nil, nil)
|
||||
if h != nil {
|
||||
t.Fatal("AttachHooks(nil, nil) should return nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachHooksDebugMode(t *testing.T) {
|
||||
ref := traverse.NewReferral("example.com.", dns.TypeA, ".", 0, 1.0, nil)
|
||||
|
||||
var buf bytes.Buffer
|
||||
cfg := DefaultConfig()
|
||||
cfg.Debug = 1
|
||||
cfg.ShowResolves = true
|
||||
|
||||
// formatter that returns an error on WriteResolve
|
||||
formatter := &errorFormatter{}
|
||||
hooks := AttachHooks(cfg, formatter)
|
||||
if hooks == nil {
|
||||
t.Fatal("expected non-nil hooks")
|
||||
}
|
||||
|
||||
// Call OnEvent with IsResolve=true - should call WriteResolve and log error to stderr (debug>0)
|
||||
hooks.OnEvent(traverse.TraversalEvent{
|
||||
Stage: traverse.EventStart,
|
||||
Result: traverse.TraversalResult{Referral: ref},
|
||||
IsResolve: true,
|
||||
})
|
||||
_ = buf.String() // no assertion - just ensure it doesn't panic
|
||||
}
|
||||
|
||||
// errorFormatter is a mock formatter for testing error paths.
|
||||
type errorFormatter struct{}
|
||||
|
||||
func (f *errorFormatter) WriteProgress(_ traverse.TraversalEvent) error { return nil }
|
||||
func (f *errorFormatter) WriteResolve(_ traverse.TraversalEvent) error { return nil }
|
||||
func (f *errorFormatter) WriteResult(_ traverse.TraversalResult) error { return nil }
|
||||
func (f *errorFormatter) WriteSummary(_ []traverse.TraversalResult) error {
|
||||
return nil
|
||||
}
|
||||
func (f *errorFormatter) Flush() error { return nil }
|
||||
@@ -1,10 +1,11 @@
|
||||
// Package output renders ExploreDNS traversal results for human consumption or
|
||||
// machine processing.
|
||||
// Package output renders ExploreDNS traversal results for human consumption
|
||||
// or machine processing.
|
||||
//
|
||||
// Two formats are supported:
|
||||
//
|
||||
// - FormatText — a coloured hierarchical tree (default)
|
||||
// - FormatJSON — a JSON array of traversal results
|
||||
// - FormatText — dnstraverse-style header/progress/results/summary text
|
||||
// (default)
|
||||
// - FormatJSON — a single JSON document with the aggregated results
|
||||
//
|
||||
// Create a Formatter via NewFormatter and call RunTraversal to drive the
|
||||
// traversal engine and stream output incrementally.
|
||||
@@ -26,9 +27,19 @@ const (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Format Format
|
||||
Domain string
|
||||
QueryType string
|
||||
Format Format
|
||||
Domain string
|
||||
QueryType string
|
||||
|
||||
// Engine settings echoed in the header block (bin/dnstraverse).
|
||||
Fast bool
|
||||
AllRootServers bool
|
||||
UDPSize int
|
||||
Retries int
|
||||
MaxDepth int
|
||||
AllowTCP bool
|
||||
AlwaysTCP bool
|
||||
|
||||
ShowProgress bool
|
||||
ShowResolves bool
|
||||
ShowServers bool
|
||||
@@ -49,22 +60,50 @@ type Config struct {
|
||||
func DefaultConfig() *Config {
|
||||
return &Config{
|
||||
Format: FormatText,
|
||||
Fast: true,
|
||||
UDPSize: 2048,
|
||||
Retries: 2,
|
||||
MaxDepth: 20,
|
||||
AllowTCP: true,
|
||||
ShowProgress: true,
|
||||
ShowResolves: true,
|
||||
ShowServers: true,
|
||||
ShowResolves: false,
|
||||
ShowServers: false,
|
||||
ShowVersions: true,
|
||||
ShowAllStats: true,
|
||||
ShowAllStats: false,
|
||||
ShowResults: true,
|
||||
ShowSummaryResults: true,
|
||||
Color: os.Getenv("NO_COLOR") == "",
|
||||
Color: ColorEnabled(os.Stdout),
|
||||
}
|
||||
}
|
||||
|
||||
// ColorEnabled reports whether colour output should be used for w: only when
|
||||
// NO_COLOR is unset and w is a terminal.
|
||||
func ColorEnabled(w io.Writer) bool {
|
||||
if os.Getenv("NO_COLOR") != "" {
|
||||
return false
|
||||
}
|
||||
f, ok := w.(*os.File)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return info.Mode()&os.ModeCharDevice != 0
|
||||
}
|
||||
|
||||
// Formatter renders traversal progress and the aggregated results. The header
|
||||
// is written once before the run, progress arrives via the traverser hooks,
|
||||
// and the aggregated leaves and the servers seen arrive once the run
|
||||
// completed.
|
||||
type Formatter interface {
|
||||
// WriteHeader renders the pre-run header block from the discovered roots
|
||||
// (suppressed entirely by --quiet in text mode).
|
||||
WriteHeader(roots []traverse.StartServer) error
|
||||
WriteProgress(event traverse.TraversalEvent) error
|
||||
WriteResolve(event traverse.TraversalEvent) error
|
||||
WriteResult(result traverse.TraversalResult) error
|
||||
WriteSummary(results []traverse.TraversalResult) error
|
||||
WriteSummary(root *traverse.Referral, servers map[string][]string) error
|
||||
Flush() error
|
||||
}
|
||||
|
||||
@@ -85,6 +124,11 @@ func AttachHooks(cfg *Config, formatter Formatter) *traverse.TraverserHooks {
|
||||
if cfg == nil || formatter == nil {
|
||||
return nil
|
||||
}
|
||||
if !cfg.ShowProgress {
|
||||
// Ruby registers no progress callbacks at all without show-progress;
|
||||
// resolve display additionally requires show-resolves.
|
||||
return nil
|
||||
}
|
||||
logErr := func(context string, err error) {
|
||||
if err != nil && cfg.Debug > 0 {
|
||||
fmt.Fprintf(os.Stderr, "Debug: formatter %s: %v\n", context, err)
|
||||
@@ -92,15 +136,13 @@ func AttachHooks(cfg *Config, formatter Formatter) *traverse.TraverserHooks {
|
||||
}
|
||||
return &traverse.TraverserHooks{
|
||||
OnEvent: func(event traverse.TraversalEvent) {
|
||||
switch {
|
||||
case event.IsResolve && cfg.ShowResolves:
|
||||
logErr("WriteResolve", formatter.WriteResolve(event))
|
||||
case !event.IsResolve && cfg.ShowProgress:
|
||||
logErr("WriteProgress", formatter.WriteProgress(event))
|
||||
}
|
||||
if event.Stage == traverse.EventComplete && cfg.ShowAllStats {
|
||||
logErr("WriteResult", formatter.WriteResult(event.Result))
|
||||
if event.IsResolve {
|
||||
if cfg.ShowResolves {
|
||||
logErr("WriteResolve", formatter.WriteResolve(event))
|
||||
}
|
||||
return
|
||||
}
|
||||
logErr("WriteProgress", formatter.WriteProgress(event))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
+366
-315
@@ -7,369 +7,420 @@ import (
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.hansenits.com.au/hits/ExploreDNS/internal/dns"
|
||||
idns "gitea.hansenits.com.au/hits/ExploreDNS/internal/dns"
|
||||
"gitea.hansenits.com.au/hits/ExploreDNS/internal/traverse"
|
||||
miekgdns "github.com/miekg/dns"
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
// mockDelegation wires root → com → example.com (2 NS, one glueless answer
|
||||
// path) through the single injected exchange.
|
||||
func mockDelegation() idns.ExchangeFunc {
|
||||
responses := map[string]*dns.Msg{}
|
||||
set := func(server, qname string, msg *dns.Msg) {
|
||||
responses[server+"/"+dns.Fqdn(qname)] = msg
|
||||
}
|
||||
a := func(name, ip string) dns.RR {
|
||||
return &dns.A{
|
||||
Hdr: dns.RR_Header{Name: dns.Fqdn(name), Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 300},
|
||||
A: net.ParseIP(ip).To4(),
|
||||
}
|
||||
}
|
||||
ns := func(zone, target string) dns.RR {
|
||||
return &dns.NS{
|
||||
Hdr: dns.RR_Header{Name: dns.Fqdn(zone), Rrtype: dns.TypeNS, Class: dns.ClassINET, Ttl: 300},
|
||||
Ns: dns.Fqdn(target),
|
||||
}
|
||||
}
|
||||
|
||||
rootMsg := new(dns.Msg)
|
||||
rootMsg.Ns = []dns.RR{ns("com", "a.gtld-servers.net")}
|
||||
rootMsg.Extra = []dns.RR{a("a.gtld-servers.net", "192.5.6.30")}
|
||||
set("198.41.0.4", "www.example.com", rootMsg)
|
||||
|
||||
comMsg := new(dns.Msg)
|
||||
comMsg.Ns = []dns.RR{ns("example.com", "ns1.example.com"), ns("example.com", "ns2.example.com")}
|
||||
comMsg.Extra = []dns.RR{a("ns1.example.com", "1.1.1.1"), a("ns2.example.com", "2.2.2.2")}
|
||||
set("192.5.6.30", "www.example.com", comMsg)
|
||||
|
||||
answer := new(dns.Msg)
|
||||
answer.Answer = []dns.RR{a("www.example.com", "9.9.9.9")}
|
||||
set("1.1.1.1", "www.example.com", answer)
|
||||
set("2.2.2.2", "www.example.com", answer)
|
||||
|
||||
return func(_ context.Context, server string, msg *dns.Msg, _ bool) (*dns.Msg, error) {
|
||||
host := server
|
||||
if h, _, err := net.SplitHostPort(server); err == nil {
|
||||
host = h
|
||||
}
|
||||
resp, ok := responses[host+"/"+msg.Question[0].Name]
|
||||
if !ok {
|
||||
return nil, &net.DNSError{Err: "no mock", Name: msg.Question[0].Name}
|
||||
}
|
||||
out := resp.Copy()
|
||||
out.SetReply(msg)
|
||||
out.Answer, out.Ns, out.Extra = resp.Answer, resp.Ns, resp.Extra
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
|
||||
func newMockTraverser() *traverse.Traverser {
|
||||
tr := traverse.NewTraverser(&traverse.TraverserConfig{
|
||||
MaxDepth: traverse.DefaultMaxDepth,
|
||||
QueryType: dns.TypeA,
|
||||
RootAddrs: []net.IP{net.ParseIP("198.41.0.4")},
|
||||
QueryConfig: &idns.QueryConfig{
|
||||
Retries: 1,
|
||||
Timeout: time.Second,
|
||||
RetryDelay: time.Millisecond,
|
||||
},
|
||||
})
|
||||
tr.SetExchange(mockDelegation())
|
||||
return tr
|
||||
}
|
||||
|
||||
func TestDefaultConfig(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
if !cfg.ShowProgress {
|
||||
t.Fatal("expected ShowProgress default true")
|
||||
}
|
||||
if cfg.Format != FormatText {
|
||||
t.Fatalf("Format = %v, want text", cfg.Format)
|
||||
if cfg.Format != FormatText || !cfg.ShowProgress || !cfg.ShowResults {
|
||||
t.Errorf("unexpected defaults: %+v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewFormatterSelectsImplementation(t *testing.T) {
|
||||
text := NewFormatter(DefaultConfig(), &bytes.Buffer{})
|
||||
if _, ok := text.(*textFormatter); !ok {
|
||||
t.Fatalf("expected text formatter, got %T", text)
|
||||
if _, ok := NewFormatter(&Config{Format: FormatJSON}, &bytes.Buffer{}).(*jsonFormatter); !ok {
|
||||
t.Error("FormatJSON should select the JSON formatter")
|
||||
}
|
||||
|
||||
jsonCfg := DefaultConfig()
|
||||
jsonCfg.Format = FormatJSON
|
||||
jsonFmt := NewFormatter(jsonCfg, &bytes.Buffer{})
|
||||
if _, ok := jsonFmt.(*jsonFormatter); !ok {
|
||||
t.Fatalf("expected json formatter, got %T", jsonFmt)
|
||||
if _, ok := NewFormatter(&Config{Format: FormatText}, &bytes.Buffer{}).(*textFormatter); !ok {
|
||||
t.Error("FormatText should select the text formatter")
|
||||
}
|
||||
if NewFormatter(nil, &bytes.Buffer{}) == nil {
|
||||
t.Error("nil config should still produce a formatter")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeSummaryAggregatesAnswers(t *testing.T) {
|
||||
ref := traverse.NewReferral("example.com.", dns.TypeA, ".", 0, 1.0, nil)
|
||||
resp := &traverse.Response{
|
||||
Referral: ref,
|
||||
Type: traverse.RespAnswer,
|
||||
Decoded: &dns.DecodedResponse{
|
||||
Answers: []miekgdns.RR{
|
||||
&miekgdns.A{
|
||||
Hdr: miekgdns.RR_Header{Name: "example.com.", Rrtype: dns.TypeA, Class: miekgdns.ClassINET},
|
||||
A: net.ParseIP("93.184.216.34"),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
stats := ComputeSummary([]traverse.TraversalResult{{Referral: ref, Response: resp}})
|
||||
if len(stats.Answers) != 1 {
|
||||
t.Fatalf("answers = %d, want 1", len(stats.Answers))
|
||||
}
|
||||
if stats.Answers[0].Prob != 1.0 {
|
||||
t.Fatalf("prob = %v, want 1.0", stats.Answers[0].Prob)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextFormatterSummaryOutput(t *testing.T) {
|
||||
ref := traverse.NewReferral("example.com.", dns.TypeA, ".", 0, 1.0, nil)
|
||||
resp := &traverse.Response{
|
||||
Referral: ref,
|
||||
Type: traverse.RespAnswer,
|
||||
Decoded: &dns.DecodedResponse{
|
||||
Answers: []miekgdns.RR{
|
||||
&miekgdns.A{
|
||||
Hdr: miekgdns.RR_Header{Name: "example.com.", Rrtype: dns.TypeA, Class: miekgdns.ClassINET},
|
||||
A: net.ParseIP("93.184.216.34"),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func TestRunTraversalTextOutput(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
cfg := DefaultConfig()
|
||||
cfg.Domain = "www.example.com"
|
||||
cfg.QueryType = "a"
|
||||
cfg.ShowServers = true
|
||||
cfg.ShowVersions = false // no fingerprint network calls in tests
|
||||
cfg.Color = false
|
||||
cfg.ShowServers = false
|
||||
cfg.ShowResults = false
|
||||
formatter := NewFormatter(cfg, &buf)
|
||||
|
||||
if err := formatter.WriteSummary([]traverse.TraversalResult{{Referral: ref, Response: resp}}); err != nil {
|
||||
t.Fatalf("WriteSummary: %v", err)
|
||||
}
|
||||
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "Summary:") {
|
||||
t.Fatalf("expected summary header, got %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "100%") {
|
||||
t.Fatalf("expected probability in summary, got %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "93.184.216.34") {
|
||||
t.Fatalf("expected answer IP in summary, got %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONFormatterProducesValidOutput(t *testing.T) {
|
||||
ref := traverse.NewReferral("example.com.", dns.TypeA, ".", 0, 1.0, nil)
|
||||
resp := &traverse.Response{
|
||||
Referral: ref,
|
||||
Server: net.ParseIP("198.41.0.4"),
|
||||
Type: traverse.RespAnswer,
|
||||
Decoded: &dns.DecodedResponse{
|
||||
Answers: []miekgdns.RR{
|
||||
&miekgdns.A{
|
||||
Hdr: miekgdns.RR_Header{Name: "example.com.", Rrtype: dns.TypeA, Class: miekgdns.ClassINET},
|
||||
A: net.ParseIP("93.184.216.34"),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
cfg := DefaultConfig()
|
||||
cfg.Format = FormatJSON
|
||||
cfg.Domain = "example.com"
|
||||
cfg.QueryType = "A"
|
||||
cfg.ShowServers = false
|
||||
cfg.ShowResults = true
|
||||
cfg.ShowSummaryResults = true
|
||||
formatter := NewFormatter(cfg, &buf)
|
||||
|
||||
if err := formatter.WriteProgress(traverse.TraversalEvent{
|
||||
Stage: traverse.EventStart,
|
||||
Result: traverse.TraversalResult{Referral: ref},
|
||||
}); err != nil {
|
||||
t.Fatalf("WriteProgress: %v", err)
|
||||
}
|
||||
if err := formatter.WriteSummary([]traverse.TraversalResult{{Referral: ref, Response: resp}}); err != nil {
|
||||
t.Fatalf("WriteSummary: %v", err)
|
||||
}
|
||||
if err := formatter.Flush(); err != nil {
|
||||
t.Fatalf("Flush: %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(buf.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("invalid json: %v\n%s", err, buf.String())
|
||||
}
|
||||
if payload["domain"] != "example.com" {
|
||||
t.Fatalf("domain = %v", payload["domain"])
|
||||
}
|
||||
if _, ok := payload["summary"]; !ok {
|
||||
t.Fatalf("expected summary in json output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunTraversalUsesHooks(t *testing.T) {
|
||||
answerResp := func() *miekgdns.Msg {
|
||||
m := new(miekgdns.Msg)
|
||||
m.SetReply(new(miekgdns.Msg))
|
||||
m.Answer = append(m.Answer, &miekgdns.A{
|
||||
Hdr: miekgdns.RR_Header{Name: "example.com.", Rrtype: dns.TypeA, Class: miekgdns.ClassINET, Ttl: 300},
|
||||
A: net.ParseIP("93.184.216.34"),
|
||||
})
|
||||
return m
|
||||
}()
|
||||
|
||||
tr := traverse.NewTraverser(&traverse.TraverserConfig{
|
||||
MaxDepth: 5,
|
||||
QueryType: dns.TypeA,
|
||||
RootAddrs: []net.IP{net.ParseIP("198.41.0.4")},
|
||||
})
|
||||
tr.SetExchange(func(ctx context.Context, server string, msg *miekgdns.Msg, useTCP bool) (*miekgdns.Msg, error) {
|
||||
return answerResp.Copy(), nil
|
||||
})
|
||||
|
||||
var buf bytes.Buffer
|
||||
cfg := DefaultConfig()
|
||||
cfg.Color = false
|
||||
cfg.ShowServers = false
|
||||
cfg.ShowResults = false
|
||||
cfg.ShowSummaryResults = true
|
||||
formatter := NewFormatter(cfg, &buf)
|
||||
|
||||
_, err := RunTraversal(context.Background(), tr, cfg, formatter, "example.com")
|
||||
root, err := RunTraversal(context.Background(), newMockTraverser(), cfg, formatter, "www.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("RunTraversal: %v", err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "Summary:") {
|
||||
t.Fatalf("expected formatted summary output, got %q", buf.String())
|
||||
if root == nil || len(root.Stats) == 0 {
|
||||
t.Fatal("expected aggregated stats on the root referral")
|
||||
}
|
||||
|
||||
out := buf.String()
|
||||
for _, want := range []string{
|
||||
"# Using fast mode",
|
||||
"Using 198.41.0.4 (198.41.0.4) as initial root",
|
||||
"Running query www.example.com type a",
|
||||
"1 198.41.0.4 (198.41.0.4)",
|
||||
"1.1 a.gtld-servers.net (192.5.6.30)",
|
||||
"1.1.1 ns1.example.com (1.1.1.1)",
|
||||
"Results:",
|
||||
" 50.0%: Answer from ns1.example.com (1.1.1.1)",
|
||||
" 50.0%: Answer from ns2.example.com (2.2.2.2)",
|
||||
"Summary Results:",
|
||||
" 100% answered with www.example.com. 300 IN A 9.9.9.9",
|
||||
"The following servers were encountered:",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("output missing %q\n---\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONFormatterWriteResolveAndResult(t *testing.T) {
|
||||
ref := traverse.NewReferral("example.com.", dns.TypeA, ".", 0, 1.0, nil)
|
||||
server := net.ParseIP("198.41.0.4")
|
||||
resp := &traverse.Response{
|
||||
Referral: ref,
|
||||
Server: server,
|
||||
Type: traverse.RespAnswer,
|
||||
Decoded: &dns.DecodedResponse{
|
||||
Answers: []miekgdns.RR{
|
||||
&miekgdns.A{
|
||||
Hdr: miekgdns.RR_Header{Name: "example.com.", Rrtype: dns.TypeA, Class: miekgdns.ClassINET},
|
||||
A: net.ParseIP("1.2.3.4"),
|
||||
},
|
||||
},
|
||||
},
|
||||
func TestVerboseProgressFormat(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
cfg := DefaultConfig()
|
||||
cfg.Domain = "www.example.com"
|
||||
cfg.QueryType = "a"
|
||||
cfg.ShowVersions = false
|
||||
cfg.Verbose = true
|
||||
cfg.Color = false
|
||||
formatter := NewFormatter(cfg, &buf)
|
||||
|
||||
if _, err := RunTraversal(context.Background(), newMockTraverser(), cfg, formatter, "www.example.com"); err != nil {
|
||||
t.Fatalf("RunTraversal: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
// Verbose rows are "<refid> [qname] <server> (<ips>) <bailiwick>"; the
|
||||
// root bailiwick renders as "<>".
|
||||
for _, want := range []string{
|
||||
"1 [www.example.com] 198.41.0.4 (198.41.0.4) <>",
|
||||
"1.1 [www.example.com] a.gtld-servers.net (192.5.6.30) <com>",
|
||||
"1.1.1 [www.example.com] ns1.example.com (1.1.1.1) <example.com>",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("output missing %q\n---\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
cfg := DefaultConfig()
|
||||
cfg.Format = FormatJSON
|
||||
cfg.Domain = "example.com"
|
||||
cfg.QueryType = "A"
|
||||
cfg.ShowResolves = true
|
||||
cfg.ShowAllStats = true
|
||||
cfg.ShowProgress = true
|
||||
f := NewFormatter(cfg, &buf).(*jsonFormatter)
|
||||
func TestRunTraversalQuietSuppressesHeader(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
cfg := DefaultConfig()
|
||||
cfg.Domain = "www.example.com"
|
||||
cfg.QueryType = "a"
|
||||
cfg.ShowVersions = false
|
||||
cfg.Quiet = true
|
||||
cfg.Color = false
|
||||
formatter := NewFormatter(cfg, &buf)
|
||||
|
||||
// WriteResolve
|
||||
if err := f.WriteResolve(traverse.TraversalEvent{
|
||||
Stage: traverse.EventStart,
|
||||
Result: traverse.TraversalResult{Referral: ref, Response: resp},
|
||||
}); err != nil {
|
||||
t.Fatalf("WriteResolve: %v", err)
|
||||
if _, err := RunTraversal(context.Background(), newMockTraverser(), cfg, formatter, "www.example.com"); err != nil {
|
||||
t.Fatalf("RunTraversal: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
for _, banned := range []string{"# Using fast mode", "as initial root", "Running query"} {
|
||||
if strings.Contains(out, banned) {
|
||||
t.Errorf("quiet output must not contain %q\n---\n%s", banned, out)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(out, "Results:") {
|
||||
t.Errorf("quiet must still print results\n---\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// WriteResult
|
||||
if err := f.WriteResult(traverse.TraversalResult{Referral: ref, Response: resp}); err != nil {
|
||||
t.Fatalf("WriteResult: %v", err)
|
||||
func TestRunTraversalJSONOutput(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
cfg := DefaultConfig()
|
||||
cfg.Format = FormatJSON
|
||||
cfg.Domain = "www.example.com"
|
||||
cfg.QueryType = "A"
|
||||
cfg.ShowVersions = false
|
||||
formatter := NewFormatter(cfg, &buf)
|
||||
|
||||
if _, err := RunTraversal(context.Background(), newMockTraverser(), cfg, formatter, "www.example.com"); err != nil {
|
||||
t.Fatalf("RunTraversal: %v", err)
|
||||
}
|
||||
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal(buf.Bytes(), &doc); err != nil {
|
||||
t.Fatalf("invalid JSON: %v\n%s", err, buf.String())
|
||||
}
|
||||
if doc["domain"] != "www.example.com" {
|
||||
t.Errorf("domain = %v", doc["domain"])
|
||||
}
|
||||
if doc["qtype"] != "A" {
|
||||
t.Errorf("qtype = %v", doc["qtype"])
|
||||
}
|
||||
root, ok := doc["root"].(map[string]any)
|
||||
if !ok || root["ip"] != "198.41.0.4" {
|
||||
t.Errorf("root = %v", doc["root"])
|
||||
}
|
||||
for _, banned := range []string{"progress", "resolves"} {
|
||||
if _, present := doc[banned]; present {
|
||||
t.Errorf("JSON document must not contain %q", banned)
|
||||
}
|
||||
}
|
||||
results, ok := doc["results"].([]any)
|
||||
if !ok || len(results) != 2 {
|
||||
t.Fatalf("results = %v", doc["results"])
|
||||
}
|
||||
summary, ok := doc["summary"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("summary missing: %v", doc)
|
||||
}
|
||||
byStatus := summary["by_status"].(map[string]any)
|
||||
if prob := byStatus["answered"].(float64); prob < 0.999 || prob > 1.001 {
|
||||
t.Errorf("answered summary prob = %v", prob)
|
||||
}
|
||||
}
|
||||
|
||||
// WriteProgress with EventComplete to cover stageName "complete"
|
||||
if err := f.WriteProgress(traverse.TraversalEvent{
|
||||
Stage: traverse.EventComplete,
|
||||
Result: traverse.TraversalResult{Referral: ref, Response: resp},
|
||||
}); err != nil {
|
||||
t.Fatalf("WriteProgress EventComplete: %v", err)
|
||||
// mockGluelessDelegation wires root → com → example.com where the single NS
|
||||
// (ns1.example.net) comes without glue, forcing a resolve subtree that walks
|
||||
// root → net → answer.
|
||||
func mockGluelessDelegation() idns.ExchangeFunc {
|
||||
responses := map[string]*dns.Msg{}
|
||||
set := func(server, qname string, msg *dns.Msg) {
|
||||
responses[server+"/"+dns.Fqdn(qname)] = msg
|
||||
}
|
||||
a := func(name, ip string) dns.RR {
|
||||
return &dns.A{
|
||||
Hdr: dns.RR_Header{Name: dns.Fqdn(name), Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 300},
|
||||
A: net.ParseIP(ip).To4(),
|
||||
}
|
||||
}
|
||||
ns := func(zone, target string) dns.RR {
|
||||
return &dns.NS{
|
||||
Hdr: dns.RR_Header{Name: dns.Fqdn(zone), Rrtype: dns.TypeNS, Class: dns.ClassINET, Ttl: 300},
|
||||
Ns: dns.Fqdn(target),
|
||||
}
|
||||
}
|
||||
|
||||
comRef := new(dns.Msg)
|
||||
comRef.Ns = []dns.RR{ns("com", "a.gtld-servers.net")}
|
||||
comRef.Extra = []dns.RR{a("a.gtld-servers.net", "192.5.6.30")}
|
||||
set("198.41.0.4", "www.example.com", comRef)
|
||||
|
||||
glueless := new(dns.Msg)
|
||||
glueless.Ns = []dns.RR{ns("example.com", "ns1.example.net")}
|
||||
set("192.5.6.30", "www.example.com", glueless)
|
||||
|
||||
netRef := new(dns.Msg)
|
||||
netRef.Ns = []dns.RR{ns("net", "b.gtld-servers.net")}
|
||||
netRef.Extra = []dns.RR{a("b.gtld-servers.net", "192.33.14.31")}
|
||||
set("198.41.0.4", "ns1.example.net", netRef)
|
||||
|
||||
nsAnswer := new(dns.Msg)
|
||||
nsAnswer.Answer = []dns.RR{a("ns1.example.net", "3.3.3.3")}
|
||||
set("192.33.14.31", "ns1.example.net", nsAnswer)
|
||||
|
||||
answer := new(dns.Msg)
|
||||
answer.Answer = []dns.RR{a("www.example.com", "9.9.9.9")}
|
||||
set("3.3.3.3", "www.example.com", answer)
|
||||
|
||||
return func(_ context.Context, server string, msg *dns.Msg, _ bool) (*dns.Msg, error) {
|
||||
host := server
|
||||
if h, _, err := net.SplitHostPort(server); err == nil {
|
||||
host = h
|
||||
}
|
||||
resp, ok := responses[host+"/"+msg.Question[0].Name]
|
||||
if !ok {
|
||||
return nil, &net.DNSError{Err: "no mock", Name: msg.Question[0].Name}
|
||||
}
|
||||
out := resp.Copy()
|
||||
out.SetReply(msg)
|
||||
out.Answer, out.Ns, out.Extra = resp.Answer, resp.Ns, resp.Extra
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
|
||||
if err := f.Flush(); err != nil {
|
||||
t.Fatalf("Flush: %v", err)
|
||||
}
|
||||
func runGlueless(t *testing.T, cfg *Config) string {
|
||||
t.Helper()
|
||||
tr := traverse.NewTraverser(&traverse.TraverserConfig{
|
||||
MaxDepth: traverse.DefaultMaxDepth,
|
||||
QueryType: dns.TypeA,
|
||||
RootAddrs: []net.IP{net.ParseIP("198.41.0.4")},
|
||||
QueryConfig: &idns.QueryConfig{
|
||||
Retries: 1,
|
||||
Timeout: time.Second,
|
||||
RetryDelay: time.Millisecond,
|
||||
},
|
||||
})
|
||||
tr.SetExchange(mockGluelessDelegation())
|
||||
|
||||
var buf bytes.Buffer
|
||||
formatter := NewFormatter(cfg, &buf)
|
||||
if _, err := RunTraversal(context.Background(), tr, cfg, formatter, "www.example.com"); err != nil {
|
||||
t.Fatalf("RunTraversal: %v", err)
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func TestJSONFormatterWriteResolveFlagOff(t *testing.T) {
|
||||
ref := traverse.NewReferral("example.com.", dns.TypeA, ".", 0, 1.0, nil)
|
||||
func TestResolveProgressHiddenByDefault(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
cfg.Domain = "www.example.com"
|
||||
cfg.QueryType = "a"
|
||||
cfg.ShowVersions = false
|
||||
cfg.Color = false
|
||||
out := runGlueless(t, cfg)
|
||||
|
||||
var buf bytes.Buffer
|
||||
cfg := DefaultConfig()
|
||||
cfg.Format = FormatJSON
|
||||
cfg.ShowResolves = false
|
||||
cfg.ShowAllStats = false
|
||||
f := NewFormatter(cfg, &buf).(*jsonFormatter)
|
||||
|
||||
if err := f.WriteResolve(traverse.TraversalEvent{
|
||||
Stage: traverse.EventStart,
|
||||
Result: traverse.TraversalResult{Referral: ref},
|
||||
}); err != nil {
|
||||
t.Fatalf("WriteResolve: %v", err)
|
||||
}
|
||||
if err := f.WriteResult(traverse.TraversalResult{Referral: ref}); err != nil {
|
||||
t.Fatalf("WriteResult: %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"1.1.1 ns1.example.net -- resolving",
|
||||
"1.1.1 ns1.example.net (3.3.3.3)",
|
||||
"100.0%: Answer from ns1.example.net (3.3.3.3)",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("output missing %q\n---\n%s", want, out)
|
||||
}
|
||||
}
|
||||
// Resolve subtree nodes (".0." refids) render only under --show-resolves,
|
||||
// and resolve outcomes never appear as separate Results entries.
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if strings.HasPrefix(line, "1.1.1.0") {
|
||||
t.Errorf("resolve subtree must be hidden by default: %q", line)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, "ns1.example.net./IN/A") {
|
||||
t.Errorf("resolve leaves must not pollute Results\n---\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONFormatterWriteSummaryWithServers(t *testing.T) {
|
||||
ref := traverse.NewReferral("example.com.", dns.TypeA, "com.", 1, 1.0, nil)
|
||||
server := net.ParseIP("1.2.3.4")
|
||||
resp := &traverse.Response{
|
||||
Referral: ref,
|
||||
Server: server,
|
||||
Type: traverse.RespAnswer,
|
||||
Decoded: &dns.DecodedResponse{
|
||||
Answers: []miekgdns.RR{
|
||||
&miekgdns.A{
|
||||
Hdr: miekgdns.RR_Header{Name: "example.com.", Rrtype: dns.TypeA, Class: miekgdns.ClassINET},
|
||||
A: net.ParseIP("1.2.3.4"),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
results := []traverse.TraversalResult{{Referral: ref, Response: resp}}
|
||||
func TestResolveProgressShownWithShowResolves(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
cfg.Domain = "www.example.com"
|
||||
cfg.QueryType = "a"
|
||||
cfg.ShowVersions = false
|
||||
cfg.ShowResolves = true
|
||||
cfg.Color = false
|
||||
out := runGlueless(t, cfg)
|
||||
|
||||
var buf bytes.Buffer
|
||||
cfg := DefaultConfig()
|
||||
cfg.Format = FormatJSON
|
||||
cfg.Domain = "example.com"
|
||||
cfg.QueryType = "A"
|
||||
cfg.ShowServers = true
|
||||
cfg.ShowVersions = false
|
||||
cfg.ShowResults = true
|
||||
cfg.ShowSummaryResults = true
|
||||
f := NewFormatter(cfg, &buf).(*jsonFormatter)
|
||||
|
||||
if err := f.WriteSummary(results); err != nil {
|
||||
t.Fatalf("WriteSummary: %v", err)
|
||||
}
|
||||
if err := f.Flush(); err != nil {
|
||||
t.Fatalf("Flush: %v", err)
|
||||
for _, want := range []string{
|
||||
"1.1.1 ns1.example.net -- resolving",
|
||||
"1.1.1.0.1 198.41.0.4 (198.41.0.4)",
|
||||
"1.1.1.0.1.1 b.gtld-servers.net (192.33.14.31)",
|
||||
"1.1.1 ns1.example.net (3.3.3.3)",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("output missing %q\n---\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(buf.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("invalid JSON: %v\n%s", err, buf.String())
|
||||
}
|
||||
if _, ok := payload["servers"]; !ok {
|
||||
t.Error("expected 'servers' field in JSON output")
|
||||
}
|
||||
func TestRunTraversalRequiresTraverser(t *testing.T) {
|
||||
if _, err := RunTraversal(context.Background(), nil, DefaultConfig(), nil, "example.com"); err == nil {
|
||||
t.Fatal("expected error for nil traverser")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewFormatterNilWriter(t *testing.T) {
|
||||
// Should not panic with nil writer
|
||||
cfg := DefaultConfig()
|
||||
f := NewFormatter(cfg, nil)
|
||||
if f == nil {
|
||||
t.Error("NewFormatter should not return nil")
|
||||
}
|
||||
func TestFormatProbability(t *testing.T) {
|
||||
tests := []struct {
|
||||
prob float64
|
||||
want string
|
||||
}{
|
||||
{1.0, " 100%"},
|
||||
{0.5, " 50%"},
|
||||
{0.933, "93.3%"},
|
||||
{0.067, " 6.7%"},
|
||||
{1.0 / 3, "33.3%"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := formatProbability(tt.prob); got != tt.want {
|
||||
t.Errorf("formatProbability(%v) = %q, want %q", tt.prob, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachHooksShowResolves(t *testing.T) {
|
||||
ref := traverse.NewReferral("example.com.", dns.TypeA, ".", 0, 1.0, nil)
|
||||
server := net.ParseIP("1.2.3.4")
|
||||
resp := &traverse.Response{
|
||||
Referral: ref,
|
||||
Server: server,
|
||||
Type: traverse.RespAnswer,
|
||||
func TestSummaryStatusLabels(t *testing.T) {
|
||||
tests := map[traverse.Status]string{
|
||||
traverse.StatusNoData: "found no such record",
|
||||
traverse.StatusReferralLame: "resulted in a lame referral",
|
||||
traverse.StatusException: "resulted in an exception",
|
||||
traverse.StatusError: "resulted in an error",
|
||||
traverse.StatusNoGlue: "found no glue",
|
||||
traverse.StatusLoop: "resulted in a loop",
|
||||
traverse.StatusCNAMELoop: "resulted in a CNAME loop",
|
||||
traverse.Status("odd"): "odd",
|
||||
}
|
||||
for status, want := range tests {
|
||||
if got := summaryStatusLabel(status); got != want {
|
||||
t.Errorf("summaryStatusLabel(%q) = %q, want %q", status, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
cfg := DefaultConfig()
|
||||
cfg.ShowProgress = false
|
||||
cfg.ShowResolves = true
|
||||
cfg.ShowAllStats = true
|
||||
cfg.Color = false
|
||||
formatter := NewFormatter(cfg, &buf)
|
||||
hooks := AttachHooks(cfg, formatter)
|
||||
|
||||
// Trigger a resolve event
|
||||
hooks.OnEvent(traverse.TraversalEvent{
|
||||
Stage: traverse.EventStart,
|
||||
IsResolve: true,
|
||||
Result: traverse.TraversalResult{Referral: ref, Response: resp},
|
||||
})
|
||||
|
||||
if buf.Len() == 0 {
|
||||
t.Error("expected resolve output when ShowResolves is true")
|
||||
}
|
||||
func TestCollectUniqueServerIPs(t *testing.T) {
|
||||
servers := map[string][]string{
|
||||
"ns1.example.com": {"1.1.1.1", "2.2.2.2"},
|
||||
"ns2.example.com": {"1.1.1.1"},
|
||||
}
|
||||
ips := collectUniqueServerIPs(servers)
|
||||
if len(ips) != 2 {
|
||||
t.Errorf("unique IPs = %v", ips)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachHooksShowAllStats(t *testing.T) {
|
||||
ref := traverse.NewReferral("example.com.", dns.TypeA, ".", 0, 1.0, nil)
|
||||
server := net.ParseIP("1.2.3.4")
|
||||
resp := &traverse.Response{
|
||||
Referral: ref,
|
||||
Server: server,
|
||||
Type: traverse.RespAnswer,
|
||||
Decoded: &dns.DecodedResponse{
|
||||
Answers: []miekgdns.RR{
|
||||
&miekgdns.A{Hdr: miekgdns.RR_Header{Name: "example.com.", Rrtype: dns.TypeA, Class: miekgdns.ClassINET}, A: net.ParseIP("1.2.3.4")},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
cfg := DefaultConfig()
|
||||
cfg.ShowProgress = false
|
||||
cfg.ShowResolves = false
|
||||
cfg.ShowAllStats = true
|
||||
cfg.Color = false
|
||||
formatter := NewFormatter(cfg, &buf)
|
||||
hooks := AttachHooks(cfg, formatter)
|
||||
|
||||
hooks.OnEvent(traverse.TraversalEvent{
|
||||
Stage: traverse.EventComplete,
|
||||
Result: traverse.TraversalResult{Referral: ref, Response: resp},
|
||||
})
|
||||
func TestReverseString(t *testing.T) {
|
||||
if got := reverseString("abc"); got != "cba" {
|
||||
t.Errorf("reverseString = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
package output
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
idns "gitea.hansenits.com.au/hits/ExploreDNS/internal/dns"
|
||||
"gitea.hansenits.com.au/hits/ExploreDNS/internal/traverse"
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
// TestTextOutputMatchesReferenceCapture rebuilds the topology of
|
||||
// docs/captures/dnstraverse-ruby-www.example.com-A.txt (root → com → the two
|
||||
// cloudflare NS, three IPs each) through the mock exchange and asserts the
|
||||
// complete text output byte-for-byte against the reference format. It differs
|
||||
// from the capture only in volatile values: the root chosen, the number of
|
||||
// gTLD servers, and the server fingerprints (versions are disabled so no
|
||||
// network is touched).
|
||||
func TestTextOutputMatchesReferenceCapture(t *testing.T) {
|
||||
responses := map[string]*dns.Msg{}
|
||||
set := func(server, qname string, msg *dns.Msg) {
|
||||
responses[server+"/"+dns.Fqdn(qname)] = msg
|
||||
}
|
||||
a := func(name, ip string) dns.RR {
|
||||
return &dns.A{
|
||||
Hdr: dns.RR_Header{Name: dns.Fqdn(name), Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 300},
|
||||
A: net.ParseIP(ip).To4(),
|
||||
}
|
||||
}
|
||||
ns := func(zone, target string) dns.RR {
|
||||
return &dns.NS{
|
||||
Hdr: dns.RR_Header{Name: dns.Fqdn(zone), Rrtype: dns.TypeNS, Class: dns.ClassINET, Ttl: 300},
|
||||
Ns: dns.Fqdn(target),
|
||||
}
|
||||
}
|
||||
|
||||
// Upstream resolver: ". NS" returns one root with glue (root discovery).
|
||||
rootNS := new(dns.Msg)
|
||||
rootNS.Answer = []dns.RR{ns(".", "m.root-servers.net")}
|
||||
rootNS.Extra = []dns.RR{a("m.root-servers.net", "202.12.27.33")}
|
||||
set("10.0.0.53", ".", rootNS)
|
||||
|
||||
// Root referral to com (three gTLD servers, all glued).
|
||||
comRef := new(dns.Msg)
|
||||
comRef.Ns = []dns.RR{
|
||||
ns("com", "a.gtld-servers.net"),
|
||||
ns("com", "b.gtld-servers.net"),
|
||||
ns("com", "c.gtld-servers.net"),
|
||||
}
|
||||
comRef.Extra = []dns.RR{
|
||||
a("a.gtld-servers.net", "192.5.6.30"),
|
||||
a("b.gtld-servers.net", "192.33.14.30"),
|
||||
a("c.gtld-servers.net", "192.26.92.30"),
|
||||
}
|
||||
set("202.12.27.33", "www.example.com", comRef)
|
||||
|
||||
// gTLD referral to example.com: two NS, three glue addresses each.
|
||||
heraIPs := []string{"108.162.192.162", "172.64.32.162", "173.245.58.162"}
|
||||
elliottIPs := []string{"108.162.195.228", "162.159.44.228", "172.64.35.228"}
|
||||
exampleRef := new(dns.Msg)
|
||||
exampleRef.Ns = []dns.RR{
|
||||
ns("example.com", "hera.ns.cloudflare.com"),
|
||||
ns("example.com", "elliott.ns.cloudflare.com"),
|
||||
}
|
||||
for _, ip := range heraIPs {
|
||||
exampleRef.Extra = append(exampleRef.Extra, a("hera.ns.cloudflare.com", ip))
|
||||
}
|
||||
for _, ip := range elliottIPs {
|
||||
exampleRef.Extra = append(exampleRef.Extra, a("elliott.ns.cloudflare.com", ip))
|
||||
}
|
||||
for _, ip := range []string{"192.5.6.30", "192.33.14.30", "192.26.92.30"} {
|
||||
set(ip, "www.example.com", exampleRef)
|
||||
}
|
||||
|
||||
answer := new(dns.Msg)
|
||||
answer.Answer = []dns.RR{
|
||||
a("www.example.com", "104.20.23.154"),
|
||||
a("www.example.com", "172.66.147.243"),
|
||||
}
|
||||
for _, ip := range append(append([]string{}, heraIPs...), elliottIPs...) {
|
||||
set(ip, "www.example.com", answer)
|
||||
}
|
||||
|
||||
exchange := func(_ context.Context, server string, msg *dns.Msg, _ bool) (*dns.Msg, error) {
|
||||
host := server
|
||||
if h, _, err := net.SplitHostPort(server); err == nil {
|
||||
host = h
|
||||
}
|
||||
resp, ok := responses[host+"/"+msg.Question[0].Name]
|
||||
if !ok {
|
||||
return nil, &net.DNSError{Err: "no mock", Name: msg.Question[0].Name}
|
||||
}
|
||||
out := resp.Copy()
|
||||
out.SetReply(msg)
|
||||
out.Answer, out.Ns, out.Extra = resp.Answer, resp.Ns, resp.Extra
|
||||
return out, nil
|
||||
}
|
||||
|
||||
tr := traverse.NewTraverser(&traverse.TraverserConfig{
|
||||
MaxDepth: traverse.DefaultMaxDepth,
|
||||
QueryType: dns.TypeA,
|
||||
Fast: true,
|
||||
RootConfig: &idns.RootDiscoveryConfig{Resolver: "10.0.0.53:53"},
|
||||
QueryConfig: &idns.QueryConfig{
|
||||
Retries: 1,
|
||||
Timeout: time.Second,
|
||||
RetryDelay: time.Millisecond,
|
||||
},
|
||||
})
|
||||
tr.SetExchange(exchange)
|
||||
|
||||
cfg := DefaultConfig()
|
||||
cfg.Domain = "www.example.com"
|
||||
cfg.QueryType = "a"
|
||||
cfg.ShowServers = true
|
||||
cfg.ShowVersions = false
|
||||
cfg.Color = false
|
||||
|
||||
var buf bytes.Buffer
|
||||
formatter := NewFormatter(cfg, &buf)
|
||||
if _, err := RunTraversal(context.Background(), tr, cfg, formatter, "www.example.com"); err != nil {
|
||||
t.Fatalf("RunTraversal: %v", err)
|
||||
}
|
||||
|
||||
answerBlock := func(server, ip string) string {
|
||||
return " 16.7%: Answer from " + server + " (" + ip + ")\n" +
|
||||
" www.example.com.\t300\tIN\tA\t104.20.23.154\n" +
|
||||
" www.example.com.\t300\tIN\tA\t172.66.147.243\n"
|
||||
}
|
||||
|
||||
want := strings.Join([]string{
|
||||
"# Using fast mode",
|
||||
"# Limiting traverse to one root",
|
||||
"# UDP size 2048 (EDNS0 is on)",
|
||||
"# Retries 2, max depth 20",
|
||||
"# Allow TCP is true, always TCP is false",
|
||||
"Using m.root-servers.net (202.12.27.33) as initial root",
|
||||
"Running query www.example.com type a",
|
||||
"1 m.root-servers.net (202.12.27.33)",
|
||||
"1.1 a.gtld-servers.net (192.5.6.30)",
|
||||
"1.1.1 hera.ns.cloudflare.com (108.162.192.162,172.64.32.162,173.245.58.162)",
|
||||
"1.1.2 elliott.ns.cloudflare.com (108.162.195.228,162.159.44.228,172.64.35.228)",
|
||||
"1.2 b.gtld-servers.net (192.33.14.30)",
|
||||
"1.2.1 hera.ns.cloudflare.com (108.162.192.162,172.64.32.162,173.245.58.162) -- completed earlier (1.1.1)",
|
||||
"1.2.2 elliott.ns.cloudflare.com (108.162.195.228,162.159.44.228,172.64.35.228) -- completed earlier (1.1.2)",
|
||||
"1.3 c.gtld-servers.net (192.26.92.30)",
|
||||
"1.3.1 hera.ns.cloudflare.com (108.162.192.162,172.64.32.162,173.245.58.162) -- completed earlier (1.1.1)",
|
||||
"1.3.2 elliott.ns.cloudflare.com (108.162.195.228,162.159.44.228,172.64.35.228) -- completed earlier (1.1.2)",
|
||||
"",
|
||||
"The following servers were encountered:",
|
||||
" hera.ns.cloudflare.com: 108.162.192.162",
|
||||
" hera.ns.cloudflare.com: 172.64.32.162",
|
||||
" hera.ns.cloudflare.com: 173.245.58.162",
|
||||
"elliott.ns.cloudflare.com: 108.162.195.228",
|
||||
"elliott.ns.cloudflare.com: 162.159.44.228",
|
||||
"elliott.ns.cloudflare.com: 172.64.35.228",
|
||||
" a.gtld-servers.net: 192.5.6.30",
|
||||
" b.gtld-servers.net: 192.33.14.30",
|
||||
" c.gtld-servers.net: 192.26.92.30",
|
||||
" m.root-servers.net: 202.12.27.33",
|
||||
"",
|
||||
"Results:",
|
||||
answerBlock("hera.ns.cloudflare.com", "108.162.192.162"),
|
||||
answerBlock("elliott.ns.cloudflare.com", "108.162.195.228"),
|
||||
answerBlock("elliott.ns.cloudflare.com", "162.159.44.228"),
|
||||
answerBlock("hera.ns.cloudflare.com", "172.64.32.162"),
|
||||
answerBlock("elliott.ns.cloudflare.com", "172.64.35.228"),
|
||||
answerBlock("hera.ns.cloudflare.com", "173.245.58.162") +
|
||||
"\n" +
|
||||
"Summary Results:\n" +
|
||||
" 100% answered with www.example.com. 300 IN A 104.20.23.154\n" +
|
||||
" www.example.com. 300 IN A 172.66.147.243\n",
|
||||
}, "\n")
|
||||
|
||||
if got := buf.String(); got != want {
|
||||
t.Errorf("output does not match the reference capture format\n--- got ---\n%s\n--- want ---\n%s", got, want)
|
||||
}
|
||||
}
|
||||
+82
-101
@@ -3,11 +3,15 @@ package output
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gitea.hansenits.com.au/hits/ExploreDNS/internal/dns"
|
||||
"gitea.hansenits.com.au/hits/ExploreDNS/internal/traverse"
|
||||
)
|
||||
|
||||
// jsonFormatter accumulates the run into a single document emitted once by
|
||||
// Flush: {domain, qtype, root, results, summary, servers}. Aggregated leaves
|
||||
// appear exactly once (in results); progress events are not recorded.
|
||||
type jsonFormatter struct {
|
||||
cfg *Config
|
||||
w io.Writer
|
||||
@@ -15,32 +19,31 @@ type jsonFormatter struct {
|
||||
}
|
||||
|
||||
type jsonDocument struct {
|
||||
Domain string `json:"domain"`
|
||||
QueryType string `json:"query_type"`
|
||||
Progress []jsonProgressEvent `json:"progress,omitempty"`
|
||||
Resolves []jsonProgressEvent `json:"resolves,omitempty"`
|
||||
Results []jsonResult `json:"results,omitempty"`
|
||||
Servers []jsonServer `json:"servers,omitempty"`
|
||||
Summary jsonSummary `json:"summary,omitempty"`
|
||||
Domain string `json:"domain"`
|
||||
QueryType string `json:"qtype"`
|
||||
Root *jsonRoot `json:"root,omitempty"`
|
||||
Results []jsonResult `json:"results,omitempty"`
|
||||
Summary *jsonSummary `json:"summary,omitempty"`
|
||||
Servers []jsonServer `json:"servers,omitempty"`
|
||||
}
|
||||
|
||||
type jsonProgressEvent struct {
|
||||
Stage string `json:"stage"`
|
||||
Depth int `json:"depth"`
|
||||
Name string `json:"name"`
|
||||
QType string `json:"qtype"`
|
||||
Server string `json:"server,omitempty"`
|
||||
Bailiwick string `json:"bailiwick,omitempty"`
|
||||
Resolving bool `json:"resolving,omitempty"`
|
||||
// jsonRoot is the initial root the traversal started from.
|
||||
type jsonRoot struct {
|
||||
Name string `json:"name"`
|
||||
IP string `json:"ip,omitempty"`
|
||||
}
|
||||
|
||||
// jsonResult is one aggregated leaf outcome, emitted exactly once.
|
||||
type jsonResult struct {
|
||||
Depth int `json:"depth"`
|
||||
Probability float64 `json:"probability"`
|
||||
ResponseType string `json:"response_type"`
|
||||
Server string `json:"server,omitempty"`
|
||||
Answers []string `json:"answers,omitempty"`
|
||||
CNAMEChain []string `json:"cname_chain,omitempty"`
|
||||
RefID string `json:"refid,omitempty"`
|
||||
Probability float64 `json:"probability"`
|
||||
Status string `json:"status"`
|
||||
Server string `json:"server,omitempty"`
|
||||
IP string `json:"ip,omitempty"`
|
||||
Qname string `json:"qname,omitempty"`
|
||||
Qtype string `json:"qtype,omitempty"`
|
||||
Answers []string `json:"answers,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
type jsonServer struct {
|
||||
@@ -50,12 +53,11 @@ type jsonServer struct {
|
||||
}
|
||||
|
||||
type jsonSummary struct {
|
||||
ByType map[string]float64 `json:"by_type,omitempty"`
|
||||
Answers []jsonAnswerStat `json:"answers,omitempty"`
|
||||
ByStatus map[string]float64 `json:"by_status,omitempty"`
|
||||
Answers []jsonAnswerStat `json:"answers,omitempty"`
|
||||
}
|
||||
|
||||
type jsonAnswerStat struct {
|
||||
RData string `json:"rdata"`
|
||||
Probability float64 `json:"probability"`
|
||||
Records []string `json:"records,omitempty"`
|
||||
}
|
||||
@@ -71,43 +73,48 @@ func newJSONFormatter(cfg *Config, w io.Writer) *jsonFormatter {
|
||||
}
|
||||
}
|
||||
|
||||
func (f *jsonFormatter) WriteProgress(event traverse.TraversalEvent) error {
|
||||
if !f.cfg.ShowProgress {
|
||||
func (f *jsonFormatter) WriteHeader(roots []traverse.StartServer) error {
|
||||
if len(roots) == 0 {
|
||||
return nil
|
||||
}
|
||||
f.payload.Progress = append(f.payload.Progress, f.eventToJSON(event))
|
||||
root := &jsonRoot{Name: roots[0].Name}
|
||||
if len(roots[0].IPs) > 0 {
|
||||
root.IP = roots[0].IPs[0]
|
||||
}
|
||||
f.payload.Root = root
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *jsonFormatter) WriteResolve(event traverse.TraversalEvent) error {
|
||||
if !f.cfg.ShowResolves {
|
||||
return nil
|
||||
}
|
||||
f.payload.Resolves = append(f.payload.Resolves, f.eventToJSON(event))
|
||||
// WriteProgress is a no-op: the JSON document contains only the aggregated
|
||||
// outcome, never per-event duplicates.
|
||||
func (f *jsonFormatter) WriteProgress(traverse.TraversalEvent) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *jsonFormatter) WriteResult(result traverse.TraversalResult) error {
|
||||
if !f.cfg.ShowAllStats {
|
||||
return nil
|
||||
}
|
||||
f.payload.Results = append(f.payload.Results, f.resultToJSON(result))
|
||||
func (f *jsonFormatter) WriteResolve(traverse.TraversalEvent) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *jsonFormatter) WriteSummary(results []traverse.TraversalResult) error {
|
||||
if f.cfg.ShowResults {
|
||||
for _, result := range terminalResults(results) {
|
||||
f.payload.Results = append(f.payload.Results, f.resultToJSON(result))
|
||||
func (f *jsonFormatter) WriteSummary(root *traverse.Referral, servers map[string][]string) error {
|
||||
if root != nil && f.cfg.ShowResults {
|
||||
// StatsList is sorted by stats key: deterministic ordering.
|
||||
for _, leaf := range root.StatsList() {
|
||||
f.payload.Results = append(f.payload.Results, leafToJSON(leaf))
|
||||
}
|
||||
}
|
||||
|
||||
if f.cfg.ShowServers {
|
||||
servers := collectServers(results)
|
||||
for name, ips := range servers {
|
||||
srv := jsonServer{Name: name, IPs: ips}
|
||||
names := make([]string, 0, len(servers))
|
||||
for name := range servers {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Slice(names, func(i, j int) bool {
|
||||
return reverseString(strings.ToLower(names[i])) < reverseString(strings.ToLower(names[j]))
|
||||
})
|
||||
for _, name := range names {
|
||||
srv := jsonServer{Name: name, IPs: servers[name]}
|
||||
if f.cfg.ShowVersions && f.cfg.Fingerprints != nil {
|
||||
for _, ip := range ips {
|
||||
for _, ip := range srv.IPs {
|
||||
if v := f.cfg.Fingerprints[ip]; v != "" {
|
||||
srv.Version = v
|
||||
break
|
||||
@@ -119,18 +126,19 @@ func (f *jsonFormatter) WriteSummary(results []traverse.TraversalResult) error {
|
||||
}
|
||||
|
||||
if f.cfg.ShowSummaryResults {
|
||||
stats := ComputeSummary(results)
|
||||
if stats != nil {
|
||||
f.payload.Summary = jsonSummary{
|
||||
ByType: stats.ByType,
|
||||
if stats := root.SummaryStats(); stats != nil {
|
||||
summary := &jsonSummary{ByStatus: make(map[string]float64)}
|
||||
for status, prob := range stats.ByStatus {
|
||||
summary.ByStatus[string(status)] = prob
|
||||
}
|
||||
for _, answer := range stats.Answers {
|
||||
f.payload.Summary.Answers = append(f.payload.Summary.Answers, jsonAnswerStat{
|
||||
RData: answer.RData,
|
||||
Probability: answer.Prob,
|
||||
Records: answer.RRs,
|
||||
})
|
||||
stat := jsonAnswerStat{Probability: answer.Prob}
|
||||
for _, rr := range answer.RRs {
|
||||
stat.Records = append(stat.Records, collapseWhitespace(rr.String()))
|
||||
}
|
||||
summary.Answers = append(summary.Answers, stat)
|
||||
}
|
||||
f.payload.Summary = summary
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,56 +151,29 @@ func (f *jsonFormatter) Flush() error {
|
||||
return enc.Encode(f.payload)
|
||||
}
|
||||
|
||||
func (f *jsonFormatter) eventToJSON(event traverse.TraversalEvent) jsonProgressEvent {
|
||||
ref := event.Result.Referral
|
||||
if ref == nil {
|
||||
return jsonProgressEvent{}
|
||||
func leafToJSON(leaf *traverse.StatsEntry) jsonResult {
|
||||
resp := leaf.Response
|
||||
item := jsonResult{
|
||||
Probability: leaf.Prob,
|
||||
Status: string(resp.Status),
|
||||
IP: resp.IP,
|
||||
Qname: resp.Qname,
|
||||
Qtype: traverse.TypeToString(resp.Qtype),
|
||||
}
|
||||
|
||||
item := jsonProgressEvent{
|
||||
Stage: stageName(event.Stage),
|
||||
Depth: ref.Depth,
|
||||
Name: trimDomain(ref.Name),
|
||||
QType: dns.QNameType(ref.Qtype),
|
||||
Bailiwick: trimDomain(ref.Bailiwick),
|
||||
Resolving: !ref.HasAddresses(),
|
||||
if leaf.Referral != nil {
|
||||
item.RefID = leaf.Referral.RefID
|
||||
item.Server = leaf.Referral.Server
|
||||
}
|
||||
if event.Result.Response != nil && event.Result.Response.Server != nil {
|
||||
item.Server = event.Result.Response.Server.String()
|
||||
} else {
|
||||
item.Server = referralServerLabel(ref, event.Result.Response)
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func (f *jsonFormatter) resultToJSON(result traverse.TraversalResult) jsonResult {
|
||||
item := jsonResult{}
|
||||
if result.Referral != nil {
|
||||
item.Depth = result.Referral.Depth
|
||||
item.Probability = result.Referral.Prob
|
||||
}
|
||||
if result.Response != nil {
|
||||
item.ResponseType = result.Response.Type.String()
|
||||
if result.Response.Server != nil {
|
||||
item.Server = result.Response.Server.String()
|
||||
if resp.DQ != nil {
|
||||
for _, rr := range resp.DQ.Answers {
|
||||
item.Answers = append(item.Answers, collapseWhitespace(rr.String()))
|
||||
}
|
||||
if result.Response.Decoded != nil {
|
||||
for _, rr := range result.Response.Decoded.Answers {
|
||||
item.Answers = append(item.Answers, dns.FormatRecord(rr))
|
||||
}
|
||||
item.CNAMEChain = append(item.CNAMEChain, result.Response.Decoded.CNAMEChain...)
|
||||
switch resp.Status {
|
||||
case traverse.StatusError:
|
||||
item.Message = resp.DQ.ErrorMessage
|
||||
case traverse.StatusException:
|
||||
item.Message = resp.DQ.ExceptionMessage
|
||||
}
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func stageName(stage traverse.EventStage) string {
|
||||
switch stage {
|
||||
case traverse.EventStart:
|
||||
return "start"
|
||||
case traverse.EventComplete:
|
||||
return "complete"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
+34
-18
@@ -9,7 +9,10 @@ import (
|
||||
"gitea.hansenits.com.au/hits/ExploreDNS/internal/traverse"
|
||||
)
|
||||
|
||||
func RunTraversal(ctx context.Context, traverser *traverse.Traverser, cfg *Config, formatter Formatter, domain string) ([]traverse.TraversalResult, error) {
|
||||
// RunTraversal drives one traversal and streams its output through the
|
||||
// formatter. It returns the synthetic root referral whose Stats aggregate
|
||||
// every leaf outcome.
|
||||
func RunTraversal(ctx context.Context, traverser *traverse.Traverser, cfg *Config, formatter Formatter, domain string) (*traverse.Referral, error) {
|
||||
if traverser == nil {
|
||||
return nil, fmt.Errorf("traverser is required")
|
||||
}
|
||||
@@ -22,40 +25,53 @@ func RunTraversal(ctx context.Context, traverser *traverse.Traverser, cfg *Confi
|
||||
|
||||
traverser.SetHooks(AttachHooks(cfg, formatter))
|
||||
|
||||
results, err := traverser.Traverse(ctx, domain)
|
||||
// Discover the roots up front so the header can report the initial root;
|
||||
// Run reuses the memoised discovery.
|
||||
roots, err := traverser.Roots(ctx)
|
||||
if err != nil {
|
||||
return results, err
|
||||
return nil, err
|
||||
}
|
||||
if err := formatter.WriteHeader(roots); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
root, err := traverser.Run(ctx, domain)
|
||||
if err != nil {
|
||||
return root, err
|
||||
}
|
||||
|
||||
servers := traverser.ServersEncountered()
|
||||
|
||||
// Fingerprint servers when both ShowVersions and ShowServers are enabled.
|
||||
// Gating on ShowServers avoids unnecessary network calls when versions
|
||||
// would not be displayed anyway.
|
||||
if cfg.ShowVersions && cfg.ShowServers {
|
||||
cfg.Fingerprints = fingerprint.New().FingerprintAll(ctx, collectUniqueServerIPs(results))
|
||||
cfg.Fingerprints = fingerprint.New().FingerprintAll(ctx, collectUniqueServerIPs(servers))
|
||||
}
|
||||
|
||||
if err := formatter.WriteSummary(results); err != nil {
|
||||
return results, err
|
||||
if err := formatter.WriteSummary(root, servers); err != nil {
|
||||
return root, err
|
||||
}
|
||||
if err := formatter.Flush(); err != nil {
|
||||
return results, err
|
||||
return root, err
|
||||
}
|
||||
|
||||
return results, nil
|
||||
return root, nil
|
||||
}
|
||||
|
||||
// collectUniqueServerIPs returns the set of unique server IPs seen in results.
|
||||
func collectUniqueServerIPs(results []traverse.TraversalResult) []net.IP {
|
||||
// collectUniqueServerIPs returns the set of unique server IPs encountered.
|
||||
func collectUniqueServerIPs(servers map[string][]string) []net.IP {
|
||||
seen := make(map[string]bool)
|
||||
var ips []net.IP
|
||||
for _, r := range results {
|
||||
if r.Response == nil || r.Response.Server == nil {
|
||||
continue
|
||||
}
|
||||
key := r.Response.Server.String()
|
||||
if !seen[key] {
|
||||
seen[key] = true
|
||||
ips = append(ips, r.Response.Server)
|
||||
for _, addrs := range servers {
|
||||
for _, addr := range addrs {
|
||||
if seen[addr] {
|
||||
continue
|
||||
}
|
||||
seen[addr] = true
|
||||
if ip := net.ParseIP(addr); ip != nil {
|
||||
ips = append(ips, ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ips
|
||||
|
||||
+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), " ")
|
||||
}
|
||||
|
||||
@@ -1,327 +0,0 @@
|
||||
package output
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"gitea.hansenits.com.au/hits/ExploreDNS/internal/dns"
|
||||
"gitea.hansenits.com.au/hits/ExploreDNS/internal/traverse"
|
||||
miekgdns "github.com/miekg/dns"
|
||||
)
|
||||
|
||||
func makeAnswerResult(name string, ip string, prob float64) traverse.TraversalResult {
|
||||
ref := traverse.NewReferral(name, dns.TypeA, ".", 0, prob, nil)
|
||||
server := net.ParseIP("198.41.0.4")
|
||||
resp := &traverse.Response{
|
||||
Referral: ref,
|
||||
Server: server,
|
||||
Type: traverse.RespAnswer,
|
||||
Decoded: &dns.DecodedResponse{
|
||||
Answers: []miekgdns.RR{
|
||||
&miekgdns.A{
|
||||
Hdr: miekgdns.RR_Header{Name: name + ".", Rrtype: dns.TypeA, Class: miekgdns.ClassINET},
|
||||
A: net.ParseIP(ip),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
return traverse.TraversalResult{Referral: ref, Response: resp}
|
||||
}
|
||||
|
||||
func TestRRDataStringAllTypes(t *testing.T) {
|
||||
tests := []struct {
|
||||
rr miekgdns.RR
|
||||
want string
|
||||
}{
|
||||
{
|
||||
&miekgdns.A{Hdr: miekgdns.RR_Header{Rrtype: dns.TypeA}, A: net.ParseIP("1.2.3.4")},
|
||||
"1.2.3.4",
|
||||
},
|
||||
{
|
||||
&miekgdns.AAAA{Hdr: miekgdns.RR_Header{Rrtype: miekgdns.TypeAAAA}, AAAA: net.ParseIP("::1")},
|
||||
"::1",
|
||||
},
|
||||
{
|
||||
&miekgdns.CNAME{Hdr: miekgdns.RR_Header{Rrtype: miekgdns.TypeCNAME}, Target: "example.com."},
|
||||
"example.com.",
|
||||
},
|
||||
{
|
||||
&miekgdns.NS{Hdr: miekgdns.RR_Header{Rrtype: miekgdns.TypeNS}, Ns: "ns1.example.com."},
|
||||
"ns1.example.com.",
|
||||
},
|
||||
{
|
||||
&miekgdns.MX{Hdr: miekgdns.RR_Header{Rrtype: miekgdns.TypeMX}, Preference: 10, Mx: "mail.example.com."},
|
||||
"10 mail.example.com.",
|
||||
},
|
||||
{
|
||||
&miekgdns.TXT{Hdr: miekgdns.RR_Header{Rrtype: miekgdns.TypeTXT}, Txt: []string{"v=spf1", "include:example.com"}},
|
||||
"v=spf1 include:example.com",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
got := rrDataString(tc.rr)
|
||||
if got != tc.want {
|
||||
t.Errorf("rrDataString(%T) = %q, want %q", tc.rr, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRRDataStringDefault(t *testing.T) {
|
||||
// SOA record hits the default case
|
||||
rr := &miekgdns.SOA{
|
||||
Hdr: miekgdns.RR_Header{Name: ".", Rrtype: miekgdns.TypeSOA, Class: miekgdns.ClassINET},
|
||||
Ns: "a.root-servers.net.",
|
||||
Mbox: "nstld.verisign-grs.com.",
|
||||
}
|
||||
got := rrDataString(rr)
|
||||
if got == "" {
|
||||
t.Error("rrDataString(SOA) should return non-empty string via default case")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummaryTypeLabelAllTypes(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"nodata": "found no such record",
|
||||
"nxdomain": "name does not exist",
|
||||
"servfail": "resulted in SERVFAIL",
|
||||
"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",
|
||||
}
|
||||
for input, want := range cases {
|
||||
got := summaryTypeLabel(input)
|
||||
if got != want {
|
||||
t.Errorf("summaryTypeLabel(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectServersEmpty(t *testing.T) {
|
||||
servers := collectServers(nil)
|
||||
if len(servers) != 0 {
|
||||
t.Errorf("collectServers(nil) = %v, want empty", servers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectServersDeduplication(t *testing.T) {
|
||||
ref := traverse.NewReferral("example.com.", dns.TypeA, "com.", 1, 1.0, nil)
|
||||
server := net.ParseIP("1.2.3.4")
|
||||
resp := &traverse.Response{
|
||||
Referral: ref,
|
||||
Server: server,
|
||||
Type: traverse.RespAnswer,
|
||||
}
|
||||
result := traverse.TraversalResult{Referral: ref, Response: resp}
|
||||
|
||||
servers := collectServers([]traverse.TraversalResult{result, result})
|
||||
name := "com"
|
||||
ips := servers[name]
|
||||
if len(ips) != 1 {
|
||||
t.Errorf("expected deduplication: got %d IPs, want 1", len(ips))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectServersWithBailiwick(t *testing.T) {
|
||||
ref := traverse.NewReferral("example.com.", dns.TypeA, "com.", 1, 1.0, nil)
|
||||
server := net.ParseIP("1.2.3.4")
|
||||
resp := &traverse.Response{
|
||||
Referral: ref,
|
||||
Server: server,
|
||||
Type: traverse.RespAnswer,
|
||||
}
|
||||
result := traverse.TraversalResult{Referral: ref, Response: resp}
|
||||
|
||||
servers := collectServers([]traverse.TraversalResult{result})
|
||||
if len(servers) == 0 {
|
||||
t.Fatal("expected at least one server entry")
|
||||
}
|
||||
if _, ok := servers["com"]; !ok {
|
||||
t.Errorf("expected server name 'com', got keys: %v", servers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerNameFallbacks(t *testing.T) {
|
||||
// No bailiwick, no NSName, with server IP
|
||||
ref := traverse.NewReferral("example.com.", dns.TypeA, ".", 0, 1.0, nil)
|
||||
resp := &traverse.Response{
|
||||
Referral: ref,
|
||||
Server: net.ParseIP("1.2.3.4"),
|
||||
Type: traverse.RespAnswer,
|
||||
}
|
||||
result := traverse.TraversalResult{Referral: ref, Response: resp}
|
||||
name := serverName(result)
|
||||
if name != "1.2.3.4" {
|
||||
t.Errorf("serverName with root bailiwick = %q, want IP", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerNameWithNSName(t *testing.T) {
|
||||
ref := &traverse.Referral{
|
||||
Name: "example.com.",
|
||||
Qtype: dns.TypeA,
|
||||
Bailiwick: ".",
|
||||
NSName: "ns1.example.com.",
|
||||
}
|
||||
resp := &traverse.Response{
|
||||
Referral: ref,
|
||||
Server: net.ParseIP("5.5.5.5"),
|
||||
Type: traverse.RespAnswer,
|
||||
}
|
||||
result := traverse.TraversalResult{Referral: ref, Response: resp}
|
||||
// Bailiwick is "." so falls through to NSName
|
||||
name := serverName(result)
|
||||
if name == "" {
|
||||
t.Error("serverName should return non-empty string")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerNameNilReferral(t *testing.T) {
|
||||
resp := &traverse.Response{
|
||||
Server: net.ParseIP("1.2.3.4"),
|
||||
Type: traverse.RespAnswer,
|
||||
}
|
||||
result := traverse.TraversalResult{Referral: nil, Response: resp}
|
||||
name := serverName(result)
|
||||
if name == "" {
|
||||
t.Error("serverName with nil referral should return non-empty string")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainsString(t *testing.T) {
|
||||
items := []string{"a", "b", "c"}
|
||||
if !containsString(items, "b") {
|
||||
t.Error("containsString should find 'b' in slice")
|
||||
}
|
||||
if containsString(items, "d") {
|
||||
t.Error("containsString should not find 'd' in slice")
|
||||
}
|
||||
if containsString(nil, "a") {
|
||||
t.Error("containsString on nil slice should return false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeSummaryMixedResults(t *testing.T) {
|
||||
refAnswer := traverse.NewReferral("example.com.", dns.TypeA, ".", 0, 0.6, nil)
|
||||
respAnswer := &traverse.Response{
|
||||
Referral: refAnswer,
|
||||
Type: traverse.RespAnswer,
|
||||
Decoded: &dns.DecodedResponse{
|
||||
Answers: []miekgdns.RR{
|
||||
&miekgdns.A{
|
||||
Hdr: miekgdns.RR_Header{Name: "example.com.", Rrtype: dns.TypeA, Class: miekgdns.ClassINET},
|
||||
A: net.ParseIP("1.2.3.4"),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
refNXD := traverse.NewReferral("notexist.com.", dns.TypeA, ".", 0, 0.4, nil)
|
||||
respNXD := &traverse.Response{
|
||||
Referral: refNXD,
|
||||
Type: traverse.RespNXDOMAIN,
|
||||
}
|
||||
|
||||
results := []traverse.TraversalResult{
|
||||
{Referral: refAnswer, Response: respAnswer},
|
||||
{Referral: refNXD, Response: respNXD},
|
||||
}
|
||||
|
||||
stats := ComputeSummary(results)
|
||||
if stats == nil {
|
||||
t.Fatal("ComputeSummary returned nil for non-empty results")
|
||||
}
|
||||
if len(stats.Answers) != 1 {
|
||||
t.Errorf("expected 1 answer entry, got %d", len(stats.Answers))
|
||||
}
|
||||
if _, ok := stats.ByType["nxdomain"]; !ok {
|
||||
t.Error("expected nxdomain in ByType")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeSummaryAnswerWithCNAMEOnly(t *testing.T) {
|
||||
// Answer with only CNAME record - no final answer, should be in ByType
|
||||
ref := traverse.NewReferral("www.example.com.", dns.TypeA, ".", 0, 1.0, nil)
|
||||
resp := &traverse.Response{
|
||||
Referral: ref,
|
||||
Type: traverse.RespAnswer,
|
||||
Decoded: &dns.DecodedResponse{
|
||||
Answers: []miekgdns.RR{
|
||||
&miekgdns.CNAME{
|
||||
Hdr: miekgdns.RR_Header{Name: "www.example.com.", Rrtype: miekgdns.TypeCNAME, Class: miekgdns.ClassINET},
|
||||
Target: "example.com.",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
results := []traverse.TraversalResult{{Referral: ref, Response: resp}}
|
||||
stats := ComputeSummary(results)
|
||||
if stats == nil {
|
||||
t.Fatal("ComputeSummary returned nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeSummaryAccumulates(t *testing.T) {
|
||||
// Two answers with the same IP should accumulate probability
|
||||
ref1 := traverse.NewReferral("example.com.", dns.TypeA, ".", 0, 0.5, nil)
|
||||
ref2 := traverse.NewReferral("example.com.", dns.TypeA, ".", 0, 0.5, nil)
|
||||
|
||||
makeResp := func(ref *traverse.Referral) *traverse.Response {
|
||||
return &traverse.Response{
|
||||
Referral: ref,
|
||||
Type: traverse.RespAnswer,
|
||||
Decoded: &dns.DecodedResponse{
|
||||
Answers: []miekgdns.RR{
|
||||
&miekgdns.A{
|
||||
Hdr: miekgdns.RR_Header{Name: "example.com.", Rrtype: dns.TypeA, Class: miekgdns.ClassINET},
|
||||
A: net.ParseIP("1.2.3.4"),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
results := []traverse.TraversalResult{
|
||||
{Referral: ref1, Response: makeResp(ref1)},
|
||||
{Referral: ref2, Response: makeResp(ref2)},
|
||||
}
|
||||
stats := ComputeSummary(results)
|
||||
if stats == nil {
|
||||
t.Fatal("ComputeSummary returned nil")
|
||||
}
|
||||
if len(stats.Answers) != 1 {
|
||||
t.Fatalf("expected 1 answer after accumulation, got %d", len(stats.Answers))
|
||||
}
|
||||
if stats.Answers[0].Prob < 0.99 {
|
||||
t.Errorf("accumulated prob = %.2f, want ~1.0", stats.Answers[0].Prob)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectUniqueServerIPs(t *testing.T) {
|
||||
ref := traverse.NewReferral("example.com.", dns.TypeA, ".", 0, 1.0, nil)
|
||||
ip1 := net.ParseIP("1.2.3.4")
|
||||
ip2 := net.ParseIP("5.6.7.8")
|
||||
|
||||
results := []traverse.TraversalResult{
|
||||
{Referral: ref, Response: &traverse.Response{Server: ip1, Type: traverse.RespAnswer}},
|
||||
{Referral: ref, Response: &traverse.Response{Server: ip1, Type: traverse.RespAnswer}}, // dup
|
||||
{Referral: ref, Response: &traverse.Response{Server: ip2, Type: traverse.RespAnswer}},
|
||||
{Referral: ref, Response: nil}, // nil response
|
||||
}
|
||||
|
||||
ips := collectUniqueServerIPs(results)
|
||||
if len(ips) != 2 {
|
||||
t.Errorf("expected 2 unique IPs, got %d", len(ips))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectUniqueServerIPsEmpty(t *testing.T) {
|
||||
ips := collectUniqueServerIPs(nil)
|
||||
if len(ips) != 0 {
|
||||
t.Errorf("expected 0 IPs for nil results, got %d", len(ips))
|
||||
}
|
||||
}
|
||||
+244
-154
@@ -6,7 +6,6 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gitea.hansenits.com.au/hits/ExploreDNS/internal/dns"
|
||||
"gitea.hansenits.com.au/hits/ExploreDNS/internal/traverse"
|
||||
)
|
||||
|
||||
@@ -19,52 +18,143 @@ func newTextFormatter(cfg *Config, w io.Writer) *textFormatter {
|
||||
return &textFormatter{cfg: cfg, w: w}
|
||||
}
|
||||
|
||||
// WriteHeader renders the pre-run header block (bin/dnstraverse): the "#"
|
||||
// settings lines, the initial root, and the "Running query" line. --quiet
|
||||
// suppresses the whole block. The EDNS0 state reflects the UDP size (the Ruby
|
||||
// source always printed "on" — a documented deviation we fix).
|
||||
func (f *textFormatter) WriteHeader(roots []traverse.StartServer) error {
|
||||
if f.cfg.Quiet {
|
||||
return nil
|
||||
}
|
||||
var b strings.Builder
|
||||
if f.cfg.Fast {
|
||||
b.WriteString("# Using fast mode\n")
|
||||
}
|
||||
if !f.cfg.AllRootServers {
|
||||
b.WriteString("# Limiting traverse to one root\n")
|
||||
}
|
||||
edns := "on"
|
||||
if f.cfg.UDPSize <= 512 {
|
||||
edns = "off"
|
||||
}
|
||||
fmt.Fprintf(&b, "# UDP size %d (EDNS0 is %s)\n", f.cfg.UDPSize, edns)
|
||||
fmt.Fprintf(&b, "# Retries %d, max depth %d\n", f.cfg.Retries, f.cfg.MaxDepth)
|
||||
fmt.Fprintf(&b, "# Allow TCP is %t, always TCP is %t\n", f.cfg.AllowTCP, f.cfg.AlwaysTCP)
|
||||
if len(roots) > 0 {
|
||||
ip := ""
|
||||
if len(roots[0].IPs) > 0 {
|
||||
ip = roots[0].IPs[0]
|
||||
}
|
||||
fmt.Fprintf(&b, "Using %s (%s) as initial root\n", roots[0].Name, ip)
|
||||
if f.cfg.AllRootServers {
|
||||
b.WriteString("All roots:\n")
|
||||
for _, root := range roots {
|
||||
fmt.Fprintf(&b, " %s %s\n", root.Name, strings.Join(root.IPs, ", "))
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(&b, "Running query %s type %s\n", f.cfg.Domain, f.cfg.QueryType)
|
||||
_, err := io.WriteString(f.w, b.String())
|
||||
return err
|
||||
}
|
||||
|
||||
func (f *textFormatter) WriteProgress(event traverse.TraversalEvent) error {
|
||||
if event.Stage != traverse.EventStart {
|
||||
ref := event.Referral
|
||||
if ref == nil || ref.IsRootRoot() {
|
||||
return nil
|
||||
}
|
||||
line := f.formatReferralLine(event.Result, false)
|
||||
if !event.Result.Referral.HasAddresses() {
|
||||
line += " -- resolving"
|
||||
switch event.Stage {
|
||||
case traverse.StageStart:
|
||||
line := f.referralTxt(ref)
|
||||
if !ref.Resolved() {
|
||||
line += " -- resolving"
|
||||
}
|
||||
return f.writeLine(line)
|
||||
case traverse.StageAnswerFast:
|
||||
return f.writeLine(fmt.Sprintf("%s -- completed earlier (%s)",
|
||||
f.referralTxt(ref), event.CompletedEarlier))
|
||||
case traverse.StageNewReferralSet:
|
||||
// One line per extra childset: the parent refid and the IP that
|
||||
// produced the children (progress_main :new_referral_set).
|
||||
refid := event.RefID
|
||||
if i := strings.LastIndex(refid, "."); i >= 0 {
|
||||
refid = refid[:i]
|
||||
}
|
||||
return f.writeLine(fmt.Sprintf("%s %s", refid, ref.ParentIP))
|
||||
case traverse.StageAnswer:
|
||||
if f.cfg.Verbose {
|
||||
for _, warning := range ref.Warnings {
|
||||
if err := f.writeLine(fmt.Sprintf("%s WARNING: %s", event.RefID, warning)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if f.cfg.ShowAllStats {
|
||||
return f.writeStatsBlocks(ref, fmt.Sprintf("%s Results:", event.RefID), false)
|
||||
}
|
||||
}
|
||||
return f.writeLine(line)
|
||||
return nil
|
||||
}
|
||||
|
||||
// WriteResolve renders progress for glue-resolution subtree nodes
|
||||
// (progress_resolves): like main progress, but the fast-mode marker carries
|
||||
// no refid.
|
||||
func (f *textFormatter) WriteResolve(event traverse.TraversalEvent) error {
|
||||
if event.Stage != traverse.EventStart {
|
||||
ref := event.Referral
|
||||
if ref == nil || ref.IsRootRoot() {
|
||||
return nil
|
||||
}
|
||||
return f.writeLine(f.formatReferralLine(event.Result, true))
|
||||
}
|
||||
|
||||
func (f *textFormatter) WriteResult(result traverse.TraversalResult) error {
|
||||
if result.Response == nil || result.Referral == nil {
|
||||
return nil
|
||||
switch event.Stage {
|
||||
case traverse.StageStart:
|
||||
return f.writeLine(f.referralTxt(ref))
|
||||
case traverse.StageAnswerFast:
|
||||
return f.writeLine(f.referralTxt(ref) + " -- completed earlier")
|
||||
}
|
||||
prefix := strings.Repeat(" ", result.Referral.Depth+1)
|
||||
line := prefix + f.formatResultLine(result)
|
||||
return f.writeLine(line)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *textFormatter) WriteSummary(results []traverse.TraversalResult) error {
|
||||
// referralTxt renders one progress row: "<refid> <server> (<ips>)"; verbose
|
||||
// adds "[qname]" and "<bailiwick>". Unresolved servers have no parens
|
||||
// (referral_txt_normal / referral_txt_verbose in bin/dnstraverse).
|
||||
func (f *textFormatter) referralTxt(ref *traverse.Referral) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(ref.RefID)
|
||||
if f.cfg.Verbose {
|
||||
fmt.Fprintf(&b, " [%s]", ref.Qname)
|
||||
}
|
||||
b.WriteString(" " + ref.Server)
|
||||
if ref.Resolved() {
|
||||
fmt.Fprintf(&b, " (%s)", ref.TxtIPs())
|
||||
}
|
||||
if f.cfg.Verbose {
|
||||
fmt.Fprintf(&b, " <%s>", ref.Bailiwick)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (f *textFormatter) WriteSummary(root *traverse.Referral, servers map[string][]string) error {
|
||||
// Blank line separating progress from the sections (bin/dnstraverse:
|
||||
// "puts if options[:progress]").
|
||||
if f.cfg.ShowProgress {
|
||||
if _, err := fmt.Fprintln(f.w); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if f.cfg.ShowServers {
|
||||
if err := f.writeServers(results); err != nil {
|
||||
if err := f.writeServers(servers); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if f.cfg.ShowResults {
|
||||
if err := f.writeResults(results); err != nil {
|
||||
if err := f.writeResults(root); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if f.cfg.ShowSummaryResults {
|
||||
if err := f.writeSummaryResults(results); err != nil {
|
||||
if err := f.writeSummaryResults(root); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -72,8 +162,10 @@ func (f *textFormatter) Flush() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *textFormatter) writeServers(results []traverse.TraversalResult) error {
|
||||
servers := collectServers(results)
|
||||
// writeServers renders "The following servers were encountered:" sorted by
|
||||
// lowercased reversed name (bin/dnstraverse); the name column is at least 16
|
||||
// characters wide.
|
||||
func (f *textFormatter) writeServers(servers map[string][]string) error {
|
||||
if len(servers) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -83,29 +175,26 @@ func (f *textFormatter) writeServers(results []traverse.TraversalResult) error {
|
||||
}
|
||||
|
||||
names := make([]string, 0, len(servers))
|
||||
width := 16
|
||||
for name := range servers {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Slice(names, func(i, j int) bool {
|
||||
return strings.ToLower(names[i]) > strings.ToLower(names[j])
|
||||
})
|
||||
|
||||
width := 16
|
||||
for _, name := range names {
|
||||
if len(name) > width {
|
||||
width = len(name)
|
||||
}
|
||||
}
|
||||
sort.Slice(names, func(i, j int) bool {
|
||||
return reverseString(strings.ToLower(names[i])) < reverseString(strings.ToLower(names[j]))
|
||||
})
|
||||
|
||||
for _, name := range names {
|
||||
for _, ip := range servers[name] {
|
||||
line := fmt.Sprintf("%*s: %-15s", width, name, ip)
|
||||
if f.cfg.ShowVersions {
|
||||
if version, ok := f.cfg.Fingerprints[ip]; ok && version != "" {
|
||||
line += " " + version
|
||||
line += " " + version
|
||||
}
|
||||
}
|
||||
if _, err := fmt.Fprintln(f.w, line); err != nil {
|
||||
if _, err := fmt.Fprintln(f.w, strings.TrimRight(line, " ")); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -114,129 +203,167 @@ func (f *textFormatter) writeServers(results []traverse.TraversalResult) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (f *textFormatter) writeResults(results []traverse.TraversalResult) error {
|
||||
func reverseString(s string) string {
|
||||
runes := []rune(s)
|
||||
for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
|
||||
runes[i], runes[j] = runes[j], runes[i]
|
||||
}
|
||||
return string(runes)
|
||||
}
|
||||
|
||||
func (f *textFormatter) writeResults(root *traverse.Referral) error {
|
||||
if root == nil {
|
||||
return nil
|
||||
}
|
||||
if _, err := fmt.Fprintln(f.w, "Results:"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
terminal := terminalResults(results)
|
||||
deduped := DeduplicateResults(terminal)
|
||||
for _, result := range deduped {
|
||||
prefix := strings.Repeat(" ", result.Referral.Depth+1)
|
||||
line := prefix + f.formatResultLine(result)
|
||||
if _, err := fmt.Fprintln(f.w, line); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := f.writeStatsBlocks(root, "", true); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := fmt.Fprintln(f.w)
|
||||
return err
|
||||
}
|
||||
|
||||
func (f *textFormatter) writeSummaryResults(results []traverse.TraversalResult) error {
|
||||
stats := ComputeSummary(results)
|
||||
// writeStatsBlocks renders every aggregated leaf of ref, sorted by stats key
|
||||
// (referral.rb stats_display). With spacing, blocks are separated by blank
|
||||
// lines.
|
||||
func (f *textFormatter) writeStatsBlocks(ref *traverse.Referral, prefix string, spacing bool) error {
|
||||
first := true
|
||||
for _, leaf := range ref.StatsList() {
|
||||
if spacing && !first {
|
||||
if _, err := fmt.Fprintln(f.w); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
first = false
|
||||
for _, line := range f.formatLeaf(leaf, prefix) {
|
||||
if err := f.writeLine(line); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *textFormatter) writeSummaryResults(root *traverse.Referral) error {
|
||||
stats := root.SummaryStats()
|
||||
if stats == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := fmt.Fprintln(f.w, "Summary:"); err != nil {
|
||||
if _, err := fmt.Fprintln(f.w, "Summary Results:"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
prefix := " "
|
||||
for _, answer := range stats.Answers {
|
||||
line := fmt.Sprintf("%s%s answered with %s", prefix, formatProbability(answer.Prob), answer.RData)
|
||||
initial := fmt.Sprintf("%s%s answered with ", prefix, formatProbability(answer.Prob))
|
||||
var rrs []string
|
||||
for _, rr := range answer.RRs {
|
||||
rrs = append(rrs, collapseWhitespace(rr.String()))
|
||||
}
|
||||
line := initial + strings.Join(rrs, "\n"+strings.Repeat(" ", len(initial)))
|
||||
if _, err := fmt.Fprintln(f.w, f.colorize(line, colorGreen)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
types := make([]string, 0, len(stats.ByType))
|
||||
for respType := range stats.ByType {
|
||||
types = append(types, respType)
|
||||
statuses := make([]traverse.Status, 0, len(stats.ByStatus))
|
||||
for status := range stats.ByStatus {
|
||||
if status != traverse.StatusAnswered {
|
||||
statuses = append(statuses, status)
|
||||
}
|
||||
}
|
||||
sort.Strings(types)
|
||||
sort.Slice(statuses, func(i, j int) bool { return statuses[i] < statuses[j] })
|
||||
|
||||
for _, respType := range types {
|
||||
line := fmt.Sprintf("%s%s %s", prefix, formatProbability(stats.ByType[respType]), summaryTypeLabel(respType))
|
||||
for _, status := range statuses {
|
||||
line := fmt.Sprintf("%s%s %s", prefix, formatProbability(stats.ByStatus[status]), summaryStatusLabel(status))
|
||||
if _, err := fmt.Fprintln(f.w, line); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
_, err := fmt.Fprintln(f.w)
|
||||
return err
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *textFormatter) formatReferralLine(result traverse.TraversalResult, isResolve bool) string {
|
||||
ref := result.Referral
|
||||
if ref == nil {
|
||||
return ""
|
||||
// formatLeaf renders one aggregated leaf per referral.rb stats_display:
|
||||
// "%5.1f%%: <wording>" plus indented RRs for answers and the
|
||||
// "While querying" line when the failing query differs from the original.
|
||||
func (f *textFormatter) formatLeaf(leaf *traverse.StatsEntry, prefix string) []string {
|
||||
resp := leaf.Response
|
||||
ref := leaf.Referral
|
||||
if resp == nil || ref == nil {
|
||||
return nil
|
||||
}
|
||||
indent := prefix + strings.Repeat(" ", 12)
|
||||
where := fmt.Sprintf("%s (%s)", ref.Server, resp.IP)
|
||||
head := fmt.Sprintf("%s%5.1f%%: ", prefix, leaf.Prob*100)
|
||||
|
||||
indent := strings.Repeat(" ", ref.Depth)
|
||||
refID := referralID(ref)
|
||||
server := referralServerLabel(ref, result.Response)
|
||||
qtype := dns.QNameType(ref.Qtype)
|
||||
qname := trimDomain(ref.Name)
|
||||
|
||||
if f.cfg.Verbose {
|
||||
bailiwick := trimDomain(ref.Bailiwick)
|
||||
if isResolve {
|
||||
return fmt.Sprintf("%s%s [%s] %s <%s>", indent, refID, qname, server, bailiwick)
|
||||
var lines []string
|
||||
switch resp.Status {
|
||||
case traverse.StatusException:
|
||||
msg := ""
|
||||
if resp.DQ != nil {
|
||||
msg = resp.DQ.ExceptionMessage
|
||||
}
|
||||
return fmt.Sprintf("%s%s [%s] %s <%s> (%s)", indent, refID, qname, server, bailiwick, qtype)
|
||||
}
|
||||
|
||||
if isResolve {
|
||||
return fmt.Sprintf("%s%s %s", indent, refID, server)
|
||||
}
|
||||
return fmt.Sprintf("%s%s %s (%s)", indent, refID, server, qtype)
|
||||
}
|
||||
|
||||
func (f *textFormatter) formatResultLine(result traverse.TraversalResult) string {
|
||||
prob := formatProbability(result.Referral.Prob)
|
||||
switch result.Response.Type {
|
||||
case traverse.RespAnswer:
|
||||
key, _ := answerKey(result.Response)
|
||||
if key == "" {
|
||||
return fmt.Sprintf("%s resulted in answer", prob)
|
||||
lines = append(lines, head+f.colorize(fmt.Sprintf("%s at %s", msg, where), colorRed))
|
||||
case traverse.StatusNoGlue:
|
||||
parent := ""
|
||||
if ref.Parent != nil {
|
||||
parent = ref.Parent.Server
|
||||
}
|
||||
nsLabel := ""
|
||||
if result.Referral.Bailiwick != "" && result.Referral.Bailiwick != "." {
|
||||
nsLabel = trimDomain(result.Referral.Bailiwick) + " "
|
||||
lines = append(lines, head+f.colorize(fmt.Sprintf("No glue at %s (%s) for %s", parent, resp.IP, ref.Server), colorYellow))
|
||||
case traverse.StatusReferralLame:
|
||||
parent := ""
|
||||
if ref.Parent != nil {
|
||||
parent = ref.Parent.Server
|
||||
}
|
||||
return f.colorize(fmt.Sprintf("%s %sanswered with %s", prob, nsLabel, key), colorGreen)
|
||||
case traverse.RespNODATA:
|
||||
return fmt.Sprintf("%s found no such record", prob)
|
||||
case traverse.RespNXDOMAIN:
|
||||
return f.colorize(fmt.Sprintf("%s name does not exist", prob), colorYellow)
|
||||
case traverse.RespSERVFAIL:
|
||||
return f.colorize(fmt.Sprintf("%s resulted in SERVFAIL", prob), colorRed)
|
||||
case traverse.RespREFUSED:
|
||||
return f.colorize(fmt.Sprintf("%s query refused by server", prob), colorRed)
|
||||
case traverse.RespNOTIMPL:
|
||||
return f.colorize(fmt.Sprintf("%s query type not implemented by server", prob), colorRed)
|
||||
case traverse.RespCNAMELoop:
|
||||
msg := "CNAME loop detected"
|
||||
if result.Response.ErrorMessage != "" {
|
||||
msg = result.Response.ErrorMessage
|
||||
lines = append(lines, head+f.colorize(fmt.Sprintf("Lame referral from %s (%s) to %s", parent, ref.ParentIP, where), colorYellow))
|
||||
case traverse.StatusLoop:
|
||||
lines = append(lines, head+f.colorize(fmt.Sprintf("Loop encountered at %s", resp.Server), colorRed))
|
||||
case traverse.StatusCNAMELoop:
|
||||
lines = append(lines, head+f.colorize(fmt.Sprintf("CNAME loop encountered at %s", resp.Server), colorRed))
|
||||
case traverse.StatusError:
|
||||
msg := ""
|
||||
if resp.DQ != nil {
|
||||
msg = resp.DQ.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
|
||||
lines = append(lines, head+f.colorize(fmt.Sprintf("%s at %s", msg, where), colorRed))
|
||||
case traverse.StatusNoData:
|
||||
lines = append(lines, head+fmt.Sprintf("NODATA (for this type) at %s", where))
|
||||
case traverse.StatusAnswered:
|
||||
lines = append(lines, head+f.colorize(fmt.Sprintf("Answer from %s", where), colorGreen))
|
||||
if resp.DQ != nil {
|
||||
for _, rr := range resp.DQ.Answers {
|
||||
lines = append(lines, indent+rr.String())
|
||||
}
|
||||
}
|
||||
return f.colorize(fmt.Sprintf("%s %s", prob, msg), colorYellow)
|
||||
case traverse.RespError:
|
||||
msg := "resulted in an error"
|
||||
if result.Response.ErrorMessage != "" {
|
||||
msg = result.Response.ErrorMessage
|
||||
}
|
||||
return f.colorize(fmt.Sprintf("%s %s", prob, msg), colorRed)
|
||||
default:
|
||||
return fmt.Sprintf("%s %s", prob, result.Response.Type)
|
||||
// The Ruby fallback prints "Stopped at <server> (<ip>))" with a stray
|
||||
// paren — a documented deviation we fix.
|
||||
lines = append(lines, head+fmt.Sprintf("Stopped at %s", where))
|
||||
lines = append(lines, indent+leaf.Key)
|
||||
}
|
||||
|
||||
if resp.Status != traverse.StatusAnswered {
|
||||
origQname, origQclass, origQtype := originalQuery(ref)
|
||||
if resp.Qname != origQname || resp.Qclass != origQclass || resp.Qtype != origQtype {
|
||||
lines = append(lines, indent+fmt.Sprintf("While querying %s/%s/%s",
|
||||
resp.Qname, traverse.ClassToString(resp.Qclass), traverse.TypeToString(resp.Qtype)))
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// originalQuery walks to the rootroot node to find the query the whole
|
||||
// traversal was started for.
|
||||
func originalQuery(ref *traverse.Referral) (string, uint16, uint16) {
|
||||
top := ref
|
||||
for top.Parent != nil {
|
||||
top = top.Parent
|
||||
}
|
||||
return top.Qname, top.Qclass, top.Qtype
|
||||
}
|
||||
|
||||
func (f *textFormatter) writeLine(line string) error {
|
||||
@@ -254,43 +381,6 @@ func (f *textFormatter) colorize(text, color string) string {
|
||||
return color + text + colorReset
|
||||
}
|
||||
|
||||
func referralID(ref *traverse.Referral) string {
|
||||
if ref == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%d", ref.Depth+1)
|
||||
}
|
||||
|
||||
func referralServerLabel(ref *traverse.Referral, resp *traverse.Response) string {
|
||||
if resp != nil && resp.Server != nil {
|
||||
return resp.Server.String()
|
||||
}
|
||||
if ref.HasAddresses() {
|
||||
ips := make([]string, 0, len(ref.Addresses))
|
||||
for _, addr := range ref.Addresses {
|
||||
ips = append(ips, addr.String())
|
||||
}
|
||||
return strings.Join(ips, ", ")
|
||||
}
|
||||
if ref.NSName != "" {
|
||||
return ref.NSName
|
||||
}
|
||||
if ref.Bailiwick != "" && ref.Bailiwick != "." {
|
||||
return trimDomain(ref.Bailiwick)
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func terminalResults(results []traverse.TraversalResult) []traverse.TraversalResult {
|
||||
var terminal []traverse.TraversalResult
|
||||
for _, result := range results {
|
||||
if result.Response != nil && result.Response.IsTerminal() {
|
||||
terminal = append(terminal, result)
|
||||
}
|
||||
}
|
||||
return terminal
|
||||
}
|
||||
|
||||
const (
|
||||
colorReset = "\033[0m"
|
||||
colorGreen = "\033[32m"
|
||||
|
||||
@@ -1,379 +0,0 @@
|
||||
package output
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.hansenits.com.au/hits/ExploreDNS/internal/dns"
|
||||
"gitea.hansenits.com.au/hits/ExploreDNS/internal/traverse"
|
||||
miekgdns "github.com/miekg/dns"
|
||||
)
|
||||
|
||||
func TestTextFormatterProgressIndentation(t *testing.T) {
|
||||
root := traverse.NewReferral("example.com.", dns.TypeA, ".", 0, 1.0, nil)
|
||||
child := traverse.NewReferral("example.com.", dns.TypeA, "com.", 1, 0.5, root)
|
||||
|
||||
var buf bytes.Buffer
|
||||
cfg := DefaultConfig()
|
||||
cfg.Color = false
|
||||
formatter := newTextFormatter(cfg, &buf)
|
||||
|
||||
if err := formatter.WriteProgress(traverse.TraversalEvent{
|
||||
Stage: traverse.EventStart,
|
||||
Result: traverse.TraversalResult{Referral: child},
|
||||
}); err != nil {
|
||||
t.Fatalf("WriteProgress: %v", err)
|
||||
}
|
||||
|
||||
out := buf.String()
|
||||
if !strings.HasPrefix(out, " 2 ") {
|
||||
t.Fatalf("expected depth-based indentation, got %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachHooksRespectsShowFlags(t *testing.T) {
|
||||
ref := traverse.NewReferral("example.com.", dns.TypeA, ".", 0, 1.0, nil)
|
||||
var progressCount int
|
||||
|
||||
cfg := DefaultConfig()
|
||||
cfg.ShowProgress = false
|
||||
cfg.ShowResolves = false
|
||||
cfg.ShowAllStats = false
|
||||
|
||||
var buf bytes.Buffer
|
||||
formatter := NewFormatter(cfg, &buf)
|
||||
hooks := AttachHooks(cfg, formatter)
|
||||
hooks.OnEvent(traverse.TraversalEvent{
|
||||
Stage: traverse.EventStart,
|
||||
Result: traverse.TraversalResult{Referral: ref},
|
||||
})
|
||||
|
||||
if buf.Len() != 0 {
|
||||
t.Fatalf("expected no output when ShowProgress is false")
|
||||
}
|
||||
|
||||
cfg.ShowProgress = true
|
||||
hooks = AttachHooks(cfg, formatter)
|
||||
hooks.OnEvent(traverse.TraversalEvent{
|
||||
Stage: traverse.EventStart,
|
||||
Result: traverse.TraversalResult{Referral: ref},
|
||||
})
|
||||
progressCount = strings.Count(buf.String(), "\n")
|
||||
if progressCount == 0 {
|
||||
t.Fatal("expected progress output when ShowProgress is true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextWriteResolve(t *testing.T) {
|
||||
ref := traverse.NewReferral("example.com.", dns.TypeA, ".", 0, 1.0, nil)
|
||||
server := net.ParseIP("198.41.0.4")
|
||||
resp := &traverse.Response{Server: server, Type: traverse.RespAnswer}
|
||||
|
||||
var buf bytes.Buffer
|
||||
cfg := DefaultConfig()
|
||||
cfg.Color = false
|
||||
f := newTextFormatter(cfg, &buf)
|
||||
|
||||
// EventStart - should write line
|
||||
if err := f.WriteResolve(traverse.TraversalEvent{
|
||||
Stage: traverse.EventStart,
|
||||
Result: traverse.TraversalResult{Referral: ref, Response: resp},
|
||||
}); err != nil {
|
||||
t.Fatalf("WriteResolve EventStart: %v", err)
|
||||
}
|
||||
if buf.Len() == 0 {
|
||||
t.Error("expected output for WriteResolve EventStart")
|
||||
}
|
||||
|
||||
buf.Reset()
|
||||
// EventComplete - should write nothing
|
||||
if err := f.WriteResolve(traverse.TraversalEvent{
|
||||
Stage: traverse.EventComplete,
|
||||
Result: traverse.TraversalResult{Referral: ref, Response: resp},
|
||||
}); err != nil {
|
||||
t.Fatalf("WriteResolve EventComplete: %v", err)
|
||||
}
|
||||
if buf.Len() != 0 {
|
||||
t.Error("expected no output for WriteResolve EventComplete")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextWriteResult(t *testing.T) {
|
||||
ref := traverse.NewReferral("example.com.", dns.TypeA, ".", 0, 1.0, nil)
|
||||
server := net.ParseIP("198.41.0.4")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
respType traverse.ResponseType
|
||||
msg *dns.DecodedResponse
|
||||
errorMsg string
|
||||
}{
|
||||
{"answer", traverse.RespAnswer, &dns.DecodedResponse{
|
||||
Answers: []miekgdns.RR{
|
||||
&miekgdns.A{Hdr: miekgdns.RR_Header{Name: "example.com.", Rrtype: dns.TypeA, Class: miekgdns.ClassINET}, A: net.ParseIP("1.2.3.4")},
|
||||
},
|
||||
}, ""},
|
||||
{"nodata", traverse.RespNODATA, nil, ""},
|
||||
{"nxdomain", traverse.RespNXDOMAIN, nil, ""},
|
||||
{"servfail", traverse.RespSERVFAIL, nil, ""},
|
||||
{"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"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
cfg := DefaultConfig()
|
||||
cfg.Color = false
|
||||
f := newTextFormatter(cfg, &buf)
|
||||
|
||||
resp := &traverse.Response{
|
||||
Referral: ref,
|
||||
Server: server,
|
||||
Type: tc.respType,
|
||||
Decoded: tc.msg,
|
||||
ErrorMessage: tc.errorMsg,
|
||||
}
|
||||
result := traverse.TraversalResult{Referral: ref, Response: resp}
|
||||
if err := f.WriteResult(result); err != nil {
|
||||
t.Fatalf("WriteResult %q: %v", tc.name, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextWriteResultNilResponse(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
cfg := DefaultConfig()
|
||||
f := newTextFormatter(cfg, &buf)
|
||||
if err := f.WriteResult(traverse.TraversalResult{Referral: nil, Response: nil}); err != nil {
|
||||
t.Fatalf("WriteResult nil: %v", err)
|
||||
}
|
||||
if buf.Len() != 0 {
|
||||
t.Error("expected no output for nil result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextWriteResultAnswerMultipleRRs(t *testing.T) {
|
||||
ref := traverse.NewReferral("example.com.", dns.TypeA, ".", 0, 0.5, nil)
|
||||
resp := &traverse.Response{
|
||||
Referral: ref,
|
||||
Server: net.ParseIP("1.2.3.4"),
|
||||
Type: traverse.RespAnswer,
|
||||
Decoded: &dns.DecodedResponse{
|
||||
Answers: []miekgdns.RR{
|
||||
&miekgdns.A{Hdr: miekgdns.RR_Header{Name: "example.com.", Rrtype: dns.TypeA, Class: miekgdns.ClassINET}, A: net.ParseIP("1.2.3.4")},
|
||||
&miekgdns.A{Hdr: miekgdns.RR_Header{Name: "example.com.", Rrtype: dns.TypeA, Class: miekgdns.ClassINET}, A: net.ParseIP("5.6.7.8")},
|
||||
},
|
||||
},
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
cfg := DefaultConfig()
|
||||
cfg.Color = false
|
||||
f := newTextFormatter(cfg, &buf)
|
||||
if err := f.WriteResult(traverse.TraversalResult{Referral: ref, Response: resp}); err != nil {
|
||||
t.Fatalf("WriteResult: %v", err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "/") {
|
||||
t.Errorf("expected '/' separator for multiple answers, got: %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextWriteSummaryWithServersAndResults(t *testing.T) {
|
||||
ref := traverse.NewReferral("example.com.", dns.TypeA, "com.", 1, 1.0, nil)
|
||||
server := net.ParseIP("1.2.3.4")
|
||||
resp := &traverse.Response{
|
||||
Referral: ref,
|
||||
Server: server,
|
||||
Type: traverse.RespAnswer,
|
||||
Decoded: &dns.DecodedResponse{
|
||||
Answers: []miekgdns.RR{
|
||||
&miekgdns.A{Hdr: miekgdns.RR_Header{Name: "example.com.", Rrtype: dns.TypeA, Class: miekgdns.ClassINET}, A: net.ParseIP("1.2.3.4")},
|
||||
},
|
||||
},
|
||||
}
|
||||
results := []traverse.TraversalResult{{Referral: ref, Response: resp}}
|
||||
|
||||
var buf bytes.Buffer
|
||||
cfg := DefaultConfig()
|
||||
cfg.Color = false
|
||||
cfg.ShowServers = true
|
||||
cfg.ShowResults = true
|
||||
cfg.ShowSummaryResults = true
|
||||
f := newTextFormatter(cfg, &buf)
|
||||
if err := f.WriteSummary(results); err != nil {
|
||||
t.Fatalf("WriteSummary: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "Summary:") {
|
||||
t.Errorf("expected Summary: in output, got: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "Results:") {
|
||||
t.Errorf("expected Results: in output, got: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextWriteSummaryNoResults(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
cfg := DefaultConfig()
|
||||
cfg.ShowServers = false
|
||||
cfg.ShowResults = false
|
||||
cfg.ShowSummaryResults = false
|
||||
f := newTextFormatter(cfg, &buf)
|
||||
if err := f.WriteSummary(nil); err != nil {
|
||||
t.Fatalf("WriteSummary nil: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextWriteSummaryNXDOMAIN(t *testing.T) {
|
||||
ref := traverse.NewReferral("gone.example.com.", dns.TypeA, "com.", 1, 1.0, nil)
|
||||
server := net.ParseIP("1.2.3.4")
|
||||
resp := &traverse.Response{
|
||||
Referral: ref,
|
||||
Server: server,
|
||||
Type: traverse.RespNXDOMAIN,
|
||||
}
|
||||
results := []traverse.TraversalResult{{Referral: ref, Response: resp}}
|
||||
|
||||
var buf bytes.Buffer
|
||||
cfg := DefaultConfig()
|
||||
cfg.Color = false
|
||||
cfg.ShowServers = true
|
||||
cfg.ShowResults = true
|
||||
cfg.ShowSummaryResults = true
|
||||
f := newTextFormatter(cfg, &buf)
|
||||
if err := f.WriteSummary(results); err != nil {
|
||||
t.Fatalf("WriteSummary: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatReferralLineVerbose(t *testing.T) {
|
||||
root := traverse.NewReferral("example.com.", dns.TypeA, ".", 0, 1.0, nil)
|
||||
child := traverse.NewReferral("example.com.", dns.TypeA, "com.", 1, 1.0, root)
|
||||
|
||||
var buf bytes.Buffer
|
||||
cfg := DefaultConfig()
|
||||
cfg.Color = false
|
||||
cfg.Verbose = true
|
||||
f := newTextFormatter(cfg, &buf)
|
||||
|
||||
event := traverse.TraversalEvent{
|
||||
Stage: traverse.EventStart,
|
||||
Result: traverse.TraversalResult{Referral: child},
|
||||
}
|
||||
if err := f.WriteProgress(event); err != nil {
|
||||
t.Fatalf("WriteProgress verbose: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "com") {
|
||||
t.Errorf("expected bailiwick in verbose output, got: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatReferralLineVerboseResolve(t *testing.T) {
|
||||
ref := traverse.NewReferral("example.com.", dns.TypeA, "com.", 1, 1.0, nil)
|
||||
server := net.ParseIP("1.2.3.4")
|
||||
resp := &traverse.Response{Server: server, Type: traverse.RespAnswer}
|
||||
|
||||
var buf bytes.Buffer
|
||||
cfg := DefaultConfig()
|
||||
cfg.Color = false
|
||||
cfg.Verbose = true
|
||||
f := newTextFormatter(cfg, &buf)
|
||||
|
||||
if err := f.WriteResolve(traverse.TraversalEvent{
|
||||
Stage: traverse.EventStart,
|
||||
Result: traverse.TraversalResult{Referral: ref, Response: resp},
|
||||
}); err != nil {
|
||||
t.Fatalf("WriteResolve verbose: %v", err)
|
||||
}
|
||||
if buf.Len() == 0 {
|
||||
t.Error("expected output for verbose WriteResolve")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextWriteProgressNoAddresses(t *testing.T) {
|
||||
// Test the "resolving" suffix when referral has no addresses
|
||||
ref := traverse.NewReferral("example.com.", dns.TypeA, ".", 0, 1.0, nil)
|
||||
// No addresses set, so HasAddresses() returns false
|
||||
|
||||
var buf bytes.Buffer
|
||||
cfg := DefaultConfig()
|
||||
cfg.Color = false
|
||||
f := newTextFormatter(cfg, &buf)
|
||||
if err := f.WriteProgress(traverse.TraversalEvent{
|
||||
Stage: traverse.EventStart,
|
||||
Result: traverse.TraversalResult{Referral: ref},
|
||||
}); err != nil {
|
||||
t.Fatalf("WriteProgress: %v", err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "resolving") {
|
||||
t.Errorf("expected 'resolving' suffix when no addresses, got: %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestColorize(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
cfg := DefaultConfig()
|
||||
cfg.Color = true
|
||||
f := newTextFormatter(cfg, &buf)
|
||||
|
||||
colored := f.colorize("hello", colorGreen)
|
||||
if colored == "hello" {
|
||||
t.Error("expected colorized output with Color=true")
|
||||
}
|
||||
|
||||
cfg.Color = false
|
||||
f2 := newTextFormatter(cfg, &buf)
|
||||
plain := f2.colorize("hello", colorGreen)
|
||||
if plain != "hello" {
|
||||
t.Errorf("expected plain text with Color=false, got %q", plain)
|
||||
}
|
||||
|
||||
// Empty color
|
||||
empty := f.colorize("hello", "")
|
||||
if empty != "hello" {
|
||||
t.Errorf("expected plain text for empty color, got %q", empty)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReferralServerLabelFallbacks(t *testing.T) {
|
||||
// With server IP in response
|
||||
ref := traverse.NewReferral("example.com.", dns.TypeA, ".", 0, 1.0, nil)
|
||||
resp := &traverse.Response{Server: net.ParseIP("1.2.3.4")}
|
||||
label := referralServerLabel(ref, resp)
|
||||
if label != "1.2.3.4" {
|
||||
t.Errorf("expected '1.2.3.4', got %q", label)
|
||||
}
|
||||
|
||||
// With addresses in referral, no response server
|
||||
ref2 := traverse.NewReferral("example.com.", dns.TypeA, "ns1.example.com.", 0, 1.0, nil)
|
||||
ref2.Addresses = []net.IP{net.ParseIP("5.6.7.8")}
|
||||
label2 := referralServerLabel(ref2, nil)
|
||||
if label2 != "5.6.7.8" {
|
||||
t.Errorf("expected '5.6.7.8', got %q", label2)
|
||||
}
|
||||
|
||||
// With NSName
|
||||
ref3 := &traverse.Referral{
|
||||
Name: "example.com.",
|
||||
NSName: "ns1.example.com.",
|
||||
Bailiwick: ".",
|
||||
}
|
||||
label3 := referralServerLabel(ref3, nil)
|
||||
if label3 != "ns1.example.com." {
|
||||
t.Errorf("expected NSName, got %q", label3)
|
||||
}
|
||||
|
||||
// With non-root bailiwick, no addresses, no NSName
|
||||
ref4 := traverse.NewReferral("example.com.", dns.TypeA, "com.", 0, 1.0, nil)
|
||||
label4 := referralServerLabel(ref4, nil)
|
||||
if label4 != "com" {
|
||||
t.Errorf("expected 'com', got %q", label4)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user