From c5c13960d52d4b398d3529046c3c45021fbd8fdd Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 16 May 2026 21:35:53 +0800 Subject: [PATCH] checker: add dname_coexistence rule and refactor sibling probing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract querySiblings from observeCoexistence so both CNAME and DNAME coexistence checks share the same parallel RRset scan. Add observeDNAMECoexistence (called from Collect) that populates AliasData.DNAMECoexistence for each DNAME node in DNAMESubstitutions. Add the dname_coexistence rule (RFC 6672 §2.3) that flags any sibling RRsets at a DNAME owner as CRIT, with matching tests. --- checker/collect.go | 54 ++++++++++++++++++++++++++++-------- checker/definition.go | 1 + checker/rules_coexistence.go | 35 +++++++++++++++++++++++ checker/rules_test.go | 32 +++++++++++++++++++++ checker/types.go | 2 ++ 5 files changed, 112 insertions(+), 12 deletions(-) diff --git a/checker/collect.go b/checker/collect.go index 5658218..c3f2a15 100644 --- a/checker/collect.go +++ b/checker/collect.go @@ -49,6 +49,7 @@ func (p *aliasProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (a } observeCoexistence(ctx, data, servers, owner) + observeDNAMECoexistence(ctx, data, servers) observeDNSSEC(ctx, data, servers, apex, owner) return data, nil @@ -403,20 +404,18 @@ func observeApex(ctx context.Context, data *AliasData, servers []string, apex st } } -func observeCoexistence(ctx context.Context, data *AliasData, servers []string, owner string) { - if !data.OwnerHasCNAME { - return - } - - siblings := []uint16{ +// querySiblings returns RRsets of common types that sit alongside a CNAME or DNAME at owner. +// Filter on owner+type: a DNAME-synthesized CNAME would otherwise count as a sibling. +func querySiblings(ctx context.Context, servers []string, owner string) []CoexistingRRset { + candidates := []uint16{ dns.TypeA, dns.TypeAAAA, dns.TypeMX, dns.TypeTXT, dns.TypeNS, dns.TypeSRV, dns.TypeCAA, } seen := map[string]uint32{} var mu sync.Mutex var wg sync.WaitGroup - wg.Add(len(siblings)) - for _, qt := range siblings { + wg.Add(len(candidates)) + for _, qt := range candidates { go func() { defer wg.Done() q := dns.Question{Name: owner, Qtype: qt, Qclass: dns.ClassINET} @@ -424,8 +423,6 @@ func observeCoexistence(ctx context.Context, data *AliasData, servers []string, if err != nil || r == nil { return } - // Filter on owner+type because a DNAME-synthesized CNAME would - // otherwise count as a sibling of every queried type. for _, rr := range r.Answer { if rr.Header().Rrtype != qt { continue @@ -441,9 +438,42 @@ func observeCoexistence(ctx context.Context, data *AliasData, servers []string, }() } wg.Wait() - + var out []CoexistingRRset for t, ttl := range seen { - data.Coexisting = append(data.Coexisting, CoexistingRRset{Type: t, TTL: ttl}) + out = append(out, CoexistingRRset{Type: t, TTL: ttl}) + } + return out +} + +func observeCoexistence(ctx context.Context, data *AliasData, servers []string, owner string) { + if !data.OwnerHasCNAME { + return + } + data.Coexisting = querySiblings(ctx, servers, owner) +} + +func observeDNAMECoexistence(ctx context.Context, data *AliasData, servers []string) { + if len(data.DNAMESubstitutions) == 0 { + return + } + results := make(map[string][]CoexistingRRset, len(data.DNAMESubstitutions)) + var mu sync.Mutex + var wg sync.WaitGroup + wg.Add(len(data.DNAMESubstitutions)) + for _, hop := range data.DNAMESubstitutions { + go func() { + defer wg.Done() + siblings := querySiblings(ctx, servers, hop.Owner) + if len(siblings) > 0 { + mu.Lock() + results[hop.Owner] = siblings + mu.Unlock() + } + }() + } + wg.Wait() + if len(results) > 0 { + data.DNAMECoexistence = results } } diff --git a/checker/definition.go b/checker/definition.go index 772d966..38af530 100644 --- a/checker/definition.go +++ b/checker/definition.go @@ -80,6 +80,7 @@ func Definition() *sdk.CheckerDefinition { cnameAtApexRule{}, apexFlatteningRule{}, cnameCoexistenceRule{}, + dnameCoexistenceRule{}, cnameDnssecRule{}, targetResolvableRule{}, multipleRecordsRule{}, diff --git a/checker/rules_coexistence.go b/checker/rules_coexistence.go index 252c987..d305031 100644 --- a/checker/rules_coexistence.go +++ b/checker/rules_coexistence.go @@ -7,6 +7,41 @@ import ( sdk "git.happydns.org/checker-sdk-go/checker" ) +type dnameCoexistenceRule struct{} + +func (dnameCoexistenceRule) Name() string { return "dname_coexistence" } +func (dnameCoexistenceRule) Description() string { + return "Flags RRsets that sit at the same owner as a DNAME (RFC 6672 §2.3)." +} + +func (dnameCoexistenceRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + data, errState := loadAlias(ctx, obs) + if errState != nil { + return errState + } + if !apexKnown(data) { + return skipped("apex lookup failed") + } + if len(data.DNAMESubstitutions) == 0 { + return skipped("no DNAME in chain") + } + if len(data.DNAMECoexistence) == 0 { + return okState(data.Owner, "all DNAME nodes have no sibling records") + } + var out []sdk.CheckState + for owner, coexisting := range data.DNAMECoexistence { + for _, rr := range coexisting { + out = append(out, withHint(sdk.CheckState{ + Status: sdk.StatusCrit, + Subject: owner, + Message: fmt.Sprintf("%s and DNAME both exist at %s (RFC 6672 §2.3)", rr.Type, owner), + Code: rr.Type, + }, "Remove the sibling record or move it under a different label; a DNAME owner must not carry other data.")) + } + } + return out +} + type cnameCoexistenceRule struct{} func (cnameCoexistenceRule) Name() string { return "cname_coexistence" } diff --git a/checker/rules_test.go b/checker/rules_test.go index 2c8452f..1d2d92f 100644 --- a/checker/rules_test.go +++ b/checker/rules_test.go @@ -266,6 +266,38 @@ func TestCnameCoexistenceRule(t *testing.T) { }) } +func TestDnameCoexistenceRule(t *testing.T) { + t.Run("skip when no DNAME in chain", func(t *testing.T) { + d := apexKnownData() + assertSkipped(t, run(dnameCoexistenceRule{}, d, nil), "no DNAME in chain") + }) + t.Run("ok when DNAME has no siblings", func(t *testing.T) { + d := apexKnownData() + d.DNAMESubstitutions = []ChainHop{{Owner: "old.example.com.", Kind: KindDNAME, Target: "new.example.com."}} + assertSingle(t, run(dnameCoexistenceRule{}, d, nil), sdk.StatusOK) + }) + t.Run("crit when DNAME has siblings", func(t *testing.T) { + d := apexKnownData() + d.DNAMESubstitutions = []ChainHop{{Owner: "old.example.com.", Kind: KindDNAME, Target: "new.example.com."}} + d.DNAMECoexistence = map[string][]CoexistingRRset{ + "old.example.com.": {{Type: "MX"}, {Type: "A"}}, + } + states := run(dnameCoexistenceRule{}, d, nil) + if len(states) != 2 { + t.Fatalf("want 2 states, got %d: %+v", len(states), states) + } + for _, s := range states { + if s.Status != sdk.StatusCrit { + t.Fatalf("want CRIT, got %v", s.Status) + } + } + }) + t.Run("skip when apex unknown", func(t *testing.T) { + d := &AliasData{Owner: "x.", ApexLookupError: "boom"} + assertSkipped(t, run(dnameCoexistenceRule{}, d, nil), "apex") + }) +} + func TestCnameDnssecRule(t *testing.T) { t.Run("skip unsigned zone", func(t *testing.T) { d := apexKnownData() diff --git a/checker/types.go b/checker/types.go index 724ec27..ecfa074 100644 --- a/checker/types.go +++ b/checker/types.go @@ -70,6 +70,8 @@ type AliasData struct { // Coexisting is populated only when Owner has a CNAME. Coexisting []CoexistingRRset `json:"coexisting,omitempty"` + // DNAMECoexistence maps each DNAME owner (from DNAMESubstitutions) to its sibling RRsets. + DNAMECoexistence map[string][]CoexistingRRset `json:"dname_coexistence,omitempty"` OwnerIsApex bool `json:"owner_is_apex,omitempty"` OwnerHasCNAME bool `json:"owner_has_cname,omitempty"`