Initial commit
This commit is contained in:
commit
5a632a3b30
24 changed files with 2901 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
checker-dnssec
|
||||||
|
checker-dnssec.so
|
||||||
17
Dockerfile
Normal file
17
Dockerfile
Normal 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
21
LICENSE
Normal 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
28
Makefile
Normal 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
26
NOTICE
Normal 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
124
README.md
Normal 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
303
checker/collect.go
Normal 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
118
checker/definition.go
Normal 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
153
checker/dns.go
Normal 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
34
checker/interactive.go
Normal 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
19
checker/provider.go
Normal 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
755
checker/report.go
Normal 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
97
checker/rules_common.go
Normal 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)))
|
||||||
|
}
|
||||||
240
checker/rules_enumeration.go
Normal file
240
checker/rules_enumeration.go
Normal 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")}
|
||||||
|
}
|
||||||
188
checker/rules_enumeration_test.go
Normal file
188
checker/rules_enumeration_test.go
Normal 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
250
checker/rules_keys.go
Normal 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
150
checker/rules_presence.go
Normal 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
162
checker/rules_signatures.go
Normal 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
41
checker/rules_ttl.go
Normal 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
103
checker/types.go
Normal 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
16
go.mod
Normal 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
16
go.sum
Normal 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
24
main.go
Normal 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
14
plugin/plugin.go
Normal 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
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue