commit 5a632a3b3051f13308a175b62d090da5d19ecb6f Author: Pierre-Olivier Mercier Date: Sun Apr 26 18:56:19 2026 +0700 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b56f851 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +checker-dnssec +checker-dnssec.so diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..248a5cf --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..07d44d8 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..9be89aa --- /dev/null +++ b/Makefile @@ -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 diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..d2893e6 --- /dev/null +++ b/NOTICE @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..f35a386 --- /dev/null +++ b/README.md @@ -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`). diff --git a/checker/collect.go b/checker/collect.go new file mode 100644 index 0000000..486e7fd --- /dev/null +++ b/checker/collect.go @@ -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 +} + diff --git a/checker/definition.go b/checker/definition.go new file mode 100644 index 0000000..1433538 --- /dev/null +++ b/checker/definition.go @@ -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 +} diff --git a/checker/dns.go b/checker/dns.go new file mode 100644 index 0000000..77c1cf9 --- /dev/null +++ b/checker/dns.go @@ -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[:]) +} diff --git a/checker/interactive.go b/checker/interactive.go new file mode 100644 index 0000000..0fc7d88 --- /dev/null +++ b/checker/interactive.go @@ -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 +} diff --git a/checker/provider.go b/checker/provider.go new file mode 100644 index 0000000..2fe1285 --- /dev/null +++ b/checker/provider.go @@ -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() +} diff --git a/checker/report.go b/checker/report.go new file mode 100644 index 0000000..d2c6289 --- /dev/null +++ b/checker/report.go @@ -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 = ` + + + + +DNSSEC report — {{.Domain}} + + + +
+
+
{{.OverallText}}
+
for {{.Domain}}{{if .CollectedAt}} · collected {{.CollectedAt}}{{end}}
+
+
+ {{.Banner.DNSKEYCount}} DNSKEY · denial: {{.Banner.DenialKind}} + {{if .Banner.HasNearestExpiry}} · next RRSIG expiry: {{.Banner.NearestExpiryDays}}{{end}} +
+
+ + {{if .GlobalErrors}} +
+ Collection errors: + +
+ {{end}} + +
+
Zone
{{.Domain}}
+
Algorithms
{{.Banner.Algorithms}}
+
DNSKEY count
{{.Banner.DNSKEYCount}}
+
Denial scheme
{{.Banner.DenialKind}}
+
Authoritative NS
{{range .NameServers}}{{.}}
{{else}}—{{end}}
+
+ + {{if .TopFailures}} +

Fix these first

+ {{range .TopFailures}} +
+

{{.Title}} {{.Severity}}

+ + {{if .Hint}}
How to fix{{.Hint}}
{{end}} +
+ {{end}} + {{end}} + +

Enumerability

+
+
+ {{.Enumerability.Verdict}} scheme: {{.Enumerability.Kind}} +
+
{{.Enumerability.Explanation}}
+ {{if .Enumerability.HasNSEC3Param}} +
+ iterations = {{.Enumerability.Iterations}}{{if eq .Enumerability.Iterations 0}} RFC 9276 ✓{{else}} > 0{{end}} + · salt length = {{.Enumerability.SaltLength}}{{if eq .Enumerability.SaltLength 0}} empty ✓{{else}} {{.Enumerability.Salt}}{{end}} + · OPT-OUT: {{if .Enumerability.OptOut}}on{{else}}off{{end}} +
+ {{end}} +
+ + {{if .Keys}} +

DNSKEYs

+ + + + {{range .Keys}} + + + + + + + + {{end}} + +
KeyTagRoleAlgorithmFlagsSize
{{.KeyTag}}{{.Role}}{{.Algorithm}}{{.Flags}}{{.Size}}
+ {{end}} + + {{if .Signatures}} +

RRSIGs

+ + + + {{range .Signatures}} + + + + + + + + + {{end}} + +
ServerCoversKeyTagInceptionExpirationValidity
{{.Server}}{{.TypeCovered}}{{.KeyTag}}{{.Inception}}{{.Expiration}} {{.Remaining}}
+ {{end}} + + {{if .PerServer}} +

Per-server view

+
+ {{range .PerServer}} +
+
+ {{.Server}} + + {{.DNSKEYCount}} DNSKEY + denial: {{.DenialKind}} + +
+ {{if .NSEC3Summary}}
NSEC3PARAM: {{.NSEC3Summary}}
{{end}} + {{if .ProbeName}}
NXDOMAIN probe: {{.ProbeName}}
{{end}} + {{if .UDPError}}
UDP error: {{.UDPError}}
{{end}} + {{if .TCPError}}
TCP error: {{.TCPError}}
{{end}} + {{if .DenialDump}} +
Denial proof records
{{range .DenialDump}}{{.}}
+{{end}}
+ {{end}} +
+ {{end}} +
+ {{end}} + + {{if .OtherFindings}} +

Additional findings

+ + + + {{range .OtherFindings}} + + + + + + + {{end}} + +
SeverityRuleSubjectMessage
{{.Severity}}{{.RuleName}}{{.Subject}}{{.Message}}{{if .Hint}}
{{.Hint}}{{end}}
+ {{end}} + + {{if .RawJSON}} +

Raw observation

+
Show JSON
{{.RawJSON}}
+ {{end}} + + +` diff --git a/checker/rules_common.go b/checker/rules_common.go new file mode 100644 index 0000000..ed402d1 --- /dev/null +++ b/checker/rules_common.go @@ -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))) +} diff --git a/checker/rules_enumeration.go b/checker/rules_enumeration.go new file mode 100644 index 0000000..0adc1ad --- /dev/null +++ b/checker/rules_enumeration.go @@ -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")} +} diff --git a/checker/rules_enumeration_test.go b/checker/rules_enumeration_test.go new file mode 100644 index 0000000..ecc5670 --- /dev/null +++ b/checker/rules_enumeration_test.go @@ -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) + } +} diff --git a/checker/rules_keys.go b/checker/rules_keys.go new file mode 100644 index 0000000..69360bc --- /dev/null +++ b/checker/rules_keys.go @@ -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 ` (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)) + } +} diff --git a/checker/rules_presence.go b/checker/rules_presence.go new file mode 100644 index 0000000..3ad920c --- /dev/null +++ b/checker/rules_presence.go @@ -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 +} diff --git a/checker/rules_signatures.go b/checker/rules_signatures.go new file mode 100644 index 0000000..4057d9b --- /dev/null +++ b/checker/rules_signatures.go @@ -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)) +} diff --git a/checker/rules_ttl.go b/checker/rules_ttl.go new file mode 100644 index 0000000..53e5ff9 --- /dev/null +++ b/checker/rules_ttl.go @@ -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") +} diff --git a/checker/types.go b/checker/types.go new file mode 100644 index 0000000..b42f951 --- /dev/null +++ b/checker/types.go @@ -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"` +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..215c597 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..2a80023 --- /dev/null +++ b/go.sum @@ -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= diff --git a/main.go b/main.go new file mode 100644 index 0000000..96328ad --- /dev/null +++ b/main.go @@ -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) + } +} diff --git a/plugin/plugin.go b/plugin/plugin.go new file mode 100644 index 0000000..fc140b4 --- /dev/null +++ b/plugin/plugin.go @@ -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 +}