diff --git a/README.md b/README.md index 13c9ebb..8e51883 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 keep + their letter case; they are not lower-cased. - 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..f89d73d 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 keep +// their letter case. 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)) + }) + } +}