From 354db6b5240b0e39380d939e46c9de25aa8dd3ab Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Mon, 28 Sep 2026 21:16:07 +0000 Subject: [PATCH] resolver: lower-case DNS names in record values (closes #157) Nameservers may answer with DNS names in any letter case. For eeqj.de, y.ns.joker.com answers in upper case while its peers answer in lower case, so the inconsistency check fired on every cycle. extractRecordValue now lower-cases CNAME, MX, SRV and NS targets, so the inconsistency check and the record-change check both compare names regardless of case. A, AAAA, TXT and CAA values are unchanged. State saved before this change can hold upper-case names, which report a one-time record change on the first check after upgrading. Model: opus-5-5 --- README.md | 4 ++ TODO.md | 3 ++ internal/resolver/export_test.go | 8 ++++ internal/resolver/iterative.go | 13 +++--- internal/resolver/iterative_test.go | 62 +++++++++++++++++++++++++++++ 5 files changed, 85 insertions(+), 5 deletions(-) create mode 100644 internal/resolver/export_test.go create mode 100644 internal/resolver/iterative_test.go diff --git a/README.md b/README.md index 13c9ebb..800a38f 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,10 @@ rejected. record types: A, AAAA, CNAME, MX, TXT, SRV, CAA, NS. - Stores results **per nameserver**. The state for a hostname is not a merged view — it is a map from nameserver to record set. +- DNS names inside record values (CNAME, MX, SRV and NS targets) are + stored in lower case, because names are case-insensitive and + nameservers may answer in any letter case. TXT and CAA values are + stored exactly as received. - Any observable change in any nameserver's response triggers a notification. This includes: - **Record change**: A nameserver returns different records than it diff --git a/TODO.md b/TODO.md index 575b959..36a1c9a 100644 --- a/TODO.md +++ b/TODO.md @@ -23,6 +23,9 @@ Rationale, Design, TODO, License, Author) if any are still missing. # Completed Steps +- 2026-09-28: DNS names in record values (CNAME, MX, SRV and NS targets) are + lower-cased, so nameservers that answer in different letter case no longer + count as inconsistent or as a record change (closes #157). - 2026-09-28: `script/cibuild` and `script/docker` now pass `--no-cache-filter=lint,builder` so lint and tests run every build (closes #115). diff --git a/internal/resolver/export_test.go b/internal/resolver/export_test.go new file mode 100644 index 0000000..6f4f9f1 --- /dev/null +++ b/internal/resolver/export_test.go @@ -0,0 +1,8 @@ +package resolver + +import "github.com/miekg/dns" + +// ExtractRecordValue exports extractRecordValue for testing. +func ExtractRecordValue(rr dns.RR) string { + return extractRecordValue(rr) +} diff --git a/internal/resolver/iterative.go b/internal/resolver/iterative.go index eebab39..4fd6f58 100644 --- a/internal/resolver/iterative.go +++ b/internal/resolver/iterative.go @@ -608,7 +608,10 @@ func classifyResponse(resp *NameserverResponse, state queryState) { } } -// extractRecordValue formats a DNS RR value as a string. +// extractRecordValue formats a DNS RR value as a string. DNS names +// are case-insensitive and nameservers may answer in any letter case, +// so names are lower-cased to compare equal. TXT and CAA values are +// kept exactly as received. func extractRecordValue(rr dns.RR) string { switch r := rr.(type) { case *dns.A: @@ -616,22 +619,22 @@ func extractRecordValue(rr dns.RR) string { case *dns.AAAA: return r.AAAA.String() case *dns.CNAME: - return r.Target + return strings.ToLower(r.Target) case *dns.MX: - return fmt.Sprintf("%d %s", r.Preference, r.Mx) + return fmt.Sprintf("%d %s", r.Preference, strings.ToLower(r.Mx)) case *dns.TXT: return strings.Join(r.Txt, "") case *dns.SRV: return fmt.Sprintf( "%d %d %d %s", - r.Priority, r.Weight, r.Port, r.Target, + r.Priority, r.Weight, r.Port, strings.ToLower(r.Target), ) case *dns.CAA: return fmt.Sprintf( "%d %s \"%s\"", r.Flag, r.Tag, r.Value, ) case *dns.NS: - return r.Ns + return strings.ToLower(r.Ns) default: return "" } diff --git a/internal/resolver/iterative_test.go b/internal/resolver/iterative_test.go new file mode 100644 index 0000000..781e90b --- /dev/null +++ b/internal/resolver/iterative_test.go @@ -0,0 +1,62 @@ +package resolver_test + +import ( + "testing" + + "github.com/miekg/dns" + "github.com/stretchr/testify/assert" + + "sneak.berlin/go/dnswatcher/internal/resolver" +) + +func TestExtractRecordValue_LetterCase(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + rr dns.RR + want string + }{ + { + name: "MX target lower-cased", + rr: &dns.MX{Preference: 1, Mx: "ASPMX.L.GOOGLE.COM."}, + want: "1 aspmx.l.google.com.", + }, + { + name: "NS target lower-cased", + rr: &dns.NS{Ns: "x.ns.joker.COM."}, + want: "x.ns.joker.com.", + }, + { + name: "CNAME target lower-cased", + rr: &dns.CNAME{Target: "WWW.Example.Com."}, + want: "www.example.com.", + }, + { + name: "SRV target lower-cased", + rr: &dns.SRV{ + Priority: 10, Weight: 5, Port: 443, + Target: "SIP.Example.Com.", + }, + want: "10 5 443 sip.example.com.", + }, + { + name: "TXT value keeps its case", + rr: &dns.TXT{Txt: []string{"Verify=AbC123"}}, + want: "Verify=AbC123", + }, + { + name: "CAA value keeps its case", + rr: &dns.CAA{Flag: 0, Tag: "issue", Value: "LetsEncrypt.org"}, + want: `0 issue "LetsEncrypt.org"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tt.want, resolver.ExtractRecordValue(tt.rr)) + }) + } +}