Initial commit

This commit is contained in:
nemunaire 2026-04-26 18:56:19 +07:00
commit 5a632a3b30
24 changed files with 2901 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
checker-dnssec
checker-dnssec.so

17
Dockerfile Normal file
View file

@ -0,0 +1,17 @@
FROM golang:1.25-alpine AS builder
ARG CHECKER_VERSION=custom-build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -tags standalone -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-dnssec .
FROM scratch
COPY --from=builder /checker-dnssec /checker-dnssec
USER 65534:65534
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD ["/checker-dnssec", "-healthcheck"]
ENTRYPOINT ["/checker-dnssec"]

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 The happyDomain Authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the “Software”), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

28
Makefile Normal file
View file

@ -0,0 +1,28 @@
CHECKER_NAME := checker-dnssec
CHECKER_IMAGE := happydomain/$(CHECKER_NAME)
CHECKER_VERSION ?= custom-build
CHECKER_SOURCES := main.go $(wildcard checker/*.go)
GO_LDFLAGS := -X main.Version=$(CHECKER_VERSION)
.PHONY: all plugin docker test clean
all: $(CHECKER_NAME)
$(CHECKER_NAME): $(CHECKER_SOURCES)
go build -tags standalone -ldflags "$(GO_LDFLAGS)" -o $@ .
plugin: $(CHECKER_NAME).so
$(CHECKER_NAME).so: $(CHECKER_SOURCES) $(wildcard plugin/*.go)
go build -buildmode=plugin -ldflags "$(GO_LDFLAGS)" -o $@ ./plugin/
docker:
docker build --build-arg CHECKER_VERSION=$(CHECKER_VERSION) -t $(CHECKER_IMAGE) .
test:
go test -tags standalone ./...
clean:
rm -f $(CHECKER_NAME) $(CHECKER_NAME).so

26
NOTICE Normal file
View file

@ -0,0 +1,26 @@
checker-dnssec
Copyright (c) 2026 The happyDomain Authors
This product is licensed under the MIT License (see LICENSE).
-------------------------------------------------------------------------------
Third-party notices
-------------------------------------------------------------------------------
This product includes software developed as part of the checker-sdk-go
project (https://git.happydns.org/happyDomain/checker-sdk-go), licensed
under the Apache License, Version 2.0:
checker-sdk-go
Copyright 2020-2026 The happyDomain Authors
This product includes software developed as part of the happyDomain
project (https://happydomain.org).
Portions of this code were originally written for the happyDomain
server (licensed under AGPL-3.0 and a commercial license) and are
made available there under the Apache License, Version 2.0 to enable
a permissively licensed ecosystem of checker plugins.
You may obtain a copy of the Apache License 2.0 at:
http://www.apache.org/licenses/LICENSE-2.0

124
README.md Normal file
View file

@ -0,0 +1,124 @@
# checker-dnssec
DNSSEC operational hygiene checker for [happyDomain](https://www.happydomain.org/).
Cryptographic chain validation is delegated to `checker-dnsviz`. This
checker focuses on **policy and operational hygiene**:
- NSEC vs NSEC3 zone walking exposure
- RFC 9276 NSEC3 parameter compliance (iterations, salt)
- Algorithm policy and key sizes (allowed / forbidden / modern)
- RRSIG presence, validity windows and freshness
- TTL recommendations for DNSKEY / RRSIG
- Per-name-server consistency of the DNSKEY RRset and denial scheme
The HTML report is laid out so the most common operator-facing failure
scenarios appear first, with a fix line citing the relevant RFC.
## Usage
### Standalone HTTP server
```bash
# Build and run
make
./checker-dnssec -listen :8080
```
The server exposes:
- `GET /health`: health check
- `POST /collect`: collect DNSSEC observations (happyDomain external checker protocol)
### Docker
```bash
make docker
docker run -p 8080:8080 happydomain/checker-dnssec
```
### happyDomain plugin
```bash
make plugin
# produces checker-dnssec.so, loadable by happyDomain as a Go plugin
```
The plugin exposes a `NewCheckerPlugin` symbol returning the checker
definition and observation provider, which happyDomain registers in its
global registries at load time.
### Versioning
The binary, plugin, and Docker image embed a version string overridable
at build time:
```bash
make CHECKER_VERSION=1.2.3
make plugin CHECKER_VERSION=1.2.3
make docker CHECKER_VERSION=1.2.3
```
### happyDomain remote endpoint
Set the `endpoint` admin option for the DNSSEC checker to the URL of the
running checker-dnssec server (e.g., `http://checker-dnssec:8080`).
happyDomain will delegate observation collection to this endpoint.
## Build
```sh
make # standalone binary
make docker # FROM scratch image
make plugin # Go plugin (.so)
make test # tests
```
## Options
### Admin options
| Id | Type | Default | Description |
|------------|--------|----------------------|--------------------------------------------------------------------------------------------------------------|
| `resolver` | string | `/etc/resolv.conf` | Bootstrap recursive resolver (`host:port`) used to discover the apex name servers and look up the parent DS. |
### User options
| Id | Type | Default | Description |
|---------------------------|--------|---------|------------------------------------------------------------------------------------------------------------------------------|
| `nsec3IterationsMax` | uint | `0` | RFC 9276 §3.1 ceiling on `NSEC3PARAM.Iterations`. Increase only if your signer cannot publish 0 yet. |
| `nsec3IterationsSeverity` | choice | `warn` | Severity when iterations exceed the ceiling. Use `crit` to enforce RFC 9276 strictly. |
| `signatureFreshness` | uint | `7` | Warn when the closest RRSIG expires in fewer than this many days. |
| `signatureFreshnessCrit` | uint | `1` | Critical when the closest RRSIG expires in fewer than this many days. |
| `minRSAKeySize` | uint | `2048` | Minimum acceptable RSA modulus size, in bits. |
| `requireSEP` | bool | `true` | Require at least one DNSKEY with the SEP bit (KSK). |
| `dnskeyTTLMin` | uint | `3600` | Minimum DNSKEY TTL, in seconds; shorter TTLs hurt cacheability. |
## Rules
Each rule emits a finding code. Severity may be affected by the options above.
| Code | Default severity | Condition |
|------|-----------------|-----------|
| `dnssec_zone_signed` | critical | Parent DS is published but the apex serves no DNSKEY (broken chain of trust). |
| `dnssec_dnskey_consistent` | critical | Authoritative servers disagree on the apex DNSKEY RRset. |
| `dnssec_dnskey_query_ok` | warning | At least one authoritative server failed to answer the DNSKEY query. |
| `dnssec_algorithm_allowed` | critical (forbidden) / warning (not in allowlist) | A DNSKEY uses a forbidden algorithm or an algorithm not in `allowedAlgorithms`. |
| `dnssec_algorithm_modern` | warning | The zone still uses RSA-family DNSKEYs; ECDSAP256SHA256 (13) or Ed25519 (15) recommended. |
| `dnssec_rsa_keysize` | critical (<1024) / warning (<`minRSAKeySize`) | An RSA DNSKEY has a modulus below the policy threshold. |
| `dnssec_ksk_present` | critical | No DNSKEY carries the SEP (KSK) flag while `requireSEP` is enabled. |
| `dnssec_dnskey_count` | warning | Eight or more DNSKEYs are published, bloating responses and amplification factor. |
| `dnssec_rrsig_present_dnskey` | critical | The apex DNSKEY RRset is unsigned. |
| `dnssec_rrsig_present_soa` | critical | The apex SOA RRset is unsigned. |
| `dnssec_rrsig_validity_window` | critical | An observed RRSIG is outside its `[Inception, Expiration]` window. |
| `dnssec_rrsig_freshness` | warning / critical | The closest RRSIG expires in fewer than `signatureFreshness` / `signatureFreshnessCrit` days. |
| `dnssec_denial_uses_nsec3` | warning | The zone uses NSEC for denial of existence, exposing it to trivial walking (RFC 5155 / RFC 7129). |
| `dnssec_nsec3_iterations` | warning / critical (per `nsec3IterationsSeverity`) | `NSEC3PARAM.Iterations` exceeds `nsec3IterationsMax` (RFC 9276 §3.1). |
| `dnssec_nsec3_salt_empty` | warning | `NSEC3PARAM.SaltLength` is non-zero (RFC 9276 §3.1: a salt buys no measurable protection). |
| `dnssec_nsec3_optout_only_when_signed_delegations` | info | The OPT-OUT flag is set in a leaf zone, where it serves no purpose. |
| `dnssec_denial_consistent` | critical | Authoritative servers disagree on the denial-of-existence scheme (NSEC vs NSEC3, or differing parameters). |
| `dnssec_dnskey_ttl_min` | warning | The DNSKEY TTL is below `dnskeyTTLMin`, hurting cache efficiency. |
## License
Licensed under the **MIT License** (see `LICENSE`).

303
checker/collect.go Normal file
View file

@ -0,0 +1,303 @@
package checker
import (
"context"
"encoding/base64"
"fmt"
"strings"
"sync"
"time"
"github.com/miekg/dns"
sdk "git.happydns.org/checker-sdk-go/checker"
)
func (p *dnssecProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
domain, _ := sdk.GetOption[string](opts, "domain_name")
domain = strings.TrimSuffix(strings.TrimSpace(domain), ".")
if domain == "" {
return nil, fmt.Errorf("missing 'domain_name' option")
}
if err := validateDomainName(domain); err != nil {
return nil, err
}
zone := lowerFQDN(domain)
resolver, _ := sdk.GetOption[string](opts, "resolver")
if resolver == "" {
resolver = systemResolver()
}
data := &DNSSECData{
Domain: strings.TrimSuffix(zone, "."),
CollectedAt: time.Now().UTC(),
Servers: map[string]PerServerView{},
}
hosts, addrs, nsErrors, err := resolveAuthNS(ctx, zone, resolver)
if err != nil {
data.Errors = append(data.Errors, err.Error())
return data, nil
}
data.NameServers = hosts
data.Errors = append(data.Errors, nsErrors...)
data.HasDS = hasParentDS(ctx, zone, resolver)
// Per-server collection runs in parallel; each goroutine writes to its
// own slot and a final pass copies it into the result map under the lock.
views := make([]PerServerView, len(addrs))
var wg sync.WaitGroup
wg.Add(len(addrs))
for i, addr := range addrs {
go func() {
defer wg.Done()
views[i] = collectFromServer(ctx, addr, zone)
}()
}
wg.Wait()
for _, v := range views {
data.Servers[v.Server] = v
}
return data, nil
}
func collectFromServer(ctx context.Context, server, zone string) PerServerView {
view := PerServerView{Server: server}
dnskeyResp := authQuery(ctx, server, zone, dns.TypeDNSKEY, &view, true)
if dnskeyResp != nil {
for _, rr := range dnskeyResp.Answer {
switch v := rr.(type) {
case *dns.DNSKEY:
rec := DNSKEYRecord{
Flags: v.Flags,
Protocol: v.Protocol,
Algorithm: v.Algorithm,
PublicKey: v.PublicKey,
KeyTag: v.KeyTag(),
KeySize: estimateKeySize(v),
IsKSK: v.Flags&0x0001 != 0, // SEP bit
}
view.DNSKEYs = append(view.DNSKEYs, rec)
if view.DNSKEYTTL == 0 || v.Hdr.Ttl < view.DNSKEYTTL {
view.DNSKEYTTL = v.Hdr.Ttl
}
case *dns.RRSIG:
if v.TypeCovered == dns.TypeDNSKEY {
view.DNSKEYRRSIGs = append(view.DNSKEYRRSIGs, rrsigOf(v))
}
}
}
}
soaResp := authQuery(ctx, server, zone, dns.TypeSOA, &view, true)
if soaResp != nil {
for _, rr := range soaResp.Answer {
switch v := rr.(type) {
case *dns.SOA:
view.SOA = &SOAObservation{
Serial: v.Serial,
Minimum: v.Minttl,
MName: v.Ns,
TTL: v.Hdr.Ttl,
}
case *dns.RRSIG:
if v.TypeCovered == dns.TypeSOA {
view.SOARRSIGs = append(view.SOARRSIGs, rrsigOf(v))
}
}
}
}
nsec3pResp := authQuery(ctx, server, zone, dns.TypeNSEC3PARAM, &view, true)
if nsec3pResp != nil {
for _, rr := range nsec3pResp.Answer {
if v, ok := rr.(*dns.NSEC3PARAM); ok {
view.NSEC3PARAM = &NSEC3ParamObservation{
HashAlgorithm: v.Hash,
Flags: v.Flags,
Iterations: v.Iterations,
SaltLength: v.SaltLength,
Salt: strings.ToLower(v.Salt),
}
}
}
}
probe := randomLabel() + "." + zone
view.ProbeName = strings.TrimSuffix(probe, ".")
if probeResp := authQuery(ctx, server, probe, dns.TypeA, &view, true); probeResp != nil {
view.DenialKind, view.DenialRecords = classifyDenial(probeResp, view.NSEC3PARAM)
} else if len(view.DNSKEYs) == 0 {
view.DenialKind = DenialNone
}
if cdsResp := authQuery(ctx, server, zone, dns.TypeCDS, &view, true); cdsResp != nil {
for _, rr := range cdsResp.Answer {
if v, ok := rr.(*dns.CDS); ok {
view.CDS = append(view.CDS, DSRecord{
KeyTag: v.KeyTag,
Algorithm: v.Algorithm,
DigestType: v.DigestType,
Digest: strings.ToLower(v.Digest),
})
}
}
}
if cdkResp := authQuery(ctx, server, zone, dns.TypeCDNSKEY, &view, true); cdkResp != nil {
for _, rr := range cdkResp.Answer {
if v, ok := rr.(*dns.CDNSKEY); ok {
view.CDNSKEY = append(view.CDNSKEY, DNSKEYRecord{
Flags: v.Flags,
Protocol: v.Protocol,
Algorithm: v.Algorithm,
PublicKey: v.PublicKey,
KeyTag: v.KeyTag(),
IsKSK: v.Flags&0x0001 != 0,
})
}
}
}
return view
}
// authQuery sends q to the auth server with DO=1 and RD=0, retries over TCP
// on truncation, and records the first error in the per-server view so the
// report can show which probes failed without aborting the rest.
func authQuery(ctx context.Context, server, name string, qtype uint16, view *PerServerView, dnssec bool) *dns.Msg {
q := dns.Question{Name: dns.Fqdn(name), Qtype: qtype, Qclass: dns.ClassINET}
r, err := dnsExchange(ctx, "", server, q, false, dnssec)
if err != nil {
if view.UDPError == "" {
view.UDPError = fmt.Sprintf("%s %s: %v", dns.TypeToString[qtype], name, err)
}
return nil
}
if r != nil && r.Truncated {
r2, err2 := dnsExchange(ctx, "tcp", server, q, false, dnssec)
if err2 != nil {
if view.TCPError == "" {
view.TCPError = fmt.Sprintf("%s %s (TCP): %v", dns.TypeToString[qtype], name, err2)
}
return r // fall back to the truncated answer rather than nothing
}
return r2
}
return r
}
// classifyDenial inspects the Authority section of a NXDOMAIN-ish response
// and maps it to NSEC / NSEC3 / OPT-OUT. NoData responses (NOERROR with NSEC
// proofs in Authority) are classified the same way: from the operator's POV,
// the negative-answer scheme is what matters.
func classifyDenial(r *dns.Msg, nsec3p *NSEC3ParamObservation) (DenialKind, []string) {
var dump []string
hasNSEC, hasNSEC3 := false, false
for _, rr := range r.Ns {
switch rr.(type) {
case *dns.NSEC:
hasNSEC = true
dump = append(dump, rr.String())
case *dns.NSEC3:
hasNSEC3 = true
dump = append(dump, rr.String())
}
}
switch {
case hasNSEC3:
if nsec3p != nil && nsec3p.Flags&0x01 != 0 {
return DenialOptOut, dump
}
return DenialNSEC3, dump
case hasNSEC:
return DenialNSEC, dump
default:
return DenialNone, dump
}
}
func rrsigOf(v *dns.RRSIG) RRSIGObservation {
return RRSIGObservation{
TypeCovered: v.TypeCovered,
Algorithm: v.Algorithm,
Labels: v.Labels,
OrigTTL: v.OrigTtl,
Inception: v.Inception,
Expiration: v.Expiration,
KeyTag: v.KeyTag,
SignerName: v.SignerName,
}
}
// estimateKeySize returns the modulus size in bits for RSA-family keys and
// the curve size for ECDSA / EdDSA. Best-effort: an unparsable PublicKey
// yields 0 so rules that care about size can skip rather than mis-judge.
func estimateKeySize(k *dns.DNSKEY) int {
switch k.Algorithm {
case dns.RSAMD5, dns.RSASHA1, dns.RSASHA1NSEC3SHA1, dns.RSASHA256, dns.RSASHA512:
raw, err := base64.StdEncoding.DecodeString(k.PublicKey)
if err != nil || len(raw) < 3 {
return 0
}
// RFC 3110: 1-byte exponent length OR 1-byte 0 + 2-byte length, then
// the exponent, then the modulus. We only need the modulus length.
var explen int
var off int
if raw[0] == 0 {
if len(raw) < 3 {
return 0
}
explen = int(raw[1])<<8 | int(raw[2])
off = 3
} else {
explen = int(raw[0])
off = 1
}
modOff := off + explen
if modOff >= len(raw) {
return 0
}
return (len(raw) - modOff) * 8
case dns.ECDSAP256SHA256:
return 256
case dns.ECDSAP384SHA384:
return 384
case dns.ED25519:
return 256
case dns.ED448:
return 456
}
return 0
}
// validateDomainName enforces RFC 1035 limits on a trimmed domain (no trailing
// dot): up to 253 octets total, each label 1..63 octets and made of letters,
// digits, hyphens or underscores (the latter is permitted to keep the checker
// usable on zones that publish _-prefixed labels such as _dmarc).
func validateDomainName(d string) error {
if len(d) > 253 {
return fmt.Errorf("domain name too long (%d > 253 octets)", len(d))
}
for _, label := range strings.Split(d, ".") {
if l := len(label); l == 0 || l > 63 {
return fmt.Errorf("invalid label length in domain name")
}
for i := 0; i < len(label); i++ {
c := label[i]
switch {
case c >= 'a' && c <= 'z':
case c >= 'A' && c <= 'Z':
case c >= '0' && c <= '9':
case c == '-' || c == '_':
default:
return fmt.Errorf("invalid character %q in domain name", c)
}
}
}
return nil
}

118
checker/definition.go Normal file
View file

@ -0,0 +1,118 @@
package checker
import (
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
var Version = "built-in"
func Definition() *sdk.CheckerDefinition {
def := &sdk.CheckerDefinition{
ID: "dnssec",
Name: "DNSSEC operational hygiene",
Version: Version,
Availability: sdk.CheckerAvailability{
ApplyToDomain: true,
},
ObservationKeys: []sdk.ObservationKey{ObservationKeyDNSSEC},
Options: sdk.CheckerOptionsDocumentation{
AdminOpts: []sdk.CheckerOptionDocumentation{
{
Id: "resolver",
Type: "string",
Label: "Bootstrap resolver (host:port)",
Description: "Recursive resolver used to discover the apex name servers and to look up the parent DS. Defaults to /etc/resolv.conf.",
},
},
UserOpts: []sdk.CheckerOptionDocumentation{
{
Id: "nsec3IterationsMax",
Type: "uint",
Label: "Maximum NSEC3 iterations",
Description: "RFC 9276 §3.1 sets the recommended ceiling at 0. Increase only if your signer cannot publish 0 yet.",
Default: defaultNSEC3IterationsMax,
},
{
Id: "nsec3IterationsSeverity",
Type: "choice",
Label: "Severity when NSEC3 iterations exceed the ceiling",
Choices: []string{"warn", "crit"},
Default: defaultNSEC3IterationsSeverityWarn,
Description: "Use 'crit' to enforce RFC 9276 strictly.",
},
{
Id: "signatureFreshness",
Type: "uint",
Label: "RRSIG freshness WARN threshold (days)",
Description: "Warn when the closest RRSIG expires in fewer than this many days.",
Default: defaultSignatureFreshnessDays,
},
{
Id: "signatureFreshnessCrit",
Type: "uint",
Label: "RRSIG freshness CRIT threshold (days)",
Default: defaultSignatureFreshnessCrit,
},
{
Id: "minRSAKeySize",
Type: "uint",
Label: "Minimum RSA modulus size (bits)",
Default: defaultMinRSAKeySize,
},
{
Id: "requireSEP",
Type: "bool",
Label: "Require a KSK (DNSKEY with SEP bit)",
Default: defaultRequireSEP,
},
{
Id: "dnskeyTTLMin",
Type: "uint",
Label: "Minimum DNSKEY TTL (seconds)",
Default: defaultDNSKEYTTLMinSec,
},
},
DomainOpts: []sdk.CheckerOptionDocumentation{
{
Id: "domain_name",
Label: "Zone apex",
AutoFill: sdk.AutoFillDomainName,
},
},
},
Rules: []sdk.CheckRule{
zoneSignedRule{},
dnskeyConsistentRule{},
dnskeyQueryOKRule{},
algorithmAllowedRule{},
algorithmModernRule{},
rsaKeySizeRule{},
kskPresentRule{},
dnskeyCountRule{},
rrsigPresentDNSKEYRule{},
rrsigPresentSOARule{},
rrsigValidityWindowRule{},
rrsigFreshnessRule{},
denialUsesNSEC3Rule{},
nsec3IterationsRule{},
nsec3SaltEmptyRule{},
nsec3OptOutRule{},
denialConsistentRule{},
dnskeyTTLMinRule{},
},
HasHTMLReport: true,
Interval: &sdk.CheckIntervalSpec{
Min: 5 * time.Minute,
Max: 24 * time.Hour,
Default: 1 * time.Hour,
},
}
def.BuildRulesInfo()
return def
}

153
checker/dns.go Normal file
View file

@ -0,0 +1,153 @@
package checker
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"net"
"strings"
"sync"
"time"
"github.com/miekg/dns"
)
const dnsTimeout = 5 * time.Second
// dnsExchange sends a single query against a host:port server.
// rd controls the RD bit (set false when querying an authoritative server),
// dnssec controls the DO bit so the server returns RRSIG / NSEC[3] records.
func dnsExchange(ctx context.Context, proto, server string, q dns.Question, rd, dnssec bool) (*dns.Msg, error) {
client := dns.Client{Net: proto, Timeout: dnsTimeout}
m := new(dns.Msg)
m.Id = dns.Id()
m.Question = []dns.Question{q}
m.RecursionDesired = rd
m.SetEdns0(4096, dnssec)
if deadline, ok := ctx.Deadline(); ok {
if d := time.Until(deadline); d > 0 && d < client.Timeout {
client.Timeout = d
}
}
r, _, err := client.Exchange(m, server)
if err != nil {
return nil, err
}
if r == nil {
return nil, fmt.Errorf("nil response from %s", server)
}
return r, nil
}
func recursiveExchange(ctx context.Context, server string, q dns.Question, dnssec bool) (*dns.Msg, error) {
return dnsExchange(ctx, "", server, q, true, dnssec)
}
func systemResolver() string {
cfg, err := dns.ClientConfigFromFile("/etc/resolv.conf")
if err != nil || len(cfg.Servers) == 0 {
return net.JoinHostPort("1.1.1.1", "53")
}
return net.JoinHostPort(cfg.Servers[0], cfg.Port)
}
func hostPort(host, port string) string {
return net.JoinHostPort(strings.TrimSuffix(host, "."), port)
}
func lowerFQDN(name string) string {
return strings.ToLower(dns.Fqdn(name))
}
// resolveAuthNS returns "host:port" addresses for every authoritative NS of
// zone, asking the bootstrap resolver. The list is deduplicated and sorted
// only by NS host order so the per-server section of the report is stable.
// Per-host lookup failures are returned as nsErrors so the caller can surface
// them without aborting the whole collection.
func resolveAuthNS(ctx context.Context, zone, resolver string) (hosts []string, addrs []string, nsErrors []string, err error) {
q := dns.Question{Name: dns.Fqdn(zone), Qtype: dns.TypeNS, Qclass: dns.ClassINET}
r, err := recursiveExchange(ctx, resolver, q, false)
if err != nil {
return nil, nil, nil, fmt.Errorf("NS lookup for %s: %w", zone, err)
}
if r.Rcode != dns.RcodeSuccess {
return nil, nil, nil, fmt.Errorf("NS lookup for %s: rcode %s", zone, dns.RcodeToString[r.Rcode])
}
for _, rr := range r.Answer {
if ns, ok := rr.(*dns.NS); ok {
hosts = append(hosts, strings.ToLower(strings.TrimSuffix(ns.Ns, ".")))
}
}
if len(hosts) == 0 {
return nil, nil, nil, fmt.Errorf("no NS records for %s", zone)
}
results := make([][]string, len(hosts))
errs := make([]string, len(hosts))
var wg sync.WaitGroup
wg.Add(len(hosts))
for i, host := range hosts {
go func() {
defer wg.Done()
a, err := net.DefaultResolver.LookupHost(ctx, host)
if err != nil {
errs[i] = fmt.Sprintf("address lookup for %s: %v", host, err)
return
}
out := make([]string, 0, len(a))
for _, ip := range a {
out = append(out, hostPort(ip, "53"))
}
results[i] = out
}()
}
wg.Wait()
seen := map[string]struct{}{}
for _, batch := range results {
for _, a := range batch {
if _, ok := seen[a]; ok {
continue
}
seen[a] = struct{}{}
addrs = append(addrs, a)
}
}
for _, e := range errs {
if e != "" {
nsErrors = append(nsErrors, e)
}
}
return hosts, addrs, nsErrors, nil
}
// hasParentDS asks the bootstrap resolver whether the parent zone publishes
// a DS for zone. Failures are reported as "false, nil" because absence-of-
// evidence is the practical fallback when the network is glitchy.
func hasParentDS(ctx context.Context, zone, resolver string) bool {
q := dns.Question{Name: dns.Fqdn(zone), Qtype: dns.TypeDS, Qclass: dns.ClassINET}
r, err := recursiveExchange(ctx, resolver, q, true)
if err != nil || r == nil || r.Rcode != dns.RcodeSuccess {
return false
}
for _, rr := range r.Answer {
if _, ok := rr.(*dns.DS); ok {
return true
}
}
return false
}
// randomLabel returns a 32-hex-char label used as the leftmost component of
// the NXDOMAIN probe name. 32 hex chars = 128 bits of entropy: collision
// with an existing wildcard or zone name is statistically impossible.
func randomLabel() string {
var b [16]byte
_, _ = rand.Read(b[:])
return hex.EncodeToString(b[:])
}

34
checker/interactive.go Normal file
View file

@ -0,0 +1,34 @@
//go:build standalone
package checker
import (
"errors"
"net/http"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
func (p *dnssecProvider) RenderForm() []sdk.CheckerOptionField {
return []sdk.CheckerOptionField{
{
Id: "name",
Type: "string",
Label: "Zone apex",
Placeholder: "example.com",
Required: true,
Description: "Fully-qualified zone apex to analyse.",
},
}
}
func (p *dnssecProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) {
name := strings.TrimSpace(r.FormValue("name"))
if name == "" {
return nil, errors.New("name is required")
}
return sdk.CheckerOptions{
"domain_name": strings.TrimSuffix(name, "."),
}, nil
}

19
checker/provider.go Normal file
View file

@ -0,0 +1,19 @@
package checker
import (
sdk "git.happydns.org/checker-sdk-go/checker"
)
func Provider() sdk.ObservationProvider {
return &dnssecProvider{}
}
type dnssecProvider struct{}
func (p *dnssecProvider) Key() sdk.ObservationKey {
return ObservationKeyDNSSEC
}
func (p *dnssecProvider) Definition() *sdk.CheckerDefinition {
return Definition()
}

755
checker/report.go Normal file
View file

@ -0,0 +1,755 @@
package checker
import (
"bytes"
"encoding/json"
"fmt"
"html/template"
"sort"
"strings"
"time"
"github.com/miekg/dns"
sdk "git.happydns.org/checker-sdk-go/checker"
)
func (p *dnssecProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) {
var data DNSSECData
if raw := ctx.Data(); len(raw) > 0 {
if err := json.Unmarshal(raw, &data); err != nil {
return "", fmt.Errorf("parse dnssec data: %w", err)
}
}
view := buildReportView(&data, ctx.States())
buf := &bytes.Buffer{}
if err := reportTmpl.Execute(buf, view); err != nil {
return "", err
}
return buf.String(), nil
}
// commonFailures drives both the visual order of the "Fix these first" cards
// and the curated catalogue of operator-facing scenarios. The order matches
// the rough operational severity in production (a NSEC-walkable zone or a
// stuck signer hurts more than a missing CDS).
var commonFailures = []struct {
rule, title string
}{
{"dnssec_zone_signed", "Zone is missing DNSSEC records"},
{"dnssec_rrsig_validity_window", "RRSIG outside its validity window"},
{"dnssec_rrsig_freshness", "RRSIG close to expiration"},
{"dnssec_dnskey_consistent", "Authoritative servers serve different DNSKEY RRsets"},
{"dnssec_denial_uses_nsec3", "Zone is enumerable through NSEC walking"},
{"dnssec_nsec3_iterations", "NSEC3 iterations above RFC 9276 ceiling"},
{"dnssec_nsec3_salt_empty", "NSEC3 salt is not empty"},
{"dnssec_denial_consistent", "Servers disagree on the denial-of-existence scheme"},
{"dnssec_algorithm_allowed", "Disallowed DNSSEC algorithm"},
{"dnssec_algorithm_modern", "Legacy RSA algorithm in use"},
{"dnssec_rsa_keysize", "RSA key too small"},
{"dnssec_ksk_present", "No KSK published"},
{"dnssec_rrsig_present_dnskey", "DNSKEY RRset has no covering RRSIG"},
{"dnssec_rrsig_present_soa", "SOA RRset has no covering RRSIG"},
{"dnssec_dnskey_query_ok", "Authoritative server unreachable for DNSKEY"},
{"dnssec_dnskey_ttl_min", "DNSKEY TTL below recommended minimum"},
{"dnssec_dnskey_count", "Too many DNSKEYs published"},
{"dnssec_nsec3_optout_only_when_signed_delegations", "OPT-OUT in a leaf zone"},
}
type reportView struct {
Domain string
CollectedAt string
NameServers []string
OverallStatus string
OverallText string
OverallClass string
HasStates bool
Banner bannerView
TopFailures []topFailure
Enumerability enumView
Keys []keyRow
Signatures []sigRow
PerServer []serverView
OtherFindings []otherFinding
GlobalErrors []string
RawJSON string
}
type bannerView struct {
Algorithms string
DenialKind string
DNSKEYCount int
NearestExpiryDays string
HasNearestExpiry bool
NearestExpiryClass string
}
type topFailure struct {
RuleName string
Title string
Severity string
Messages []string
Hint string
Subject string
}
type enumView struct {
Kind string
KindClass string
Verdict string
VerdictClass string
Explanation string
Iterations uint16
SaltLength uint8
Salt string
OptOut bool
HasNSEC3Param bool
RFC9276Compliant bool
WalkableWarning bool
}
type keyRow struct {
KeyTag uint16
Algorithm string
Flags string
Size string
Role string
}
type sigRow struct {
Server string
TypeCovered string
KeyTag uint16
Algorithm string
Inception string
Expiration string
Remaining string
BarPercent int
BarClass string
}
type serverView struct {
Server string
UDPError string
TCPError string
DNSKEYCount int
DenialKind string
NSEC3Summary string
ProbeName string
DenialDump []string
}
type otherFinding struct {
Severity string
RuleName string
Subject string
Message string
Hint string
}
func buildReportView(d *DNSSECData, states []sdk.CheckState) *reportView {
v := &reportView{
Domain: d.Domain,
NameServers: d.NameServers,
HasStates: len(states) > 0,
}
if !d.CollectedAt.IsZero() {
v.CollectedAt = d.CollectedAt.Format(time.RFC3339)
}
v.GlobalErrors = d.Errors
if raw, err := json.MarshalIndent(d, "", " "); err == nil {
v.RawJSON = string(raw)
} else {
v.GlobalErrors = append(v.GlobalErrors, fmt.Sprintf("render raw JSON: %v", err))
}
v.Banner = buildBanner(d)
v.Enumerability = buildEnum(d)
v.Keys = buildKeys(d)
v.Signatures = buildSignatures(d)
v.PerServer = buildServers(d)
if v.HasStates {
worst := worstStatus(states)
v.OverallStatus, v.OverallText, v.OverallClass = statusLabel(worst)
titleByRule := map[string]string{}
order := map[string]int{}
for i, cf := range commonFailures {
titleByRule[cf.rule] = cf.title
order[cf.rule] = i
}
topMap := map[string]*topFailure{}
for _, s := range states {
if s.Status == sdk.StatusOK || s.Status == sdk.StatusUnknown || s.Status == sdk.StatusInfo {
continue
}
if _, isTop := titleByRule[s.RuleName]; !isTop {
v.OtherFindings = append(v.OtherFindings, otherFinding{
Severity: severityClass(s.Status),
RuleName: s.RuleName,
Subject: s.Subject,
Message: s.Message,
Hint: hintOf(s),
})
continue
}
tf := topMap[s.RuleName]
if tf == nil {
tf = &topFailure{
RuleName: s.RuleName,
Title: titleByRule[s.RuleName],
Severity: severityClass(s.Status),
Hint: hintOf(s),
Subject: s.Subject,
}
topMap[s.RuleName] = tf
}
tf.Messages = append(tf.Messages, s.Message)
if tf.Hint == "" {
tf.Hint = hintOf(s)
}
if statusRank(s.Status) > severityRankClass(tf.Severity) {
tf.Severity = severityClass(s.Status)
}
}
ruleNames := make([]string, 0, len(topMap))
for n := range topMap {
ruleNames = append(ruleNames, n)
}
sort.Slice(ruleNames, func(i, j int) bool { return order[ruleNames[i]] < order[ruleNames[j]] })
for _, n := range ruleNames {
v.TopFailures = append(v.TopFailures, *topMap[n])
}
} else {
v.OverallStatus = "info"
v.OverallText = "Rule output not provided"
v.OverallClass = "status-info"
}
return v
}
func buildBanner(d *DNSSECData) bannerView {
algos := map[uint8]bool{}
count := 0
for _, k := range allDNSKEYs(d) {
algos[k.Algorithm] = true
count++
}
algoList := make([]string, 0, len(algos))
for a := range algos {
algoList = append(algoList, fmt.Sprintf("%d (%s)", a, dns.AlgorithmToString[a]))
}
sort.Strings(algoList)
b := bannerView{
Algorithms: strings.Join(algoList, ", "),
DenialKind: string(majorityDenialKind(d)),
DNSKEYCount: count,
}
if b.Algorithms == "" {
b.Algorithms = "—"
}
now := time.Now().UTC().Unix()
var nearest int64 = 1 << 30
found := false
for _, name := range sortedServers(d) {
v := d.Servers[name]
for _, sig := range v.AllRRSIGs() {
diff := int64(int32(sig.Expiration - uint32(now)))
if !found || diff < nearest {
nearest = diff
found = true
}
}
}
if found {
b.HasNearestExpiry = true
days := nearest / 86400
switch {
case nearest < 0:
b.NearestExpiryDays = "EXPIRED"
b.NearestExpiryClass = "crit"
case days < int64(defaultSignatureFreshnessCrit):
b.NearestExpiryDays = fmt.Sprintf("%dh", nearest/3600)
b.NearestExpiryClass = "crit"
case days < int64(defaultSignatureFreshnessDays):
b.NearestExpiryDays = fmt.Sprintf("%dd", days)
b.NearestExpiryClass = "warn"
default:
b.NearestExpiryDays = fmt.Sprintf("%dd", days)
b.NearestExpiryClass = "ok"
}
}
return b
}
func buildEnum(d *DNSSECData) enumView {
kind := majorityDenialKind(d)
param := firstNSEC3Param(d)
e := enumView{
Kind: string(kind),
KindClass: enumKindClass(kind),
}
switch kind {
case DenialNSEC:
e.WalkableWarning = true
e.Verdict = "Zone is enumerable"
e.VerdictClass = "warn"
e.Explanation = "NSEC publishes a sorted, signed list of every name in the zone; an attacker can iterate it (`zone walking`) and recover every label. RFC 7129 lays out the details. Migrate to NSEC3 with iterations=0 and an empty salt (RFC 9276)."
case DenialNSEC3:
e.HasNSEC3Param = param != nil
if param != nil {
e.Iterations = param.Iterations
e.SaltLength = param.SaltLength
e.Salt = param.Salt
e.OptOut = param.Flags&0x01 != 0
compliant := param.Iterations == 0 && param.SaltLength == 0
e.RFC9276Compliant = compliant
if compliant {
e.Verdict = "RFC 9276 compliant"
e.VerdictClass = "ok"
e.Explanation = "NSEC3 with iterations=0 and an empty salt is the modern recommendation: it gives some opacity against casual enumeration without burning resolver CPU."
} else {
e.Verdict = "NSEC3 in use, but not RFC 9276 compliant"
e.VerdictClass = "warn"
var issues []string
if param.Iterations > 0 {
issues = append(issues, fmt.Sprintf("iterations=%d (recommended 0)", param.Iterations))
}
if param.SaltLength > 0 {
issues = append(issues, fmt.Sprintf("salt length=%d (recommended 0)", param.SaltLength))
}
e.Explanation = fmt.Sprintf("RFC 9276 §3.1: %s. Modern resolvers may treat answers with iterations>0 as insecure or bogus.", strings.Join(issues, "; "))
}
} else {
e.Verdict = "NSEC3 in use"
e.VerdictClass = "info"
e.Explanation = "Negative answers are protected by NSEC3 hashing. NSEC3PARAM was not observed; rules cannot fully verify RFC 9276 compliance."
}
case DenialOptOut:
e.HasNSEC3Param = param != nil
if param != nil {
e.Iterations = param.Iterations
e.SaltLength = param.SaltLength
e.Salt = param.Salt
e.OptOut = true
}
e.Verdict = "NSEC3 with OPT-OUT"
e.VerdictClass = "info"
e.Explanation = "OPT-OUT skips authenticated denial of existence for unsigned delegations. Appropriate for TLDs/registries; surprising in a leaf zone."
default:
e.Verdict = "Zone is unsigned"
e.VerdictClass = "info"
e.Explanation = "No NSEC or NSEC3 records were observed in the NXDOMAIN probe. Either the zone is unsigned, or the probe could not reach the authoritative servers."
}
return e
}
func enumKindClass(k DenialKind) string {
switch k {
case DenialNSEC:
return "kind-nsec"
case DenialNSEC3:
return "kind-nsec3"
case DenialOptOut:
return "kind-optout"
}
return "kind-none"
}
func buildKeys(d *DNSSECData) []keyRow {
out := make([]keyRow, 0)
for _, k := range allDNSKEYs(d) {
role := "ZSK"
if k.IsKSK {
role = "KSK"
}
size := "—"
if k.KeySize > 0 {
size = fmt.Sprintf("%d bits", k.KeySize)
}
out = append(out, keyRow{
KeyTag: k.KeyTag,
Algorithm: fmt.Sprintf("%d (%s)", k.Algorithm, dns.AlgorithmToString[k.Algorithm]),
Flags: fmt.Sprintf("%d", k.Flags),
Size: size,
Role: role,
})
}
return out
}
func buildSignatures(d *DNSSECData) []sigRow {
now := time.Now().UTC().Unix()
out := make([]sigRow, 0)
for _, name := range sortedServers(d) {
v := d.Servers[name]
for _, s := range v.AllRRSIGs() {
incTime := time.Unix(int64(s.Inception), 0).UTC()
expTime := time.Unix(int64(s.Expiration), 0).UTC()
remaining := int64(int32(s.Expiration - uint32(now)))
lifetime := int64(int32(s.Expiration - s.Inception))
percent := 0
if lifetime > 0 && remaining > 0 {
percent = int(remaining * 100 / lifetime)
}
class := "ok"
switch {
case remaining < 0:
class = "crit"
percent = 0
case remaining < int64(defaultSignatureFreshnessCrit)*86400:
class = "crit"
case remaining < int64(defaultSignatureFreshnessDays)*86400:
class = "warn"
}
row := sigRow{
Server: name,
TypeCovered: dns.TypeToString[s.TypeCovered],
KeyTag: s.KeyTag,
Algorithm: fmt.Sprintf("%d", s.Algorithm),
Inception: incTime.Format(time.RFC3339),
Expiration: expTime.Format(time.RFC3339),
BarPercent: percent,
BarClass: class,
}
switch {
case remaining < 0:
row.Remaining = "expired"
case remaining < 86400:
row.Remaining = fmt.Sprintf("%dh left", remaining/3600)
default:
row.Remaining = fmt.Sprintf("%dd left", remaining/86400)
}
out = append(out, row)
}
}
return out
}
func buildServers(d *DNSSECData) []serverView {
out := make([]serverView, 0, len(d.Servers))
for _, name := range sortedServers(d) {
v := d.Servers[name]
row := serverView{
Server: name,
UDPError: v.UDPError,
TCPError: v.TCPError,
DNSKEYCount: len(v.DNSKEYs),
DenialKind: string(v.DenialKind),
ProbeName: v.ProbeName,
DenialDump: v.DenialRecords,
}
if v.NSEC3PARAM != nil {
optOut := ""
if v.NSEC3PARAM.Flags&0x01 != 0 {
optOut = " OPT-OUT"
}
row.NSEC3Summary = fmt.Sprintf("hash=%d, iter=%d, salt-len=%d%s",
v.NSEC3PARAM.HashAlgorithm, v.NSEC3PARAM.Iterations, v.NSEC3PARAM.SaltLength, optOut)
}
out = append(out, row)
}
return out
}
func worstStatus(states []sdk.CheckState) sdk.Status {
worst := sdk.StatusOK
for _, s := range states {
if statusRank(s.Status) > statusRank(worst) {
worst = s.Status
}
}
return worst
}
func statusLabel(s sdk.Status) (status, text, class string) {
switch s {
case sdk.StatusCrit:
return "crit", "Critical issues detected", "status-crit"
case sdk.StatusError:
return "error", "Checker error", "status-crit"
case sdk.StatusWarn:
return "warn", "Warnings detected", "status-warn"
case sdk.StatusInfo:
return "info", "Informational notes", "status-info"
default:
return "ok", "DNSSEC hygiene looks good", "status-ok"
}
}
func severityClass(s sdk.Status) string {
switch s {
case sdk.StatusCrit, sdk.StatusError:
return "crit"
case sdk.StatusWarn:
return "warn"
case sdk.StatusInfo:
return "info"
default:
return "ok"
}
}
func statusRank(s sdk.Status) int {
switch s {
case sdk.StatusError, sdk.StatusCrit:
return 4
case sdk.StatusWarn:
return 3
case sdk.StatusInfo:
return 2
case sdk.StatusOK:
return 1
}
return 0
}
func severityRankClass(c string) int {
switch c {
case "crit":
return 4
case "warn":
return 3
case "info":
return 2
case "ok":
return 1
}
return 0
}
func hintOf(s sdk.CheckState) string {
if s.Meta == nil {
return ""
}
h, _ := s.Meta[hintKey].(string)
return h
}
var reportTmpl = template.Must(template.New("dnssec-report").Parse(reportTemplate))
const reportTemplate = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>DNSSEC report {{.Domain}}</title>
<style>
:root {
--ok: #2e7d32;
--info: #0277bd;
--warn: #ef6c00;
--crit: #c62828;
--error: #6a1b9a;
--bg: #f7f7f8;
--card: #ffffff;
--border: #e5e7eb;
--text: #111827;
--muted: #6b7280;
}
* { box-sizing: border-box; }
body { margin: 0; padding: 1.2rem; max-width: 100%; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; color: var(--text); background: var(--bg); line-height: 1.45; }
h1 { font-size: 1.4rem; margin: 0 0 .3rem 0; }
h2 { font-size: 1.05rem; margin: 1.6rem 0 .6rem 0; border-bottom: 1px solid var(--border); padding-bottom: .25rem; }
h3 { font-size: .95rem; margin: 0 0 .35rem 0; }
.muted { color: var(--muted); }
code { font-family: ui-monospace,SFMono-Regular,Menlo,monospace; word-break: break-all; }
.status-banner { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: .5rem; padding: .8rem 1rem; border-radius: 8px; color: #fff; margin-bottom: 1rem; }
.status-ok { background: var(--ok); }
.status-info { background: var(--info); }
.status-warn { background: var(--warn); }
.status-crit { background: var(--crit); }
.status-banner .label { font-weight: 600; font-size: 1rem; }
.status-banner .sub { opacity: .92; font-size: .85rem; }
.grid { display: grid; grid-template-columns: repeat(auto-fit,minmax(220px,1fr)); gap: .75rem; margin-bottom: 1rem; }
.card { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: .8rem 1rem; }
.card .k { color: var(--muted); font-size: .75rem; text-transform: uppercase; letter-spacing: .03em; }
.card .v { font-family: ui-monospace,SFMono-Regular,Menlo,monospace; font-size: .95rem; word-break: break-all; }
.top-failure { border-left: 4px solid var(--crit); background: #fef2f2; padding: .8rem 1rem; border-radius: 6px; margin-bottom: .6rem; }
.top-failure.severity-warn { border-color: var(--warn); background: #fffbeb; }
.top-failure.severity-info { border-color: var(--info); background: #eff6ff; }
.top-failure h3 { margin-bottom: .25rem; }
.top-failure ul { margin: .25rem 0 .35rem 1.1rem; padding: 0; font-size: .9rem; }
.top-failure .fix { background: rgba(0,0,0,.04); padding: .45rem .6rem; border-radius: 4px; font-size: .9rem; margin-top: .35rem; }
.top-failure .fix strong { display: block; color: var(--text); margin-bottom: .15rem; }
.sev { display: inline-block; padding: .08rem .4rem; border-radius: 4px; font-size: .72rem; font-weight: 600; color: #fff; text-transform: uppercase; }
.sev-ok { background: var(--ok); }
.sev-info { background: var(--info); }
.sev-warn { background: var(--warn); }
.sev-crit { background: var(--crit); }
table { width: 100%; border-collapse: collapse; font-size: .88rem; background: var(--card); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
th, td { text-align: left; padding: .45rem .7rem; border-bottom: 1px solid var(--border); vertical-align: top; }
th { background: #f3f4f6; font-weight: 600; font-size: .78rem; text-transform: uppercase; letter-spacing: .03em; color: var(--muted); }
tr:last-child td { border-bottom: none; }
details { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: .5rem .8rem; }
details pre { max-height: 360px; overflow: auto; font-size: .78rem; }
.enum-card { padding: .9rem 1rem; border-radius: 8px; margin-bottom: 1rem; border: 1px solid var(--border); background: var(--card); }
.enum-card.kind-nsec { border-left: 6px solid var(--warn); background: #fff7ed; }
.enum-card.kind-nsec3 { border-left: 6px solid var(--ok); background: #ecfdf5; }
.enum-card.kind-optout { border-left: 6px solid var(--info); background: #eff6ff; }
.enum-card.kind-none { border-left: 6px solid var(--muted); }
.enum-verdict { font-weight: 600; margin-bottom: .25rem; }
.enum-verdict.ok { color: var(--ok); }
.enum-verdict.warn { color: var(--warn); }
.enum-verdict.crit { color: var(--crit); }
.enum-verdict.info { color: var(--info); }
.enum-params { margin-top: .5rem; font-family: ui-monospace,SFMono-Regular,Menlo,monospace; font-size: .85rem; }
.bar { display: inline-block; vertical-align: middle; width: 80px; height: 8px; background: #e5e7eb; border-radius: 4px; overflow: hidden; margin-right: .35rem; }
.bar > i { display: block; height: 100%; }
.bar-ok > i { background: var(--ok); }
.bar-warn > i { background: var(--warn); }
.bar-crit > i { background: var(--crit); }
.badge { display: inline-block; background: #e5e7eb; padding: .05rem .4rem; border-radius: 4px; font-size: .75rem; }
.badge.on { background: #dcfce7; color: #14532d; }
.badge.off { background: #fee2e2; color: #7f1d1d; }
.servers { display: flex; flex-direction: column; gap: .5rem; }
.server-card { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: .7rem .9rem; font-size: .88rem; }
.server-card .head { display: flex; justify-content: space-between; flex-wrap: wrap; gap: .35rem; align-items: baseline; }
.server-card .err { color: var(--crit); font-size: .82rem; margin-top: .25rem; }
.err-list { color: var(--crit); margin: .5rem 0; }
</style>
</head>
<body>
<div class="status-banner {{.OverallClass}}">
<div>
<div class="label">{{.OverallText}}</div>
<div class="sub">for <code>{{.Domain}}</code>{{if .CollectedAt}} · collected {{.CollectedAt}}{{end}}</div>
</div>
<div class="sub">
{{.Banner.DNSKEYCount}} DNSKEY · denial: <strong>{{.Banner.DenialKind}}</strong>
{{if .Banner.HasNearestExpiry}} · next RRSIG expiry: <strong>{{.Banner.NearestExpiryDays}}</strong>{{end}}
</div>
</div>
{{if .GlobalErrors}}
<div class="card err-list">
<strong>Collection errors:</strong>
<ul>{{range .GlobalErrors}}<li>{{.}}</li>{{end}}</ul>
</div>
{{end}}
<div class="grid">
<div class="card"><div class="k">Zone</div><div class="v">{{.Domain}}</div></div>
<div class="card"><div class="k">Algorithms</div><div class="v">{{.Banner.Algorithms}}</div></div>
<div class="card"><div class="k">DNSKEY count</div><div class="v">{{.Banner.DNSKEYCount}}</div></div>
<div class="card"><div class="k">Denial scheme</div><div class="v">{{.Banner.DenialKind}}</div></div>
<div class="card"><div class="k">Authoritative NS</div><div class="v">{{range .NameServers}}{{.}}<br>{{else}}{{end}}</div></div>
</div>
{{if .TopFailures}}
<h2>Fix these first</h2>
{{range .TopFailures}}
<div class="top-failure severity-{{.Severity}}">
<h3>{{.Title}} <span class="sev sev-{{.Severity}}">{{.Severity}}</span></h3>
<ul>{{range .Messages}}<li>{{.}}</li>{{end}}</ul>
{{if .Hint}}<div class="fix"><strong>How to fix</strong>{{.Hint}}</div>{{end}}
</div>
{{end}}
{{end}}
<h2>Enumerability</h2>
<div class="enum-card {{.Enumerability.KindClass}}">
<div class="enum-verdict {{.Enumerability.VerdictClass}}">
{{.Enumerability.Verdict}} <span class="badge">scheme: {{.Enumerability.Kind}}</span>
</div>
<div>{{.Enumerability.Explanation}}</div>
{{if .Enumerability.HasNSEC3Param}}
<div class="enum-params">
iterations = <strong>{{.Enumerability.Iterations}}</strong>{{if eq .Enumerability.Iterations 0}} <span class="badge on">RFC 9276 </span>{{else}} <span class="badge off">> 0</span>{{end}}
· salt length = <strong>{{.Enumerability.SaltLength}}</strong>{{if eq .Enumerability.SaltLength 0}} <span class="badge on">empty </span>{{else}} <span class="badge off">{{.Enumerability.Salt}}</span>{{end}}
· OPT-OUT: {{if .Enumerability.OptOut}}<span class="badge off">on</span>{{else}}<span class="badge on">off</span>{{end}}
</div>
{{end}}
</div>
{{if .Keys}}
<h2>DNSKEYs</h2>
<table>
<thead><tr><th>KeyTag</th><th>Role</th><th>Algorithm</th><th>Flags</th><th>Size</th></tr></thead>
<tbody>
{{range .Keys}}
<tr>
<td><code>{{.KeyTag}}</code></td>
<td>{{.Role}}</td>
<td>{{.Algorithm}}</td>
<td>{{.Flags}}</td>
<td>{{.Size}}</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
{{if .Signatures}}
<h2>RRSIGs</h2>
<table>
<thead><tr><th>Server</th><th>Covers</th><th>KeyTag</th><th>Inception</th><th>Expiration</th><th>Validity</th></tr></thead>
<tbody>
{{range .Signatures}}
<tr>
<td><code>{{.Server}}</code></td>
<td>{{.TypeCovered}}</td>
<td><code>{{.KeyTag}}</code></td>
<td><code>{{.Inception}}</code></td>
<td><code>{{.Expiration}}</code></td>
<td><span class="bar bar-{{.BarClass}}"><i style="width:{{.BarPercent}}%"></i></span> {{.Remaining}}</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
{{if .PerServer}}
<h2>Per-server view</h2>
<div class="servers">
{{range .PerServer}}
<div class="server-card">
<div class="head">
<strong><code>{{.Server}}</code></strong>
<span>
<span class="badge">{{.DNSKEYCount}} DNSKEY</span>
<span class="badge">denial: {{.DenialKind}}</span>
</span>
</div>
{{if .NSEC3Summary}}<div class="muted">NSEC3PARAM: <code>{{.NSEC3Summary}}</code></div>{{end}}
{{if .ProbeName}}<div class="muted">NXDOMAIN probe: <code>{{.ProbeName}}</code></div>{{end}}
{{if .UDPError}}<div class="err">UDP error: {{.UDPError}}</div>{{end}}
{{if .TCPError}}<div class="err">TCP error: {{.TCPError}}</div>{{end}}
{{if .DenialDump}}
<details><summary>Denial proof records</summary><pre>{{range .DenialDump}}{{.}}
{{end}}</pre></details>
{{end}}
</div>
{{end}}
</div>
{{end}}
{{if .OtherFindings}}
<h2>Additional findings</h2>
<table>
<thead><tr><th>Severity</th><th>Rule</th><th>Subject</th><th>Message</th></tr></thead>
<tbody>
{{range .OtherFindings}}
<tr>
<td><span class="sev sev-{{.Severity}}">{{.Severity}}</span></td>
<td><code>{{.RuleName}}</code></td>
<td><code>{{.Subject}}</code></td>
<td>{{.Message}}{{if .Hint}}<br><span class="muted">{{.Hint}}</span>{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
{{if .RawJSON}}
<h2>Raw observation</h2>
<details><summary>Show JSON</summary><pre>{{.RawJSON}}</pre></details>
{{end}}
</body>
</html>`

97
checker/rules_common.go Normal file
View file

@ -0,0 +1,97 @@
package checker
import (
"context"
"fmt"
"sort"
sdk "git.happydns.org/checker-sdk-go/checker"
)
const (
hintKey = "hint"
codeKey = "code"
)
// defaults centralised so Definition's docs and runtime reads cannot drift.
const (
defaultNSEC3IterationsMax = 0 // RFC 9276 §3.1
defaultNSEC3IterationsSeverityWarn = "warn"
defaultSignatureFreshnessDays = 7
defaultSignatureFreshnessCrit = 1
defaultMinRSAKeySize = 2048
defaultRequireSEP = true
defaultDNSKEYTTLMinSec = 3600
)
func defaultAllowedAlgorithms() []uint8 { return []uint8{8, 13, 14, 15, 16} }
func defaultForbiddenAlgorithms() []uint8 { return []uint8{1, 3, 5, 6, 7, 12} }
func loadDNSSEC(ctx context.Context, obs sdk.ObservationGetter) (*DNSSECData, []sdk.CheckState) {
var data DNSSECData
if err := obs.Get(ctx, ObservationKeyDNSSEC, &data); err != nil {
return nil, []sdk.CheckState{{
Status: sdk.StatusError,
Message: fmt.Sprintf("failed to read dnssec observation: %v", err),
}}
}
return &data, nil
}
func skipped(reason string) []sdk.CheckState {
return []sdk.CheckState{{
Status: sdk.StatusUnknown,
Message: "skipped: " + reason,
}}
}
func okState(subject, message string) []sdk.CheckState {
return []sdk.CheckState{{
Status: sdk.StatusOK,
Subject: subject,
Message: message,
}}
}
func withMeta(s sdk.CheckState, hint, code string) sdk.CheckState {
if hint == "" && code == "" {
return s
}
if s.Meta == nil {
s.Meta = map[string]any{}
}
if hint != "" {
s.Meta[hintKey] = hint
}
if code != "" {
s.Meta[codeKey] = code
s.Code = code
}
return s
}
// sortedServers returns the servers map keys in stable order so per-server
// rule output is reproducible across runs.
func sortedServers(d *DNSSECData) []string {
keys := make([]string, 0, len(d.Servers))
for k := range d.Servers {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// hasAnyDNSKEY returns true when at least one server returned at least one
// DNSKEY: a coarse "is the zone signed at all" probe.
func hasAnyDNSKEY(d *DNSSECData) bool {
for _, v := range d.Servers {
if len(v.DNSKEYs) > 0 {
return true
}
}
return false
}
func optionUint(opts sdk.CheckerOptions, key string, def uint) uint {
return uint(sdk.GetIntOption(opts, key, int(def)))
}

View file

@ -0,0 +1,240 @@
package checker
import (
"context"
"fmt"
"sort"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// majorityDenialKind picks the denial scheme observed by most servers; ties
// fall back to the alphabetically-first kind so the picked value is
// deterministic. An empty/None map collapses to DenialNone.
func majorityDenialKind(d *DNSSECData) DenialKind {
counts := map[DenialKind]int{}
for _, v := range d.Servers {
if v.DenialKind != "" {
counts[v.DenialKind]++
}
}
if len(counts) == 0 {
return DenialNone
}
type pair struct {
k DenialKind
n int
}
var ps []pair
for k, n := range counts {
ps = append(ps, pair{k, n})
}
sort.Slice(ps, func(i, j int) bool {
if ps[i].n != ps[j].n {
return ps[i].n > ps[j].n
}
return ps[i].k < ps[j].k
})
return ps[0].k
}
// firstNSEC3Param returns the first NSEC3PARAM observed across servers; it
// is checked elsewhere that all servers agree (denial_consistent rule).
func firstNSEC3Param(d *DNSSECData) *NSEC3ParamObservation {
for _, name := range sortedServers(d) {
if v := d.Servers[name]; v.NSEC3PARAM != nil {
return v.NSEC3PARAM
}
}
return nil
}
// denialUsesNSEC3Rule is the central anti-walking rule. It is the most
// frequent operator-actionable finding for small zones whose signers default
// to NSEC.
type denialUsesNSEC3Rule struct{}
func (denialUsesNSEC3Rule) Name() string { return "dnssec_denial_uses_nsec3" }
func (denialUsesNSEC3Rule) Description() string {
return "Warns when the zone uses NSEC for negative answers, which makes the zone walkable (RFC 5155 / RFC 7129)."
}
func (denialUsesNSEC3Rule) 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 []sdk.CheckState{{
Status: sdk.StatusInfo,
Subject: data.Domain,
Message: "zone is unsigned: denial-of-existence scheme is not applicable",
}}
}
kind := majorityDenialKind(data)
switch kind {
case DenialNSEC:
return []sdk.CheckState{withMeta(sdk.CheckState{
Status: sdk.StatusWarn,
Subject: data.Domain,
Message: "zone uses NSEC for negative answers: every name in the zone can be enumerated by walking NSEC chains",
}, "Migrate to NSEC3 with iterations=0 and an empty salt (RFC 9276). BIND: `dnssec-policy default;` (named.conf). Knot: `policy.signing-policy { nsec3 = on; nsec3-iterations = 0; nsec3-salt-length = 0 }`. PowerDNS: `pdnsutil set-nsec3 ZONE \"1 0 0 -\"`.",
"dnssec.nsec_walkable")}
case DenialNSEC3, DenialOptOut:
return okState(data.Domain, fmt.Sprintf("zone uses %s for negative answers", kind))
case DenialNone:
return []sdk.CheckState{{
Status: sdk.StatusInfo,
Subject: data.Domain,
Message: "could not classify the negative-answer scheme: no NSEC/NSEC3 in the NXDOMAIN probe",
}}
}
return skipped("no denial information available")
}
// nsec3IterationsRule encodes RFC 9276 §3.1: iterations > 0 buys nothing
// against modern attackers but slows down every validating resolver. The
// severity is configurable so air-gapped or paranoid setups can downgrade.
type nsec3IterationsRule struct{}
func (nsec3IterationsRule) Name() string { return "dnssec_nsec3_iterations" }
func (nsec3IterationsRule) Description() string {
return "Verifies that NSEC3PARAM.Iterations is at most nsec3IterationsMax (default 0, per RFC 9276 §3.1)."
}
func (nsec3IterationsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
data, errState := loadDNSSEC(ctx, obs)
if errState != nil {
return errState
}
param := firstNSEC3Param(data)
if param == nil {
return []sdk.CheckState{{
Status: sdk.StatusInfo,
Subject: data.Domain,
Message: "no NSEC3PARAM observed: rule does not apply (zone uses NSEC or is unsigned)",
}}
}
maxIter := optionUint(opts, "nsec3IterationsMax", defaultNSEC3IterationsMax)
severity, _ := sdk.GetOption[string](opts, "nsec3IterationsSeverity")
if severity == "" {
severity = defaultNSEC3IterationsSeverityWarn
}
if uint(param.Iterations) <= maxIter {
return okState(data.Domain, fmt.Sprintf("NSEC3 iterations = %d (≤ %d)", param.Iterations, maxIter))
}
status := sdk.StatusWarn
if strings.EqualFold(severity, "crit") {
status = sdk.StatusCrit
}
return []sdk.CheckState{withMeta(sdk.CheckState{
Status: status,
Subject: data.Domain,
Message: fmt.Sprintf("NSEC3 iterations = %d (recommended ≤ %d, RFC 9276 §3.1); modern resolvers may treat this answer as insecure or bogus", param.Iterations, maxIter),
}, "Re-sign the zone with iterations=0. BIND 9.18+: `rndc signing -nsec3param 1 0 0 -` then `rndc reload`. Knot: `nsec3-iterations: 0`. PowerDNS: `pdnsutil set-nsec3 ZONE \"1 0 0 -\"`.",
"dnssec.nsec3_iterations_too_high")}
}
// nsec3SaltEmptyRule encodes RFC 9276 §3.1 about salts: a salt offers no
// measurable benefit and adds operational cost. Surfaced as WARN (not CRIT)
// because it does not break resolution today.
type nsec3SaltEmptyRule struct{}
func (nsec3SaltEmptyRule) Name() string { return "dnssec_nsec3_salt_empty" }
func (nsec3SaltEmptyRule) Description() string {
return "Verifies that NSEC3PARAM.SaltLength is 0 (RFC 9276 §3.1: a salt buys no measurable protection)."
}
func (nsec3SaltEmptyRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errState := loadDNSSEC(ctx, obs)
if errState != nil {
return errState
}
param := firstNSEC3Param(data)
if param == nil {
return []sdk.CheckState{{
Status: sdk.StatusInfo,
Subject: data.Domain,
Message: "no NSEC3PARAM observed: rule does not apply",
}}
}
if param.SaltLength == 0 {
return okState(data.Domain, "NSEC3 salt is empty (RFC 9276 compliant)")
}
return []sdk.CheckState{withMeta(sdk.CheckState{
Status: sdk.StatusWarn,
Subject: data.Domain,
Message: fmt.Sprintf("NSEC3 salt length = %d bytes (salt = %q); RFC 9276 §3.1 recommends an empty salt", param.SaltLength, param.Salt),
}, "Re-sign the zone with an empty salt. BIND: salt parameter `-` in `dnssec-policy`. Knot: `nsec3-salt-length: 0`. PowerDNS: `pdnsutil set-nsec3 ZONE \"1 0 0 -\"`.",
"dnssec.nsec3_salt_present")}
}
// nsec3OptOutRule reports OPT-OUT misuse. OPT-OUT is appropriate for zones
// with many unsigned delegations (TLDs, registries) but defeats authenticated
// denial of existence for normal records inside leaf zones.
type nsec3OptOutRule struct{}
func (nsec3OptOutRule) Name() string { return "dnssec_nsec3_optout_only_when_signed_delegations" }
func (nsec3OptOutRule) Description() string {
return "Reports informational note when the OPT-OUT flag is set on NSEC3PARAM in a leaf zone."
}
func (nsec3OptOutRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errState := loadDNSSEC(ctx, obs)
if errState != nil {
return errState
}
param := firstNSEC3Param(data)
if param == nil {
return skipped("no NSEC3PARAM observed")
}
if param.Flags&0x01 == 0 {
return okState(data.Domain, "OPT-OUT flag not set")
}
return []sdk.CheckState{withMeta(sdk.CheckState{
Status: sdk.StatusInfo,
Subject: data.Domain,
Message: "NSEC3 OPT-OUT is set: appropriate only for zones with many unsigned delegations (typically TLDs/registries)",
}, "If this is a leaf zone, disable OPT-OUT to keep authenticated denial of existence for every name.",
"dnssec.nsec3_optout_inappropriate")}
}
// denialConsistentRule catches the per-server inconsistency that screams
// "your secondaries are not in sync": typically a mid-rollover artefact.
type denialConsistentRule struct{}
func (denialConsistentRule) Name() string { return "dnssec_denial_consistent" }
func (denialConsistentRule) Description() string {
return "Verifies that every authoritative server uses the same denial-of-existence scheme."
}
func (denialConsistentRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errState := loadDNSSEC(ctx, obs)
if errState != nil {
return errState
}
seen := map[DenialKind][]string{}
for _, name := range sortedServers(data) {
v := data.Servers[name]
if v.DenialKind == "" {
continue
}
seen[v.DenialKind] = append(seen[v.DenialKind], name)
}
if len(seen) <= 1 {
return okState(data.Domain, "all servers agree on the denial-of-existence scheme")
}
var parts []string
for k, servers := range seen {
parts = append(parts, fmt.Sprintf("%s: %s", k, strings.Join(servers, ", ")))
}
sort.Strings(parts)
return []sdk.CheckState{withMeta(sdk.CheckState{
Status: sdk.StatusWarn,
Subject: data.Domain,
Message: "authoritative servers disagree on the denial scheme: " + strings.Join(parts, " / "),
}, "Make sure every secondary completed AXFR/IXFR for the latest zone version; a partial NSEC→NSEC3 migration is the typical cause.",
"dnssec.denial_kind_drift")}
}

View file

@ -0,0 +1,188 @@
package checker
import (
"context"
"encoding/json"
"strings"
"testing"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// fakeObs round-trips through JSON like the production read path so tests
// catch any tag drift between DNSSECData fields and rule expectations.
type fakeObs struct{ data *DNSSECData }
func (f fakeObs) Get(_ context.Context, _ sdk.ObservationKey, dest any) error {
if f.data == nil {
return nil
}
raw, err := json.Marshal(f.data)
if err != nil {
return err
}
return json.Unmarshal(raw, dest)
}
func (fakeObs) GetRelated(_ context.Context, _ sdk.ObservationKey) ([]sdk.RelatedObservation, error) {
return nil, nil
}
func run(r sdk.CheckRule, data *DNSSECData, opts sdk.CheckerOptions) []sdk.CheckState {
return r.Evaluate(context.Background(), fakeObs{data: data}, opts)
}
func signedZone(denial DenialKind, p *NSEC3ParamObservation) *DNSSECData {
return &DNSSECData{
Domain: "example.com",
Servers: map[string]PerServerView{
"ns1.example.com.:53": {
Server: "ns1.example.com.:53",
DNSKEYs: []DNSKEYRecord{{Flags: 257, Algorithm: 13, KeyTag: 12345, IsKSK: true}},
NSEC3PARAM: p,
DenialKind: denial,
},
},
}
}
func wantStatus(t *testing.T, states []sdk.CheckState, want sdk.Status) {
t.Helper()
if len(states) == 0 {
t.Fatalf("no states returned")
}
if states[0].Status != want {
t.Fatalf("status = %v, want %v: %+v", states[0].Status, want, states[0])
}
}
func TestDenialUsesNSEC3(t *testing.T) {
cases := []struct {
name string
data *DNSSECData
want sdk.Status
}{
{
name: "NSEC zone is walkable -> WARN",
data: signedZone(DenialNSEC, nil),
want: sdk.StatusWarn,
},
{
name: "NSEC3 zone -> OK",
data: signedZone(DenialNSEC3, &NSEC3ParamObservation{Iterations: 0}),
want: sdk.StatusOK,
},
{
name: "OPT-OUT zone -> OK",
data: signedZone(DenialOptOut, &NSEC3ParamObservation{Iterations: 0, Flags: 1}),
want: sdk.StatusOK,
},
{
name: "Unsigned zone -> INFO",
data: &DNSSECData{Domain: "x", Servers: map[string]PerServerView{
"ns1:53": {Server: "ns1:53", DenialKind: DenialNone},
}},
want: sdk.StatusInfo,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
wantStatus(t, run(denialUsesNSEC3Rule{}, tc.data, nil), tc.want)
})
}
}
func TestNSEC3Iterations(t *testing.T) {
cases := []struct {
name string
iter uint16
opts sdk.CheckerOptions
want sdk.Status
}{
{"iter=0 -> OK", 0, nil, sdk.StatusOK},
{"iter=1 default ceiling 0 -> WARN", 1, nil, sdk.StatusWarn},
{"iter=10 default ceiling 0 -> WARN", 10, nil, sdk.StatusWarn},
{"iter=10 ceiling 100 -> OK", 10, sdk.CheckerOptions{"nsec3IterationsMax": float64(100)}, sdk.StatusOK},
{"iter=10 severity=crit -> CRIT", 10, sdk.CheckerOptions{"nsec3IterationsSeverity": "crit"}, sdk.StatusCrit},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
data := signedZone(DenialNSEC3, &NSEC3ParamObservation{Iterations: tc.iter})
wantStatus(t, run(nsec3IterationsRule{}, data, tc.opts), tc.want)
})
}
}
func TestNSEC3SaltEmpty(t *testing.T) {
cases := []struct {
name string
saltLength uint8
want sdk.Status
}{
{"empty salt -> OK", 0, sdk.StatusOK},
{"non-empty salt -> WARN", 8, sdk.StatusWarn},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
data := signedZone(DenialNSEC3, &NSEC3ParamObservation{
Iterations: 0, SaltLength: tc.saltLength, Salt: strings.Repeat("ab", int(tc.saltLength)),
})
wantStatus(t, run(nsec3SaltEmptyRule{}, data, nil), tc.want)
})
}
}
func TestNSEC3OptOut(t *testing.T) {
cases := []struct {
name string
flags uint8
want sdk.Status
}{
{"OPT-OUT off -> OK", 0, sdk.StatusOK},
{"OPT-OUT on -> INFO", 1, sdk.StatusInfo},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
data := signedZone(DenialNSEC3, &NSEC3ParamObservation{Iterations: 0, Flags: tc.flags})
wantStatus(t, run(nsec3OptOutRule{}, data, nil), tc.want)
})
}
}
func TestDenialConsistent(t *testing.T) {
consistent := &DNSSECData{
Domain: "x",
Servers: map[string]PerServerView{
"ns1:53": {Server: "ns1:53", DenialKind: DenialNSEC3},
"ns2:53": {Server: "ns2:53", DenialKind: DenialNSEC3},
},
}
wantStatus(t, run(denialConsistentRule{}, consistent, nil), sdk.StatusOK)
drifting := &DNSSECData{
Domain: "x",
Servers: map[string]PerServerView{
"ns1:53": {Server: "ns1:53", DenialKind: DenialNSEC},
"ns2:53": {Server: "ns2:53", DenialKind: DenialNSEC3},
},
}
wantStatus(t, run(denialConsistentRule{}, drifting, nil), sdk.StatusWarn)
}
func TestRoundTripJSON(t *testing.T) {
d := signedZone(DenialNSEC3, &NSEC3ParamObservation{Iterations: 0, SaltLength: 0})
raw, err := json.Marshal(d)
if err != nil {
t.Fatal(err)
}
var back DNSSECData
if err := json.Unmarshal(raw, &back); err != nil {
t.Fatal(err)
}
if back.Domain != d.Domain {
t.Fatalf("domain round-trip lost: %q vs %q", back.Domain, d.Domain)
}
if got := back.Servers["ns1.example.com.:53"].DenialKind; got != DenialNSEC3 {
t.Fatalf("denial round-trip lost: %v", got)
}
}

250
checker/rules_keys.go Normal file
View file

@ -0,0 +1,250 @@
package checker
import (
"context"
"fmt"
"sort"
"github.com/miekg/dns"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// allDNSKEYs flattens every server's DNSKEY into a single deduplicated slice,
// keyed by (KeyTag, Algorithm). The first occurrence wins because all servers
// should agree (dnssec_dnskey_consistent enforces that separately).
func allDNSKEYs(d *DNSSECData) []DNSKEYRecord {
seen := map[string]DNSKEYRecord{}
for _, name := range sortedServers(d) {
for _, k := range d.Servers[name].DNSKEYs {
id := fmt.Sprintf("%d/%d", k.KeyTag, k.Algorithm)
if _, ok := seen[id]; !ok {
seen[id] = k
}
}
}
out := make([]DNSKEYRecord, 0, len(seen))
for _, v := range seen {
out = append(out, v)
}
sort.Slice(out, func(i, j int) bool { return out[i].KeyTag < out[j].KeyTag })
return out
}
type algorithmAllowedRule struct{}
func (algorithmAllowedRule) Name() string { return "dnssec_algorithm_allowed" }
func (algorithmAllowedRule) Description() string {
return "Rejects DNSKEYs that use a forbidden algorithm or are not in the allowed list."
}
func (algorithmAllowedRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
data, errState := loadDNSSEC(ctx, obs)
if errState != nil {
return errState
}
keys := allDNSKEYs(data)
if len(keys) == 0 {
return skipped("no DNSKEY observed")
}
allowed := defaultAllowedAlgorithms()
if v, ok := sdk.GetOption[[]uint8](opts, "allowedAlgorithms"); ok && len(v) > 0 {
allowed = v
}
forbidden := defaultForbiddenAlgorithms()
if v, ok := sdk.GetOption[[]uint8](opts, "forbiddenAlgorithms"); ok && len(v) > 0 {
forbidden = v
}
allowedSet := map[uint8]bool{}
for _, a := range allowed {
allowedSet[a] = true
}
forbiddenSet := map[uint8]bool{}
for _, a := range forbidden {
forbiddenSet[a] = true
}
var states []sdk.CheckState
for _, k := range keys {
switch {
case forbiddenSet[k.Algorithm]:
states = append(states, withMeta(sdk.CheckState{
Status: sdk.StatusCrit,
Subject: fmt.Sprintf("KeyTag %d", k.KeyTag),
Message: fmt.Sprintf("DNSKEY uses forbidden algorithm %d (%s)", k.Algorithm, dns.AlgorithmToString[k.Algorithm]),
}, "Roll the key to a modern algorithm: 13 (ECDSAP256SHA256) or 15 (Ed25519).", "dnssec.algorithm_disallowed"))
case !allowedSet[k.Algorithm]:
states = append(states, withMeta(sdk.CheckState{
Status: sdk.StatusWarn,
Subject: fmt.Sprintf("KeyTag %d", k.KeyTag),
Message: fmt.Sprintf("DNSKEY uses algorithm %d (%s), not in the allowed list", k.Algorithm, dns.AlgorithmToString[k.Algorithm]),
}, "Add the algorithm to allowedAlgorithms or roll the key to one of: 8, 13, 14, 15, 16.", "dnssec.algorithm_disallowed"))
default:
states = append(states, sdk.CheckState{
Status: sdk.StatusOK,
Subject: fmt.Sprintf("KeyTag %d", k.KeyTag),
Message: fmt.Sprintf("DNSKEY algorithm %d (%s) accepted", k.Algorithm, dns.AlgorithmToString[k.Algorithm]),
})
}
}
return states
}
type algorithmModernRule struct{}
func (algorithmModernRule) Name() string { return "dnssec_algorithm_modern" }
func (algorithmModernRule) Description() string {
return "Recommends ECDSAP256SHA256 (13) or Ed25519 (15) over RSA."
}
func (algorithmModernRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errState := loadDNSSEC(ctx, obs)
if errState != nil {
return errState
}
keys := allDNSKEYs(data)
if len(keys) == 0 {
return skipped("no DNSKEY observed")
}
hasModern := false
hasLegacy := false
for _, k := range keys {
switch k.Algorithm {
case dns.ECDSAP256SHA256, dns.ECDSAP384SHA384, dns.ED25519, dns.ED448:
hasModern = true
case dns.RSASHA256, dns.RSASHA512, dns.RSASHA1, dns.RSASHA1NSEC3SHA1:
hasLegacy = true
}
}
switch {
case hasModern && !hasLegacy:
return okState(data.Domain, "zone uses modern elliptic-curve algorithms (13/14/15/16)")
case hasLegacy:
return []sdk.CheckState{withMeta(sdk.CheckState{
Status: sdk.StatusWarn,
Subject: data.Domain,
Message: "zone still uses RSA-family DNSKEYs; modern operators prefer 13 (ECDSAP256SHA256) or 15 (Ed25519) for smaller responses and faster validation",
}, "Plan an algorithm rollover. `dnssec-keygen -a ECDSAP256SHA256 -K /var/lib/bind <zone>` (BIND), then add the new key, wait for the parent's DS to update, then drop the old key.",
"dnssec.algorithm_legacy")}
}
return []sdk.CheckState{{
Status: sdk.StatusInfo,
Subject: data.Domain,
Message: "no modern or legacy algorithms detected; review DNSKEY policy manually",
}}
}
type rsaKeySizeRule struct{}
func (rsaKeySizeRule) Name() string { return "dnssec_rsa_keysize" }
func (rsaKeySizeRule) Description() string {
return "Verifies RSA DNSKEYs reach a minimum modulus size (default 2048 bits)."
}
func (rsaKeySizeRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
data, errState := loadDNSSEC(ctx, obs)
if errState != nil {
return errState
}
keys := allDNSKEYs(data)
if len(keys) == 0 {
return skipped("no DNSKEY observed")
}
minSize := optionUint(opts, "minRSAKeySize", defaultMinRSAKeySize)
var states []sdk.CheckState
for _, k := range keys {
switch k.Algorithm {
case dns.RSASHA1, dns.RSASHA1NSEC3SHA1, dns.RSASHA256, dns.RSASHA512:
default:
continue
}
if k.KeySize == 0 {
continue // could not estimate; rule_keys parser limitation
}
switch {
case k.KeySize < 1024:
states = append(states, withMeta(sdk.CheckState{
Status: sdk.StatusCrit,
Subject: fmt.Sprintf("KeyTag %d", k.KeyTag),
Message: fmt.Sprintf("RSA DNSKEY %d uses a %d-bit modulus: practically broken", k.KeyTag, k.KeySize),
}, "Roll the key to at least 2048-bit RSA, or better, ECDSAP256SHA256 (algo 13).", "dnssec.rsa_keysize_small"))
case uint(k.KeySize) < minSize:
states = append(states, withMeta(sdk.CheckState{
Status: sdk.StatusWarn,
Subject: fmt.Sprintf("KeyTag %d", k.KeyTag),
Message: fmt.Sprintf("RSA DNSKEY %d uses a %d-bit modulus (recommended ≥ %d)", k.KeyTag, k.KeySize, minSize),
}, "Roll to a 2048-bit RSA key, or migrate to ECDSAP256SHA256 (algo 13).", "dnssec.rsa_keysize_small"))
default:
states = append(states, sdk.CheckState{
Status: sdk.StatusOK,
Subject: fmt.Sprintf("KeyTag %d", k.KeyTag),
Message: fmt.Sprintf("RSA DNSKEY %d uses a %d-bit modulus", k.KeyTag, k.KeySize),
})
}
}
if len(states) == 0 {
return okState(data.Domain, "no RSA DNSKEY in use")
}
return states
}
type kskPresentRule struct{}
func (kskPresentRule) Name() string { return "dnssec_ksk_present" }
func (kskPresentRule) Description() string {
return "Verifies at least one DNSKEY has the SEP bit (KSK)."
}
func (kskPresentRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
data, errState := loadDNSSEC(ctx, obs)
if errState != nil {
return errState
}
keys := allDNSKEYs(data)
if len(keys) == 0 {
return skipped("no DNSKEY observed")
}
required := sdk.GetBoolOption(opts, "requireSEP", defaultRequireSEP)
if !required {
return okState(data.Domain, "requireSEP=false")
}
for _, k := range keys {
if k.IsKSK {
return okState(data.Domain, fmt.Sprintf("KSK present (KeyTag %d, algorithm %d)", k.KeyTag, k.Algorithm))
}
}
return []sdk.CheckState{withMeta(sdk.CheckState{
Status: sdk.StatusCrit,
Subject: data.Domain,
Message: "no DNSKEY carries the SEP (KSK) flag",
}, "Re-publish the apex DNSKEY RRset with at least one key flagged as SEP (flags 257). Most signers do this automatically; check that the KSK was not accidentally removed during a rollover.",
"dnssec.no_ksk")}
}
type dnskeyCountRule struct{}
func (dnskeyCountRule) Name() string { return "dnssec_dnskey_count" }
func (dnskeyCountRule) Description() string {
return "Warns when too many DNSKEYs are published, inflating responses and amplification potential."
}
func (dnskeyCountRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errState := loadDNSSEC(ctx, obs)
if errState != nil {
return errState
}
keys := allDNSKEYs(data)
n := len(keys)
switch {
case n == 0:
return skipped("no DNSKEY observed")
case n >= 8:
return []sdk.CheckState{withMeta(sdk.CheckState{
Status: sdk.StatusWarn,
Subject: data.Domain,
Message: fmt.Sprintf("%d DNSKEYs published; large RRsets bloat responses and increase amplification factor", n),
}, "Drop retired keys after their successor has fully rolled.", "dnssec.dnskey_count")}
default:
return okState(data.Domain, fmt.Sprintf("%d DNSKEY(s) published", n))
}
}

150
checker/rules_presence.go Normal file
View file

@ -0,0 +1,150 @@
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
}

162
checker/rules_signatures.go Normal file
View file

@ -0,0 +1,162 @@
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))
}

41
checker/rules_ttl.go Normal file
View file

@ -0,0 +1,41 @@
package checker
import (
"context"
"fmt"
sdk "git.happydns.org/checker-sdk-go/checker"
)
type dnskeyTTLMinRule struct{}
func (dnskeyTTLMinRule) Name() string { return "dnssec_dnskey_ttl_min" }
func (dnskeyTTLMinRule) Description() string {
return "Warns when the DNSKEY TTL is too short to be useful for caching."
}
func (dnskeyTTLMinRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
data, errState := loadDNSSEC(ctx, obs)
if errState != nil {
return errState
}
if !hasAnyDNSKEY(data) {
return skipped("zone not signed")
}
minTTL := optionUint(opts, "dnskeyTTLMin", defaultDNSKEYTTLMinSec)
for _, name := range sortedServers(data) {
v := data.Servers[name]
if v.DNSKEYTTL == 0 {
continue
}
if uint(v.DNSKEYTTL) < minTTL {
return []sdk.CheckState{withMeta(sdk.CheckState{
Status: sdk.StatusWarn,
Subject: name,
Message: fmt.Sprintf("DNSKEY TTL on %s = %ds (recommended ≥ %ds)", name, v.DNSKEYTTL, minTTL),
}, "Increase the DNSKEY TTL so resolvers cache the keys; short TTLs increase load and break key-rollover prepublish strategies.", "dnssec.dnskey_ttl_low")}
}
}
return okState(data.Domain, "DNSKEY TTL is at or above the recommended minimum")
}

103
checker/types.go Normal file
View file

@ -0,0 +1,103 @@
package checker
import "time"
const ObservationKeyDNSSEC = "dnssec"
// DenialKind describes the negative-answer scheme observed on a NXDOMAIN probe.
type DenialKind string
const (
DenialNone DenialKind = "NONE" // zone unsigned or no NSEC/NSEC3 records returned
DenialNSEC DenialKind = "NSEC" // walkable
DenialNSEC3 DenialKind = "NSEC3" // hashed denial of existence
DenialOptOut DenialKind = "OPT-OUT" // NSEC3 with the OPT-OUT flag set
)
// DNSSECData carries raw facts only; judgement is delegated to the rules.
type DNSSECData struct {
Domain string `json:"domain"`
NameServers []string `json:"name_servers,omitempty"`
Servers map[string]PerServerView `json:"servers,omitempty"` // key: "host:port"
Errors []string `json:"errors,omitempty"` // global errors (NS resolution, …)
CollectedAt time.Time `json:"collected_at"`
// HasDS is whether the parent advertises a DS for this zone (best effort,
// resolved through the bootstrap resolver). Used by dnssec_zone_signed.
HasDS bool `json:"has_ds,omitempty"`
}
type PerServerView struct {
Server string `json:"server"`
UDPError string `json:"udp_error,omitempty"`
TCPError string `json:"tcp_error,omitempty"`
DNSKEYs []DNSKEYRecord `json:"dnskeys,omitempty"`
DNSKEYTTL uint32 `json:"dnskey_ttl,omitempty"`
DNSKEYRRSIGs []RRSIGObservation `json:"dnskey_rrsigs,omitempty"`
SOA *SOAObservation `json:"soa,omitempty"`
SOARRSIGs []RRSIGObservation `json:"soa_rrsigs,omitempty"`
NSEC3PARAM *NSEC3ParamObservation `json:"nsec3param,omitempty"`
DenialKind DenialKind `json:"denial_kind,omitempty"`
DenialRecords []string `json:"denial_records,omitempty"` // textual dump for the report
ProbeName string `json:"probe_name,omitempty"` // random NXDOMAIN probe used
CDS []DSRecord `json:"cds,omitempty"`
CDNSKEY []DNSKEYRecord `json:"cdnskey,omitempty"`
}
// AllRRSIGs returns DNSKEY and SOA RRSIGs concatenated into a fresh slice, so
// callers can iterate every signature observed on this server without mutating
// the underlying fields.
func (v *PerServerView) AllRRSIGs() []RRSIGObservation {
out := make([]RRSIGObservation, 0, len(v.DNSKEYRRSIGs)+len(v.SOARRSIGs))
out = append(out, v.DNSKEYRRSIGs...)
out = append(out, v.SOARRSIGs...)
return out
}
type DNSKEYRecord struct {
Flags uint16 `json:"flags"`
Protocol uint8 `json:"protocol"`
Algorithm uint8 `json:"algorithm"`
PublicKey string `json:"public_key"`
KeyTag uint16 `json:"keytag"`
KeySize int `json:"key_size,omitempty"` // computed at collect time
IsKSK bool `json:"is_ksk,omitempty"` // derived from the SEP flag
}
type RRSIGObservation struct {
TypeCovered uint16 `json:"type_covered"`
Algorithm uint8 `json:"algorithm"`
Labels uint8 `json:"labels"`
OrigTTL uint32 `json:"orig_ttl"`
Inception uint32 `json:"inception"`
Expiration uint32 `json:"expiration"`
KeyTag uint16 `json:"keytag"`
SignerName string `json:"signer_name"`
}
type SOAObservation struct {
Serial uint32 `json:"serial"`
Minimum uint32 `json:"minimum"`
MName string `json:"mname"`
TTL uint32 `json:"ttl"`
}
type NSEC3ParamObservation struct {
HashAlgorithm uint8 `json:"hash_algorithm"`
Flags uint8 `json:"flags"` // OPT-OUT bit = 0x01
Iterations uint16 `json:"iterations"`
SaltLength uint8 `json:"salt_length"`
Salt string `json:"salt,omitempty"` // hex
}
type DSRecord struct {
KeyTag uint16 `json:"keytag"`
Algorithm uint8 `json:"algorithm"`
DigestType uint8 `json:"digest_type"`
Digest string `json:"digest"`
}

16
go.mod Normal file
View file

@ -0,0 +1,16 @@
module git.happydns.org/checker-dnssec
go 1.25.0
require (
git.happydns.org/checker-sdk-go v1.5.0
github.com/miekg/dns v1.1.72
)
require (
golang.org/x/mod v0.31.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/tools v0.40.0 // indirect
)

16
go.sum Normal file
View file

@ -0,0 +1,16 @@
git.happydns.org/checker-sdk-go v1.5.0 h1:5uD5Cm6xJ+lwnhbJ09iCXGHbYS9zRh+Yh0NeBHkAPBY=
git.happydns.org/checker-sdk-go v1.5.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=

24
main.go Normal file
View file

@ -0,0 +1,24 @@
package main
import (
"flag"
"log"
dnssec "git.happydns.org/checker-dnssec/checker"
"git.happydns.org/checker-sdk-go/checker/server"
)
var Version = "custom-build"
var listenAddr = flag.String("listen", ":8080", "HTTP listen address")
func main() {
flag.Parse()
dnssec.Version = Version
srv := server.New(dnssec.Provider())
if err := srv.ListenAndServe(*listenAddr); err != nil {
log.Fatalf("server error: %v", err)
}
}

14
plugin/plugin.go Normal file
View file

@ -0,0 +1,14 @@
// Command plugin is the happyDomain plugin entrypoint for the dnssec checker.
package main
import (
dnssec "git.happydns.org/checker-dnssec/checker"
sdk "git.happydns.org/checker-sdk-go/checker"
)
var Version = "custom-build"
func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
dnssec.Version = Version
return dnssec.Definition(), dnssec.Provider(), nil
}