package checker import ( "context" "fmt" "time" sdk "git.happydns.org/checker-sdk-go/checker" ) // rrsigPresentDNSKEYRule catches the most opaque DNSSEC failure: an answer // with DNSKEYs but no covering RRSIG, which makes the zone unverifiable. type rrsigPresentDNSKEYRule struct{} func (rrsigPresentDNSKEYRule) Name() string { return "dnssec_rrsig_present_dnskey" } func (rrsigPresentDNSKEYRule) Description() string { return "Ensures the DNSKEY RRset is signed." } func (rrsigPresentDNSKEYRule) 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") } for _, name := range sortedServers(data) { v := data.Servers[name] if len(v.DNSKEYs) > 0 && len(v.DNSKEYRRSIGs) == 0 { return []sdk.CheckState{withMeta(sdk.CheckState{ Status: sdk.StatusCrit, Subject: name, Message: fmt.Sprintf("server %s returned DNSKEYs but no covering RRSIG; validators will SERVFAIL", name), }, "Re-sign the zone and check the signer's KSK access; an expired or revoked KSK silently produces this state.", "dnssec.dnskey_unsigned")} } } return okState(data.Domain, "DNSKEY RRset is signed on every server") } type rrsigPresentSOARule struct{} func (rrsigPresentSOARule) Name() string { return "dnssec_rrsig_present_soa" } func (rrsigPresentSOARule) Description() string { return "Ensures the SOA RRset is signed." } func (rrsigPresentSOARule) 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") } for _, name := range sortedServers(data) { v := data.Servers[name] if v.SOA != nil && len(v.SOARRSIGs) == 0 { return []sdk.CheckState{withMeta(sdk.CheckState{ Status: sdk.StatusCrit, Subject: name, Message: fmt.Sprintf("server %s returned a SOA but no covering RRSIG", name), }, "Re-sign the zone; an unsigned SOA in a signed zone breaks every NXDOMAIN proof.", "dnssec.soa_unsigned")} } } return okState(data.Domain, "SOA RRset is signed on every server") } type rrsigValidityWindowRule struct{} func (rrsigValidityWindowRule) Name() string { return "dnssec_rrsig_validity_window" } func (rrsigValidityWindowRule) Description() string { return "Verifies that every observed RRSIG is currently within [Inception, Expiration]." } func (rrsigValidityWindowRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { data, errState := loadDNSSEC(ctx, obs) if errState != nil { return errState } now := uint32(time.Now().UTC().Unix()) for _, name := range sortedServers(data) { v := data.Servers[name] for _, sig := range v.AllRRSIGs() { // Inception/Expiration are unsigned 32-bit serial-arithmetic // timestamps. A naive < / > would mishandle the year-2106 wrap; // we use signed-difference comparison instead. if int32(now-sig.Inception) < 0 { return []sdk.CheckState{withMeta(sdk.CheckState{ Status: sdk.StatusCrit, Subject: name, Message: fmt.Sprintf("RRSIG (KeyTag %d, type %d) on %s has not yet entered its validity window", sig.KeyTag, sig.TypeCovered, name), }, "Check the signer's clock; future-dated inceptions usually mean a misconfigured NTP.", "dnssec.rrsig_outside_window")} } if int32(sig.Expiration-now) < 0 { return []sdk.CheckState{withMeta(sdk.CheckState{ Status: sdk.StatusCrit, Subject: name, Message: fmt.Sprintf("RRSIG (KeyTag %d, type %d) on %s is already expired", sig.KeyTag, sig.TypeCovered, name), }, "Re-sign the zone immediately and check the signing cron; this is the most common cause of sudden DNSSEC outages.", "dnssec.rrsig_outside_window")} } } } return okState(data.Domain, "all RRSIGs are within their validity window") } type rrsigFreshnessRule struct{} func (rrsigFreshnessRule) Name() string { return "dnssec_rrsig_freshness" } func (rrsigFreshnessRule) Description() string { return "Warns when RRSIGs are close to expiring; preemptive alert for stuck signers." } func (rrsigFreshnessRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { data, errState := loadDNSSEC(ctx, obs) if errState != nil { return errState } warnDays := optionUint(opts, "signatureFreshness", defaultSignatureFreshnessDays) critDays := optionUint(opts, "signatureFreshnessCrit", defaultSignatureFreshnessCrit) now := time.Now().UTC().Unix() var minRemaining int64 = 1 << 30 var minSubject string var minSig RRSIGObservation found := false for _, name := range sortedServers(data) { v := data.Servers[name] for _, sig := range v.AllRRSIGs() { diff := int64(int32(sig.Expiration - uint32(now))) if !found || diff < minRemaining { minRemaining = diff minSubject = name minSig = sig found = true } } } if !found { return skipped("no RRSIG observed") } days := minRemaining / 86400 switch { case days < int64(critDays): return []sdk.CheckState{withMeta(sdk.CheckState{ Status: sdk.StatusCrit, Subject: minSubject, Message: fmt.Sprintf("RRSIG on %s (KeyTag %d) expires in %d hours", minSubject, minSig.KeyTag, minRemaining/3600), }, "Check the signing cron: this is hours away from causing a SERVFAIL outage.", "dnssec.rrsig_close_to_expiry")} case days < int64(warnDays): return []sdk.CheckState{withMeta(sdk.CheckState{ Status: sdk.StatusWarn, Subject: minSubject, Message: fmt.Sprintf("nearest RRSIG (KeyTag %d on %s) expires in %d days", minSig.KeyTag, minSubject, days), }, "Verify the signer's resigning interval; less than a week of headroom leaves no margin for a stuck cron.", "dnssec.rrsig_close_to_expiry")} } return okState(data.Domain, fmt.Sprintf("nearest RRSIG expires in %d days", days)) }