150 lines
5.4 KiB
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
|
|
}
|