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"`