Compare commits

..

No commits in common. "master" and "v0.2.0" have entirely different histories.

8 changed files with 59 additions and 139 deletions

View file

@ -49,7 +49,6 @@ 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
@ -401,21 +400,29 @@ 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,
})
} }
} }
// querySiblings returns RRsets of common types that sit alongside a CNAME or DNAME at owner. func observeCoexistence(ctx context.Context, data *AliasData, servers []string, owner string) {
// Filter on owner+type: a DNAME-synthesized CNAME would otherwise count as a sibling. if !data.OwnerHasCNAME {
func querySiblings(ctx context.Context, servers []string, owner string) []CoexistingRRset { return
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(candidates)) wg.Add(len(siblings))
for _, qt := range candidates { for _, qt := range siblings {
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}
@ -423,6 +430,8 @@ func querySiblings(ctx context.Context, servers []string, owner string) []Coexis
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
@ -438,42 +447,9 @@ func querySiblings(ctx context.Context, servers []string, owner string) []Coexis
}() }()
} }
wg.Wait() wg.Wait()
var out []CoexistingRRset
for t, ttl := range seen { for t, ttl := range seen {
out = append(out, CoexistingRRset{Type: t, TTL: ttl}) data.Coexisting = append(data.Coexisting, 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,7 +80,6 @@ func Definition() *sdk.CheckerDefinition {
cnameAtApexRule{}, cnameAtApexRule{},
apexFlatteningRule{}, apexFlatteningRule{},
cnameCoexistenceRule{}, cnameCoexistenceRule{},
dnameCoexistenceRule{},
cnameDnssecRule{}, cnameDnssecRule{},
targetResolvableRule{}, targetResolvableRule{},
multipleRecordsRule{}, multipleRecordsRule{},

View file

@ -104,15 +104,7 @@ 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...)
chain := data.Chain for i, h := range 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,
@ -120,7 +112,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(chain)-1, IsLast: i == len(data.Chain)-1,
} }
switch h.Kind { switch h.Kind {
case KindCNAME: case KindCNAME:

View file

@ -189,10 +189,24 @@ 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 exists in DNS (returns NOERROR, not NXDOMAIN)." return "Verifies that the final target of the alias chain publishes at least one A or AAAA record."
} }
func (targetResolvableRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { func (targetResolvableRule) Options() sdk.CheckerOptionsDocumentation {
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
@ -203,14 +217,22 @@ 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 data.FinalRcode != "NXDOMAIN" { if len(data.FinalA) > 0 || len(data.FinalAAAA) > 0 {
return okState(data.FinalTarget, fmt.Sprintf("target %s exists in DNS", data.FinalTarget)) return okState(data.FinalTarget, fmt.Sprintf("target %s resolves to %d address(es)", data.FinalTarget, len(data.FinalA)+len(data.FinalAAAA)))
}
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: sdk.StatusCrit, Status: status,
Subject: data.FinalTarget, Subject: data.FinalTarget,
Message: fmt.Sprintf("final target %s does not exist (NXDOMAIN)", data.FinalTarget), Message: fmt.Sprintf("final target %s does not resolve to an address (%s)", data.FinalTarget, rcode),
}, "The alias points at a name that does not exist; create the missing record or update the alias target.")} }, "Point the alias at a name that publishes at least one A or AAAA record, or fix the upstream zone.")}
} }
type multipleRecordsRule struct{} type multipleRecordsRule struct{}

View file

@ -7,41 +7,6 @@ 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

@ -11,6 +11,7 @@ import (
const ( const (
defaultMaxChainLength = 8 defaultMaxChainLength = 8
defaultMinTargetTTL = 60 defaultMinTargetTTL = 60
defaultRequireResolvableTarget = true
defaultAllowApexCNAME = false defaultAllowApexCNAME = false
defaultRecognizeApexFlattening = true defaultRecognizeApexFlattening = true

View file

@ -266,38 +266,6 @@ 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()
@ -322,26 +290,25 @@ func TestCnameDnssecRule(t *testing.T) {
} }
func TestTargetResolvableRule(t *testing.T) { func TestTargetResolvableRule(t *testing.T) {
t.Run("ok when NOERROR with A record", func(t *testing.T) { t.Run("ok", 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("ok when NOERROR with no A/AAAA (e.g. service label)", func(t *testing.T) { t.Run("crit by default", func(t *testing.T) {
d := apexKnownData()
d.ChainTerminated.Reason = TermOK
d.FinalTarget = "_2772._tcp.znc.example."
assertSingle(t, run(targetResolvableRule{}, d, nil), sdk.StatusOK)
})
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."
d.FinalRcode = "NXDOMAIN"
assertSingle(t, run(targetResolvableRule{}, d, nil), sdk.StatusCrit) assertSingle(t, run(targetResolvableRule{}, d, nil), sdk.StatusCrit)
}) })
t.Run("warn when requireResolvableTarget=false", func(t *testing.T) {
d := apexKnownData()
d.ChainTerminated.Reason = TermOK
d.FinalTarget = "target."
assertSingle(t, run(targetResolvableRule{}, d, sdk.CheckerOptions{"requireResolvableTarget": false}), sdk.StatusWarn)
})
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()
d.ChainTerminated.Reason = TermLoop d.ChainTerminated.Reason = TermLoop

View file

@ -70,8 +70,6 @@ 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"`