Compare commits

...

3 commits

Author SHA1 Message Date
c5c13960d5 checker: add dname_coexistence rule and refactor sibling probing
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
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.
2026-05-16 21:36:20 +08:00
1493ef4d3f report: move synthetic ALIAS hop from collector to report view
All checks were successful
continuous-integration/drone/push Build is passing
2026-05-15 17:37:11 +08:00
52a3e56c4f checker: rework target_resolvable to check existence (NOERROR) instead of A/AAAA 2026-05-15 17:31:51 +08:00
8 changed files with 139 additions and 59 deletions

View file

@ -49,6 +49,7 @@ func (p *aliasProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (a
} }
observeCoexistence(ctx, data, servers, owner) observeCoexistence(ctx, data, servers, owner)
observeDNAMECoexistence(ctx, data, servers)
observeDNSSEC(ctx, data, servers, apex, owner) observeDNSSEC(ctx, data, servers, apex, owner)
return data, nil return data, nil
@ -400,29 +401,21 @@ func observeApex(ctx context.Context, data *AliasData, servers []string, apex st
if (hasA || hasAAAA) && !data.ApexHasCNAME { if (hasA || hasAAAA) && !data.ApexHasCNAME {
data.ApexFlattening = true data.ApexFlattening = true
// Synthesize a pseudo-hop so the report's chain view shows the ALIAS
// indirection that would otherwise be invisible from the wire.
data.Chain = append(data.Chain, ChainHop{
Owner: lowerFQDN(apex),
Kind: KindALIAS,
})
} }
} }
func observeCoexistence(ctx context.Context, data *AliasData, servers []string, owner string) { // querySiblings returns RRsets of common types that sit alongside a CNAME or DNAME at owner.
if !data.OwnerHasCNAME { // Filter on owner+type: a DNAME-synthesized CNAME would otherwise count as a sibling.
return func querySiblings(ctx context.Context, servers []string, owner string) []CoexistingRRset {
} candidates := []uint16{
siblings := []uint16{
dns.TypeA, dns.TypeAAAA, dns.TypeMX, dns.TypeTXT, dns.TypeA, dns.TypeAAAA, dns.TypeMX, dns.TypeTXT,
dns.TypeNS, dns.TypeSRV, dns.TypeCAA, dns.TypeNS, dns.TypeSRV, dns.TypeCAA,
} }
seen := map[string]uint32{} seen := map[string]uint32{}
var mu sync.Mutex var mu sync.Mutex
var wg sync.WaitGroup var wg sync.WaitGroup
wg.Add(len(siblings)) wg.Add(len(candidates))
for _, qt := range siblings { for _, qt := range candidates {
go func() { go func() {
defer wg.Done() defer wg.Done()
q := dns.Question{Name: owner, Qtype: qt, Qclass: dns.ClassINET} q := dns.Question{Name: owner, Qtype: qt, Qclass: dns.ClassINET}
@ -430,8 +423,6 @@ func observeCoexistence(ctx context.Context, data *AliasData, servers []string,
if err != nil || r == nil { if err != nil || r == nil {
return 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 { for _, rr := range r.Answer {
if rr.Header().Rrtype != qt { if rr.Header().Rrtype != qt {
continue continue
@ -447,9 +438,42 @@ func observeCoexistence(ctx context.Context, data *AliasData, servers []string,
}() }()
} }
wg.Wait() wg.Wait()
var out []CoexistingRRset
for t, ttl := range seen { 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
} }
} }

View file

@ -80,6 +80,7 @@ func Definition() *sdk.CheckerDefinition {
cnameAtApexRule{}, cnameAtApexRule{},
apexFlatteningRule{}, apexFlatteningRule{},
cnameCoexistenceRule{}, cnameCoexistenceRule{},
dnameCoexistenceRule{},
cnameDnssecRule{}, cnameDnssecRule{},
targetResolvableRule{}, targetResolvableRule{},
multipleRecordsRule{}, multipleRecordsRule{},

View file

@ -104,7 +104,15 @@ func buildReportView(data *AliasData, states []sdk.CheckState) *reportView {
v.FinalAddresses = append(v.FinalAddresses, data.FinalA...) v.FinalAddresses = append(v.FinalAddresses, data.FinalA...)
v.FinalAddresses = append(v.FinalAddresses, data.FinalAAAA...) v.FinalAddresses = append(v.FinalAddresses, data.FinalAAAA...)
for i, h := range data.Chain { chain := data.Chain
if data.ApexFlattening {
chain = append(chain, ChainHop{
Owner: data.Apex,
Kind: KindALIAS,
})
}
for i, h := range chain {
step := chainStep{ step := chainStep{
Index: i + 1, Index: i + 1,
Owner: h.Owner, Owner: h.Owner,
@ -112,7 +120,7 @@ func buildReportView(data *AliasData, states []sdk.CheckState) *reportView {
Target: h.Target, Target: h.Target,
TTL: h.TTL, TTL: h.TTL,
Server: h.Server, Server: h.Server,
IsLast: i == len(data.Chain)-1, IsLast: i == len(chain)-1,
} }
switch h.Kind { switch h.Kind {
case KindCNAME: case KindCNAME:

View file

@ -189,24 +189,10 @@ type targetResolvableRule struct{}
func (targetResolvableRule) Name() string { return "target_resolvable" } func (targetResolvableRule) Name() string { return "target_resolvable" }
func (targetResolvableRule) Description() string { func (targetResolvableRule) Description() string {
return "Verifies that the final target of the alias chain publishes at least one A or AAAA record." return "Verifies that the final target of the alias chain exists in DNS (returns NOERROR, not NXDOMAIN)."
} }
func (targetResolvableRule) Options() sdk.CheckerOptionsDocumentation { func (targetResolvableRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
return sdk.CheckerOptionsDocumentation{
UserOpts: []sdk.CheckerOptionDocumentation{
{
Id: "requireResolvableTarget",
Type: "bool",
Label: "Require resolvable target",
Description: "When enabled, a chain whose final target returns no A/AAAA is reported as critical (otherwise a warning).",
Default: defaultRequireResolvableTarget,
},
},
}
}
func (targetResolvableRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
data, errState := loadAlias(ctx, obs) data, errState := loadAlias(ctx, obs)
if errState != nil { if errState != nil {
return errState return errState
@ -217,22 +203,14 @@ func (targetResolvableRule) Evaluate(ctx context.Context, obs sdk.ObservationGet
if data.ChainTerminated.Reason != TermOK { if data.ChainTerminated.Reason != TermOK {
return skipped("chain did not terminate normally") return skipped("chain did not terminate normally")
} }
if len(data.FinalA) > 0 || len(data.FinalAAAA) > 0 { if data.FinalRcode != "NXDOMAIN" {
return okState(data.FinalTarget, fmt.Sprintf("target %s resolves to %d address(es)", data.FinalTarget, len(data.FinalA)+len(data.FinalAAAA))) return okState(data.FinalTarget, fmt.Sprintf("target %s exists in DNS", data.FinalTarget))
}
status := sdk.StatusWarn
if sdk.GetBoolOption(opts, "requireResolvableTarget", defaultRequireResolvableTarget) {
status = sdk.StatusCrit
}
rcode := data.FinalRcode
if rcode == "" {
rcode = "no A/AAAA"
} }
return []sdk.CheckState{withHint(sdk.CheckState{ return []sdk.CheckState{withHint(sdk.CheckState{
Status: status, Status: sdk.StatusCrit,
Subject: data.FinalTarget, Subject: data.FinalTarget,
Message: fmt.Sprintf("final target %s does not resolve to an address (%s)", data.FinalTarget, rcode), Message: fmt.Sprintf("final target %s does not exist (NXDOMAIN)", data.FinalTarget),
}, "Point the alias at a name that publishes at least one A or AAAA record, or fix the upstream zone.")} }, "The alias points at a name that does not exist; create the missing record or update the alias target.")}
} }
type multipleRecordsRule struct{} type multipleRecordsRule struct{}

View file

@ -7,6 +7,41 @@ import (
sdk "git.happydns.org/checker-sdk-go/checker" 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{} type cnameCoexistenceRule struct{}
func (cnameCoexistenceRule) Name() string { return "cname_coexistence" } func (cnameCoexistenceRule) Name() string { return "cname_coexistence" }

View file

@ -9,10 +9,9 @@ import (
// Defaults are centralised so Definition's docs and runtime reads cannot drift. // Defaults are centralised so Definition's docs and runtime reads cannot drift.
const ( const (
defaultMaxChainLength = 8 defaultMaxChainLength = 8
defaultMinTargetTTL = 60 defaultMinTargetTTL = 60
defaultRequireResolvableTarget = true defaultAllowApexCNAME = false
defaultAllowApexCNAME = false
defaultRecognizeApexFlattening = true defaultRecognizeApexFlattening = true
// hintKey is the CheckState.Meta key the HTML report reads to render the // hintKey is the CheckState.Meta key the HTML report reads to render the

View file

@ -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) { func TestCnameDnssecRule(t *testing.T) {
t.Run("skip unsigned zone", func(t *testing.T) { t.Run("skip unsigned zone", func(t *testing.T) {
d := apexKnownData() d := apexKnownData()
@ -290,24 +322,25 @@ func TestCnameDnssecRule(t *testing.T) {
} }
func TestTargetResolvableRule(t *testing.T) { func TestTargetResolvableRule(t *testing.T) {
t.Run("ok", func(t *testing.T) { t.Run("ok when NOERROR with A record", func(t *testing.T) {
d := apexKnownData() d := apexKnownData()
d.ChainTerminated.Reason = TermOK d.ChainTerminated.Reason = TermOK
d.FinalTarget = "target." d.FinalTarget = "target."
d.FinalA = []string{"1.2.3.4"} d.FinalA = []string{"1.2.3.4"}
assertSingle(t, run(targetResolvableRule{}, d, nil), sdk.StatusOK) assertSingle(t, run(targetResolvableRule{}, d, nil), sdk.StatusOK)
}) })
t.Run("crit by default", func(t *testing.T) { t.Run("ok when NOERROR with no A/AAAA (e.g. service label)", func(t *testing.T) {
d := apexKnownData() d := apexKnownData()
d.ChainTerminated.Reason = TermOK d.ChainTerminated.Reason = TermOK
d.FinalTarget = "target." d.FinalTarget = "_2772._tcp.znc.example."
assertSingle(t, run(targetResolvableRule{}, d, nil), sdk.StatusCrit) assertSingle(t, run(targetResolvableRule{}, d, nil), sdk.StatusOK)
}) })
t.Run("warn when requireResolvableTarget=false", func(t *testing.T) { t.Run("crit when NXDOMAIN", func(t *testing.T) {
d := apexKnownData() d := apexKnownData()
d.ChainTerminated.Reason = TermOK d.ChainTerminated.Reason = TermOK
d.FinalTarget = "target." d.FinalTarget = "target."
assertSingle(t, run(targetResolvableRule{}, d, sdk.CheckerOptions{"requireResolvableTarget": false}), sdk.StatusWarn) d.FinalRcode = "NXDOMAIN"
assertSingle(t, run(targetResolvableRule{}, d, nil), sdk.StatusCrit)
}) })
t.Run("skip when chain did not terminate normally", func(t *testing.T) { t.Run("skip when chain did not terminate normally", func(t *testing.T) {
d := apexKnownData() d := apexKnownData()

View file

@ -70,6 +70,8 @@ type AliasData struct {
// Coexisting is populated only when Owner has a CNAME. // Coexisting is populated only when Owner has a CNAME.
Coexisting []CoexistingRRset `json:"coexisting,omitempty"` 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"` OwnerIsApex bool `json:"owner_is_apex,omitempty"`
OwnerHasCNAME bool `json:"owner_has_cname,omitempty"` OwnerHasCNAME bool `json:"owner_has_cname,omitempty"`