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 }