Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 147 additions & 23 deletions internal/dnsinspect/dnsinspect.go
Original file line number Diff line number Diff line change
Expand Up @@ -1111,16 +1111,10 @@ func safeRecordText(text string) string {
}

func formatCAA(raw []byte) string {
if len(raw) < 2 {
return "0x" + hex.EncodeToString(raw)
}
tagLen := int(raw[1])
if len(raw) < 2+tagLen {
flags, tag, value, ok := caaFields(raw)
if !ok {
return "0x" + hex.EncodeToString(raw)
}
flags := raw[0]
tag := string(raw[2 : 2+tagLen])
value := string(raw[2+tagLen:])
return fmt.Sprintf("%d %s %q", flags, safeRecordText(tag), value)
}

Expand Down Expand Up @@ -1774,17 +1768,131 @@ func renderSection(p *core.Printer, name string, records []record) {
p.WriteString("\n")

for i, rec := range records {
if rec.typ == dnsmessage.TypeTXT && len(rec.txt) > 1 {
renderTXTRecord(p, rec, i == len(records)-1)
continue
last := i == len(records)-1
switch {
case rec.typ == dnsmessage.TypeTXT && len(rec.txt) > 1:
renderTXTRecord(p, rec, last)
case rec.hasComplexRendering():
renderComplexRecord(p, rec, last)
default:
renderRecordLine(p, rec, last)
}
renderRecordLine(p, rec, i == len(records)-1)
}

p.WriteInfoPrefix()
p.WriteString("\n")
}

func (rec record) hasComplexRendering() bool {
switch rec.typ {
case dnsmessage.TypeMX:
return rec.target != ""
case dnsmessage.TypeSRV:
return rec.target != ""
case dnsmessage.TypeSOA:
return rec.target != "" && rec.target2 != ""
case dnsTypeCAA:
_, _, _, ok := caaFields(rec.rawRData)
return ok
default:
return false
}
}

// renderComplexRecord keeps the structured fields of complex resource records
// visible. The first line identifies the owner and target (when one exists),
// while the indented fields explain the numeric and type-specific values.
func renderComplexRecord(p *core.Printer, rec record, last bool) {
writeRecordPrefix(p, last)
p.Set(core.Green)
if rec.owner != "" {
p.WriteString(core.TerminalSafeText(rec.owner))
if rec.typ != dnsmessage.TypeSOA && rec.typ != dnsTypeCAA {
p.WriteString(" → ")
}
}
switch rec.typ {
case dnsmessage.TypeMX:
p.WriteString(core.TerminalSafeText(rec.target))
case dnsmessage.TypeSRV:
p.WriteString(core.TerminalSafeText(rec.target))
p.WriteString(fmt.Sprintf(":%d", rec.port))
case dnsmessage.TypeSOA, dnsTypeCAA:
// These records list their semantic values on indented lines below.
}
p.Reset()
p.WriteString("\n")

continued := !last
switch rec.typ {
case dnsmessage.TypeMX:
writeRecordDetail(p, "Priority", strconv.FormatUint(uint64(rec.preference), 10), continued)
case dnsmessage.TypeSRV:
writeRecordDetail(p, "Priority", strconv.FormatUint(uint64(rec.priority), 10), continued)
writeRecordDetail(p, "Weight", strconv.FormatUint(uint64(rec.weight), 10), continued)
case dnsmessage.TypeSOA:
writeRecordDetail(p, "Primary NS", rec.target, continued)
writeRecordDetail(p, "Responsible", rec.target2, continued)
writeRecordDetail(p, "Serial", strconv.FormatUint(uint64(rec.soa[0]), 10), continued)
writeRecordDetail(p, "Refresh", formatTTL(rec.soa[1]), continued)
writeRecordDetail(p, "Retry", formatTTL(rec.soa[2]), continued)
writeRecordDetail(p, "Expire", formatTTL(rec.soa[3]), continued)
writeRecordDetail(p, "Minimum TTL", formatTTL(rec.soa[4]), continued)
case dnsTypeCAA:
flags, tag, value, ok := caaFields(rec.rawRData)
if !ok {
renderRecordLine(p, rec, last)
return
}
writeRecordDetail(p, "Flags", strconv.Itoa(int(flags)), continued)
writeRecordDetail(p, "Tag", tag, continued)
writeRecordDetail(p, "Value", value, continued)
}
writeRecordSourceAndTTL(p, rec, continued)
}

func writeRecordDetail(p *core.Printer, label, value string, continued bool) {
writeRecordContinuationPrefix(p, continued)
p.WriteString(label)
p.WriteString(": ")
p.WriteString(core.TerminalSafeText(safeRecordText(value)))
p.WriteString("\n")
}

// writeRecordContinuationPrefix keeps detail lines connected to the record
// branch. Without the vertical continuation, the indentation looks like a
// large gap between the tree marker and the field text.
func writeRecordContinuationPrefix(p *core.Printer, continued bool) {
p.WriteInfoPrefix()
if continued {
p.WriteString(" \u2502 ")
return
}
p.WriteString(" ")
}

func writeRecordSourceAndTTL(p *core.Printer, rec record, continued bool) {
if rec.source == recordSourcePlatform {
writeRecordDetail(p, "Source", "platform resolver", continued)
}
if rec.hasTTL {
writeRecordDetail(p, "TTL", formatTTL(rec.ttl), continued)
} else {
writeRecordDetail(p, "TTL", "unavailable", continued)
}
}

func caaFields(raw []byte) (flags uint8, tag, value string, ok bool) {
if len(raw) < 2 {
return 0, "", "", false
}
tagLen := int(raw[1])
if tagLen > len(raw)-2 {
return 0, "", "", false
}
return raw[0], string(raw[2 : 2+tagLen]), string(raw[2+tagLen:]), true
}

func formatTXTChunk(chunk []byte) string {
// strconv.Quote escapes controls, invalid UTF-8, and quotes, so TXT data
// cannot inject terminal control sequences or output lines.
Expand All @@ -1804,16 +1912,14 @@ func renderTXTRecord(p *core.Printer, rec record, last bool) {
p.WriteString("\n")

for _, chunk := range rec.txt {
p.WriteInfoPrefix()
p.WriteString(" ")
writeRecordContinuationPrefix(p, !last)
p.Set(core.Green)
p.WriteString(formatTXTChunk(chunk))
p.Reset()
p.WriteString("\n")
}

p.WriteInfoPrefix()
p.WriteString(" ")
writeRecordContinuationPrefix(p, !last)
p.Set(core.Dim)
if rec.source == recordSourcePlatform {
p.WriteString("Source: platform resolver; ")
Expand Down Expand Up @@ -1883,15 +1989,33 @@ func formatDuration(d time.Duration) string {
}

func formatTTL(ttl uint32) string {
if ttl == 1 {
return "1s"
if ttl == 0 {
return "0s"
}

// DNS TTLs are seconds. Use compact whole-unit components so SOA
// durations such as expire=604800 are readable as 1w instead of 168h.
remaining := uint64(ttl)
units := []struct {
seconds uint64
suffix string
}{
{7 * 24 * 60 * 60, "w"},
{24 * 60 * 60, "d"},
{60 * 60, "h"},
{60, "m"},
{1, "s"},
}
d := time.Duration(ttl) * time.Second
if ttl < 60 {
return d.String()
var b strings.Builder
for _, unit := range units {
if remaining < unit.seconds {
continue
}
count := remaining / unit.seconds
remaining %= unit.seconds
fmt.Fprintf(&b, "%d%s", count, unit.suffix)
}
text := strings.TrimSuffix(d.String(), "0s")
return strings.TrimSuffix(text, "0m")
return b.String()
}

func flushInspectionOutput(output, errorOutput *core.Printer) int {
Expand Down
68 changes: 63 additions & 5 deletions internal/dnsinspect/dnsinspect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1159,9 +1159,11 @@ func TestRenderUsesTypedDNSRecordData(t *testing.T) {
out := string(p.Bytes())
for _, want := range []string{
"192.0.2.1", "2001:db8::1", "alias.example.", "\"first\"\n", "\"second\"\n",
"10 mail.example.", "ns1.example.",
"ns1.example. hostmaster.example. serial=2026082901 refresh=3600 retry=600 expire=604800 minttl=300",
"10 5 443 service.example.", `0 issue "letsencrypt.org"`, "0 .", "1 . ALPN=h2",
"Priority: 10", "mail.example.", "ns1.example.",
"Primary NS: ns1.example.", "Responsible: hostmaster.example.",
"Serial: 2026082901", "Refresh: 1h", "Retry: 10m", "Expire: 1w", "Minimum TTL: 5m",
"Weight: 5", "service.example.:443",
"Flags: 0", "Tag: issue", "Value: letsencrypt.org", "0 .", "1 . ALPN=h2",
"TYPE99", "0xdead",
} {
if !strings.Contains(out, want) {
Expand All @@ -1170,6 +1172,62 @@ func TestRenderUsesTypedDNSRecordData(t *testing.T) {
}
}

func TestRenderComplexRecordsUseLabeledFields(t *testing.T) {
p := core.TestPrinter(false)
rawCAA := append([]byte{1, 5}, []byte("issueacme.org")...)
render(p, &result{host: "example.com", records: map[string][]record{
"MX": {{
owner: "example.com.", typ: dnsmessage.TypeMX, preference: 10,
target: "mail.example.com.", ttl: 3600, hasTTL: true,
}},
"SRV": {{
owner: "_https._tcp.example.com.", typ: dnsmessage.TypeSRV,
priority: 20, weight: 5, port: 443, target: "service.example.com.",
ttl: 300, hasTTL: true,
}},
"SOA": {{
owner: "example.com.", typ: dnsmessage.TypeSOA,
target: "ns1.example.com.", target2: "hostmaster.example.com.",
soa: [5]uint32{2026082901, 3600, 600, 604800, 300}, ttl: 3600, hasTTL: true,
}},
"CAA": {{
owner: "example.com.", typ: dnsTypeCAA, rawRData: rawCAA,
ttl: 3600, hasTTL: true,
}},
}})

out := string(p.Bytes())
for _, want := range []string{
"example.com. → mail.example.com.", " Priority: 10", " TTL: 1h",
"_https._tcp.example.com. → service.example.com.:443", " Weight: 5",
"example.com.\n", "Primary NS: ns1.example.com.", "Responsible: hostmaster.example.com.",
"Serial: 2026082901", "Refresh: 1h", "Retry: 10m", "Expire: 1w", "Minimum TTL: 5m",
"Flags: 1", "Tag: issue", "Value: acme.org",
} {
if !strings.Contains(out, want) {
t.Fatalf("complex record output missing %q:\n%s", want, out)
}
}
}

func TestRenderComplexRecordTreeContinuationStopsAtLastRecord(t *testing.T) {
p := core.TestPrinter(false)
render(p, &result{host: "example.com", records: map[string][]record{
"MX": {
{typ: dnsmessage.TypeMX, preference: 2, target: "first.example."},
{typ: dnsmessage.TypeMX, preference: 10, target: "last.example."},
},
}})

out := string(p.Bytes())
if !strings.Contains(out, " │ Priority: 2") {
t.Fatalf("non-final record details lost tree continuation:\n%s", out)
}
if strings.Contains(out, " │ Priority: 10") || !strings.Contains(out, " Priority: 10") {
t.Fatalf("final record details retained tree continuation:\n%s", out)
}
}

func TestAggregateKeepsDistinctDOHPresentationRecords(t *testing.T) {
out := &result{records: make(map[string][]record)}
results := []queryResult{
Expand Down Expand Up @@ -1265,8 +1323,8 @@ func TestRenderSortsTypedNumericFieldsNumerically(t *testing.T) {
},
}})
out := string(p.Bytes())
two := strings.Index(out, "2 two.example.")
ten := strings.Index(out, "10 ten.example.")
two := strings.Index(out, "two.example.")
ten := strings.Index(out, "ten.example.")
if two < 0 || ten < 0 || two > ten {
t.Fatalf("MX records are not sorted by numeric preference:\n%s", out)
}
Expand Down
Loading