checker-dnssec/checker/rules_presence.go

150 lines
5.4 KiB
Go

package checker
import (
"context"
"fmt"
"sort"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// zoneSignedRule cross-checks "DS published at parent" against "DNSKEY served
// at apex". A DS without a DNSKEY is the classic post-rollover hard-fail
// scenario and triggers SERVFAIL on every validating resolver.
type zoneSignedRule struct{}
func (zoneSignedRule) Name() string { return "dnssec_zone_signed" }
func (zoneSignedRule) Description() string {
return "Detects a zone advertised as signed at the parent (DS) but no DNSKEY served at the apex."
}
func (zoneSignedRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errState := loadDNSSEC(ctx, obs)
if errState != nil {
return errState
}
signed := hasAnyDNSKEY(data)
switch {
case data.HasDS && !signed:
return []sdk.CheckState{withMeta(sdk.CheckState{
Status: sdk.StatusCrit,
Subject: data.Domain,
Message: fmt.Sprintf("zone %s has a DS at the parent but no DNSKEY at the apex; every validating resolver will SERVFAIL", data.Domain),
}, "Restore the apex DNSKEY RRset, or remove the DS at the parent until the zone is signed again.", "dnssec.unsigned")}
case !data.HasDS && !signed:
return []sdk.CheckState{{
Status: sdk.StatusInfo,
Subject: data.Domain,
Message: "zone is unsigned (no DS at parent, no DNSKEY at apex)",
}}
case !data.HasDS && signed:
return []sdk.CheckState{withMeta(sdk.CheckState{
Status: sdk.StatusInfo,
Subject: data.Domain,
Message: "zone is signed at the apex but no DS is published at the parent; validators treat it as insecure",
}, "Publish a DS record at the parent registrar to enable DNSSEC validation.", "dnssec.no_ds")}
default:
return okState(data.Domain, "zone is signed and the parent publishes a DS")
}
}
// dnskeyConsistentRule guards against split-brain auth servers: a single
// stale secondary serving a different DNSKEY RRset is a frequent rollover
// failure mode and gives intermittent validation failures.
type dnskeyConsistentRule struct{}
func (dnskeyConsistentRule) Name() string { return "dnssec_dnskey_consistent" }
func (dnskeyConsistentRule) Description() string {
return "Verifies that every authoritative server returns the same DNSKEY RRset."
}
func (dnskeyConsistentRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errState := loadDNSSEC(ctx, obs)
if errState != nil {
return errState
}
if !hasAnyDNSKEY(data) {
return skipped("zone not signed")
}
type sig = string
signatures := map[sig][]string{}
for _, name := range sortedServers(data) {
view := data.Servers[name]
if len(view.DNSKEYs) == 0 {
continue
}
signatures[dnskeySetSignature(view.DNSKEYs)] = append(signatures[dnskeySetSignature(view.DNSKEYs)], name)
}
if len(signatures) <= 1 {
return okState(data.Domain, fmt.Sprintf("all %d servers serve the same DNSKEY RRset", len(data.Servers)))
}
var msgs []string
for s, servers := range signatures {
msgs = append(msgs, fmt.Sprintf("[%s] -> %s", s, strings.Join(servers, ", ")))
}
sort.Strings(msgs)
return []sdk.CheckState{withMeta(sdk.CheckState{
Status: sdk.StatusCrit,
Subject: data.Domain,
Message: "authoritative servers disagree on the DNSKEY RRset: " + strings.Join(msgs, " / "),
}, "Force a zone re-transfer (AXFR/IXFR) or check that every secondary tracks the primary's signing pipeline.", "dnssec.dnskey_drift")}
}
// dnskeySetSignature collapses a DNSKEY RRset to a stable identity made of
// (KeyTag, Algorithm) pairs. Sorting keeps ordering differences invisible.
func dnskeySetSignature(keys []DNSKEYRecord) string {
parts := make([]string, len(keys))
for i, k := range keys {
parts[i] = fmt.Sprintf("%d/%d", k.KeyTag, k.Algorithm)
}
sort.Strings(parts)
return strings.Join(parts, ",")
}
// dnskeyQueryOKRule emits one state per server: a checker that hides "this
// secondary is unreachable" inside an aggregated CRIT loses the operator's
// most actionable signal.
type dnskeyQueryOKRule struct{}
func (dnskeyQueryOKRule) Name() string { return "dnssec_dnskey_query_ok" }
func (dnskeyQueryOKRule) Description() string {
return "Verifies that every authoritative server answered the DNSKEY query."
}
func (dnskeyQueryOKRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errState := loadDNSSEC(ctx, obs)
if errState != nil {
return errState
}
if len(data.Servers) == 0 {
return skipped("no servers probed")
}
var states []sdk.CheckState
for _, name := range sortedServers(data) {
v := data.Servers[name]
switch {
case v.UDPError != "" && len(v.DNSKEYs) == 0:
states = append(states, withMeta(sdk.CheckState{
Status: sdk.StatusCrit,
Subject: name,
Message: fmt.Sprintf("%s did not answer the DNSKEY query: %s", name, v.UDPError),
}, "Verify the server is reachable on UDP/53 and TCP/53 and that DNSSEC responses are not being filtered by a firewall.", "dnssec.dnskey_query_failed"))
case len(v.DNSKEYs) == 0:
states = append(states, sdk.CheckState{
Status: sdk.StatusInfo,
Subject: name,
Message: fmt.Sprintf("%s answered but returned no DNSKEY (zone unsigned on this server?)", name),
})
default:
states = append(states, sdk.CheckState{
Status: sdk.StatusOK,
Subject: name,
Message: fmt.Sprintf("%s served %d DNSKEY records", name, len(v.DNSKEYs)),
})
}
}
return states
}