From 7e0f29075e3e0c10dbeb84ada4bb2445ef967146 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Wed, 8 Apr 2026 04:18:58 +0700 Subject: [PATCH] Initial commit --- .gitignore | 2 + Dockerfile | 15 + LICENSE | 21 + Makefile | 28 ++ NOTICE | 27 ++ README.md | 132 ++++++ checker/collect.go | 218 +++++++++ checker/definition.go | 82 ++++ checker/dns.go | 250 ++++++++++ checker/helpers.go | 108 +++++ checker/helpers_test.go | 136 ++++++ checker/interactive.go | 135 ++++++ checker/provider.go | 48 ++ checker/report.go | 262 +++++++++++ checker/rule.go | 992 ++++++++++++++++++++++++++++++++++++++++ checker/rule_test.go | 439 ++++++++++++++++++ checker/types.go | 143 ++++++ go.mod | 16 + go.sum | 16 + main.go | 25 + plugin/plugin.go | 17 + 21 files changed, 3112 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 NOTICE create mode 100644 README.md create mode 100644 checker/collect.go create mode 100644 checker/definition.go create mode 100644 checker/dns.go create mode 100644 checker/helpers.go create mode 100644 checker/helpers_test.go create mode 100644 checker/interactive.go create mode 100644 checker/provider.go create mode 100644 checker/report.go create mode 100644 checker/rule.go create mode 100644 checker/rule_test.go create mode 100644 checker/types.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 plugin/plugin.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d214794 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +checker-delegation +*.so diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1308fcc --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +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-delegation . + +FROM scratch +COPY --from=builder /checker-delegation /checker-delegation +USER 65534:65534 +EXPOSE 8080 +ENTRYPOINT ["/checker-delegation"] 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..a257412 --- /dev/null +++ b/Makefile @@ -0,0 +1,28 @@ +CHECKER_NAME := checker-delegation +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..990fd41 --- /dev/null +++ b/NOTICE @@ -0,0 +1,27 @@ +checker-delegation +Copyright 2024-2026 The happyDomain Authors + +This product is licensed under the MIT License (see LICENSE). + +------------------------------------------------------------------------ +Third-party notices +------------------------------------------------------------------------ + +This product depends on checker-sdk-go (https://git.happydns.org/happyDomain/checker-sdk-go), +licensed under the Apache License, Version 2.0. The following NOTICE +accompanies that dependency and is reproduced here as required by +section 4(d) of the Apache License: + + 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 here under the Apache License, Version 2.0 to enable + a permissively licensed ecosystem of checker plugins. + +A copy of the Apache License, Version 2.0 is available at: +https://www.apache.org/licenses/LICENSE-2.0 diff --git a/README.md b/README.md new file mode 100644 index 0000000..db0667f --- /dev/null +++ b/README.md @@ -0,0 +1,132 @@ +# checker-delegation + +DNS delegation checker for [happyDomain](https://www.happydomain.org/). + +Audits the delegation of a zone: NS consistency between parent and child, +glue correctness, DS / DNSKEY hand-off, TCP reachability, SOA serial drift, +and authoritativeness of each delegated server. Applies to services of type +`abstract.Delegation`. + +## Usage + +### Standalone HTTP server + +```bash +# Build and run +make +./checker-delegation -listen :8080 +``` + +The server exposes: + +- `GET /health`, health check +- `POST /collect`, collect delegation observations (happyDomain external checker protocol) + +### Docker + +```bash +make docker +docker run -p 8080:8080 happydomain/checker-delegation +``` + +### happyDomain plugin + +```bash +make plugin +# produces checker-delegation.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 delegation checker to the URL of +the running checker-delegation server (e.g., +`http://checker-delegation:8080`). happyDomain will delegate observation +collection to this endpoint. + +### Deployment + +The `/collect` endpoint has no built-in authentication and will issue +DNS queries to whatever name servers (and glue addresses) the parent +zone advertises for the target. It is meant to run on a trusted network, +reachable only by the happyDomain instance that drives it. Restrict +access via a reverse proxy with authentication, a network ACL, or by +binding the listener to a private interface; do not expose it directly +to the public internet. + +## Options + +| Option | Type | Default | Description | +|---------------------|------|---------|---------------------------------------------------------------------------------------------------| +| `requireDS` | bool | `false` | When enabled, missing DS records at the parent are treated as critical (otherwise informational). | +| `requireTCP` | bool | `true` | When enabled, name servers that fail to answer over TCP are reported as critical (otherwise warning). | +| `minNameServers` | uint | `2` | Below this count, the delegation is reported as a warning (RFC 1034 recommends at least 2). | +| `allowGlueMismatch` | bool | `false` | When disabled, glue/address mismatches between parent and child are reported as critical. | + +## Protocol + +### POST /collect + +Request: +```json +{ + "key": "delegation", + "target": {"userId": "...", "domainId": "..."}, + "options": { + "domain_name": "example.com.", + "subdomain": "www", + "service": { "_svctype": "abstract.Delegation", "Service": { "ns": [...], "ds": [...] } } + } +} +``` + +Response: +```json +{ + "data": { + "delegated_fqdn": "www.example.com.", + "parent_zone": "example.com.", + "parent_ns": ["a.iana-servers.net.", "b.iana-servers.net."], + "advertised_ns": ["ns1.example.net.", "ns2.example.net."], + "advertised_glue": {}, + "parent_ds": [], + "child_serials": {"ns1.example.net.:53": 2026042401}, + "findings": [ + { + "code": "delegation_ns_mismatch", + "severity": "crit", + "message": "NS RRset at parent does not match declared service: missing=[ns3.example.net] extra=[]", + "server": "a.iana-servers.net.:53" + } + ] + } +} +``` + +Findings carry a stable `code` (e.g. `delegation_lame`, +`delegation_missing_glue`, `delegation_ds_mismatch`, +`delegation_soa_serial_drift`, `delegation_dnskey_no_match`, …) so that +downstream rules can match on them deterministically. + +## License + +This project is licensed under the **MIT License** (see `LICENSE`), in +line with the rest of the happyDomain checker ecosystem. + +The third-party Apache-2.0 attributions for `checker-sdk-go` are recorded +in `NOTICE` and must accompany any binary or source redistribution of this +project. diff --git a/checker/collect.go b/checker/collect.go new file mode 100644 index 0000000..f4fda00 --- /dev/null +++ b/checker/collect.go @@ -0,0 +1,218 @@ +package checker + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/miekg/dns" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// Collect probes the delegation and records raw facts only; judgment lives +// in rule.go. Phase B queries delegated servers using only the NS names and +// glue learned from the parent: the child zone is never trusted as a +// source of truth. +func (p *delegationProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) { + svc, err := loadService(opts) + if err != nil { + return nil, err + } + + parentZone, subdomain := loadNames(opts) + if parentZone == "" { + return nil, fmt.Errorf("missing 'domain_name' option") + } + + parent := strings.TrimSuffix(parentZone, ".") + sub := strings.TrimSuffix(subdomain, ".") + var delegatedFQDN string + if sub == "" { + delegatedFQDN = dns.Fqdn(parent) + } else { + delegatedFQDN = dns.Fqdn(sub + "." + parent) + } + + data := &DelegationData{ + DelegatedFQDN: delegatedFQDN, + ParentZone: dns.Fqdn(parentZone), + DeclaredNS: normalizeNSList(svc.NameServers), + } + for _, d := range svc.DS { + if d == nil { + continue + } + data.DeclaredDS = append(data.DeclaredDS, NewDSRecord(d)) + } + + _, parentServers, err := findParentZone(ctx, delegatedFQDN, parentZone) + if err != nil { + data.ParentDiscoveryError = err.Error() + return data, nil + } + data.ParentNS = parentServers + + // Phase A: per-parent observations, no judgment. + for _, ps := range parentServers { + view := ParentView{Server: ps} + + ns, glue, _, qerr := queryDelegation(ctx, ps, delegatedFQDN) + if qerr != nil { + view.UDPNSError = qerr.Error() + } else { + view.NS = ns + view.Glue = glue + } + + if terr := queryDelegationTCP(ctx, ps, delegatedFQDN); terr != nil { + view.TCPNSError = terr.Error() + } + + dsRRs, sigs, dserr := queryDS(ctx, ps, delegatedFQDN) + if dserr != nil { + view.DSQueryError = dserr.Error() + } else { + for _, d := range dsRRs { + view.DS = append(view.DS, NewDSRecord(d)) + } + for _, sig := range sigs { + view.DSRRSIGs = append(view.DSRRSIGs, DSRRSIGObservation{ + Inception: sig.Inception, + Expiration: sig.Expiration, + }) + } + } + + data.ParentViews = append(data.ParentViews, view) + } + + // If no parent answered with an NS RRset, skip Phase B; rules flag the gap. + var primary *ParentView + for i := range data.ParentViews { + if data.ParentViews[i].UDPNSError == "" && len(data.ParentViews[i].NS) > 0 { + primary = &data.ParentViews[i] + break + } + } + if primary == nil { + return data, nil + } + + // Phase B: per-child observations, seeded only from parent data. + for _, nsName := range primary.NS { + child := ChildNSView{NSName: nsName} + addrs := primary.Glue[nsName] + if len(addrs) == 0 { + // Out-of-bailiwick: no glue expected, fall back to the system resolver. + resolved, rerr := resolveHost(ctx, nsName) + if rerr != nil { + child.ResolveError = rerr.Error() + data.Children = append(data.Children, child) + continue + } + addrs = resolved + } + + for _, addr := range addrs { + srv := hostPort(addr, "53") + av := ChildAddressView{Address: addr, Server: srv} + + soa, aa, qerr := querySOA(ctx, "", srv, delegatedFQDN) + if qerr != nil { + av.UDPError = qerr.Error() + av.Authoritative = aa + child.Addresses = append(child.Addresses, av) + continue + } + av.Authoritative = aa + if soa != nil { + av.SOASerial = soa.Serial + av.SOASerialKnown = true + } + + if _, _, terr := querySOA(ctx, "tcp", srv, delegatedFQDN); terr != nil { + av.TCPError = terr.Error() + } + + childNS, nerr := queryNSAt(ctx, srv, delegatedFQDN) + if nerr != nil { + av.ChildNSError = nerr.Error() + } else { + av.ChildNS = childNS + } + + if isInBailiwick(nsName, delegatedFQDN) { + addrsAt, _ := queryAddrsAt(ctx, srv, nsName) + av.ChildGlueAddrs = addrsAt + } + + // DNSKEY is only useful when there's a parent DS to match against. + parentHasDS := false + for _, pv := range data.ParentViews { + if len(pv.DS) > 0 { + parentHasDS = true + break + } + } + if parentHasDS { + keys, kerr := queryDNSKEY(ctx, srv, delegatedFQDN) + if kerr != nil { + av.DNSKEYError = kerr.Error() + } else { + for _, k := range keys { + av.DNSKEYs = append(av.DNSKEYs, NewDNSKEYRecord(k)) + } + } + } + + child.Addresses = append(child.Addresses, av) + } + + data.Children = append(data.Children, child) + } + + return data, nil +} + +// queryDelegationTCP only reports reachability; the payload was already +// captured over UDP. +func queryDelegationTCP(ctx context.Context, parentServer, fqdn string) error { + q := dns.Question{Name: dns.Fqdn(fqdn), Qtype: dns.TypeNS, Qclass: dns.ClassINET} + msg, err := dnsExchange(ctx, "tcp", parentServer, q, true) + if err != nil { + return err + } + if msg.Rcode != dns.RcodeSuccess { + return fmt.Errorf("parent answered %s", dns.RcodeToString[msg.Rcode]) + } + return nil +} + +// loadService decodes the "service" option into a minimal local type to +// avoid pulling in the full happyDomain server module. +func loadService(opts sdk.CheckerOptions) (*delegationService, error) { + svc, ok := sdk.GetOption[serviceMessage](opts, "service") + if !ok { + return nil, fmt.Errorf("missing 'service' option") + } + if svc.Type != "" && svc.Type != "abstract.Delegation" { + return nil, fmt.Errorf("service is %s, expected abstract.Delegation", svc.Type) + } + var d delegationService + if err := json.Unmarshal(svc.Service, &d); err != nil { + return nil, fmt.Errorf("decoding delegation service: %w", err) + } + return &d, nil +} + +func loadNames(opts sdk.CheckerOptions) (parentZone, subdomain string) { + if v, ok := sdk.GetOption[string](opts, "domain_name"); ok { + parentZone = v + } + if v, ok := sdk.GetOption[string](opts, "subdomain"); ok { + subdomain = v + } + return +} diff --git a/checker/definition.go b/checker/definition.go new file mode 100644 index 0000000..5979304 --- /dev/null +++ b/checker/definition.go @@ -0,0 +1,82 @@ +package checker + +import ( + "time" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// Version is overridden at link-time by CI: -ldflags "-X ...Version=1.2.3". +var Version = "built-in" + +func (p *delegationProvider) Definition() *sdk.CheckerDefinition { + return &sdk.CheckerDefinition{ + ID: "delegation", + Name: "DNS delegation", + Version: Version, + Availability: sdk.CheckerAvailability{ + ApplyToService: true, + LimitToServices: []string{"abstract.Delegation"}, + }, + ObservationKeys: []sdk.ObservationKey{ObservationKeyDelegation}, + Options: sdk.CheckerOptionsDocumentation{ + UserOpts: []sdk.CheckerOptionDocumentation{ + { + Id: "requireDS", + Type: "bool", + Label: "Require DS at parent", + Description: "When enabled, missing DS records at the parent are treated as a critical issue (otherwise informational).", + Default: false, + }, + { + Id: "requireTCP", + Type: "bool", + Label: "Require DNS over TCP", + Description: "When enabled, name servers that fail to answer over TCP are reported as critical (otherwise as warning).", + Default: true, + }, + { + Id: "minNameServers", + Type: "uint", + Label: "Minimum number of name servers", + Description: "Below this count, the delegation is reported as a warning (RFC 1034 recommends at least 2).", + Default: float64(2), + }, + { + Id: "allowGlueMismatch", + Type: "bool", + Label: "Allow glue mismatches", + Description: "When disabled, glue/address mismatches between parent and child are reported as critical.", + Default: false, + }, + }, + DomainOpts: []sdk.CheckerOptionDocumentation{ + { + Id: "domain_name", + Label: "Parent domain name", + AutoFill: sdk.AutoFillDomainName, + }, + { + Id: "subdomain", + Label: "Subdomain", + AutoFill: sdk.AutoFillSubdomain, + }, + }, + ServiceOpts: []sdk.CheckerOptionDocumentation{ + { + Id: "service", + Label: "Service", + AutoFill: sdk.AutoFillService, + }, + }, + }, + Rules: Rules(), + HasHTMLReport: true, + HasMetrics: true, + Interval: &sdk.CheckIntervalSpec{ + Min: 5 * time.Minute, + Max: 24 * time.Hour, + Default: 1 * time.Hour, + }, + } +} diff --git a/checker/dns.go b/checker/dns.go new file mode 100644 index 0000000..a5a45a3 --- /dev/null +++ b/checker/dns.go @@ -0,0 +1,250 @@ +package checker + +import ( + "context" + "fmt" + "net" + "strings" + "time" + + "github.com/miekg/dns" +) + +// year68 wraps RRSIG validity periods around 2^32 seconds, matching miekg. +const year68 = int64(1 << 31) + +const dnsTimeout = 5 * time.Second + +// dnsExchange forces RecursionDesired off: this checker only talks to +// authoritative servers, never recursors. +func dnsExchange(ctx context.Context, proto, server string, q dns.Question, edns 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 = false + if edns { + m.SetEdns0(4096, true) + } + + if deadline, ok := ctx.Deadline(); ok { + if d := time.Until(deadline); d > 0 && d < client.Timeout { + client.Timeout = d + } + } + + r, _, err := client.ExchangeContext(ctx, m, server) + if err != nil { + return nil, err + } + if r == nil { + return nil, fmt.Errorf("nil response from %s", server) + } + return r, nil +} + +// hostPort brackets IPv6 literals so net.Dial accepts them. +func hostPort(host, port string) string { + if ip := net.ParseIP(host); ip != nil && ip.To4() == nil { + return "[" + host + "]:" + port + } + host = strings.TrimSuffix(host, ".") + return host + ":" + port +} + +// resolveHost is the fallback path for out-of-bailiwick NS without glue. +func resolveHost(ctx context.Context, host string) ([]string, error) { + var resolver net.Resolver + addrs, err := resolver.LookupHost(ctx, strings.TrimSuffix(host, ".")) + if err != nil { + return nil, err + } + return addrs, nil +} + +// findParentZone returns the parent zone and its authoritative servers. +// hintParent skips the label walk: happyDomain already knows the parent. +func findParentZone(ctx context.Context, fqdn, hintParent string) (zone string, servers []string, err error) { + zone = dns.Fqdn(hintParent) + if zone == "" || zone == "." { + labels := dns.SplitDomainName(fqdn) + if len(labels) == 0 { + return "", nil, fmt.Errorf("cannot derive parent of %q", fqdn) + } + zone = dns.Fqdn(strings.Join(labels[1:], ".")) + } + + servers, err = resolveZoneNSAddrs(ctx, zone) + if err != nil { + return "", nil, fmt.Errorf("resolving NS of parent zone %q: %w", zone, err) + } + if len(servers) == 0 { + return "", nil, fmt.Errorf("parent zone %q has no resolvable NS", zone) + } + return zone, servers, nil +} + +// resolveZoneNSAddrs returns "host:53" entries for the zone's NS set. +func resolveZoneNSAddrs(ctx context.Context, zone string) ([]string, error) { + var resolver net.Resolver + nss, err := resolver.LookupNS(ctx, strings.TrimSuffix(zone, ".")) + if err != nil { + return nil, err + } + + var out []string + for _, ns := range nss { + addrs, err := resolver.LookupHost(ctx, strings.TrimSuffix(ns.Host, ".")) + if err != nil || len(addrs) == 0 { + continue + } + for _, a := range addrs { + out = append(out, hostPort(a, "53")) + } + } + return out, nil +} + +// queryDelegation expects a referral response (no RD) and pulls NS + glue +// from every section so misconfigured parents (NS in Answer) still parse. +func queryDelegation(ctx context.Context, parentServer, fqdn string) (ns []string, glue map[string][]string, msg *dns.Msg, err error) { + q := dns.Question{Name: dns.Fqdn(fqdn), Qtype: dns.TypeNS, Qclass: dns.ClassINET} + + msg, err = dnsExchange(ctx, "", parentServer, q, true) + if err != nil { + return nil, nil, nil, err + } + if msg.Rcode != dns.RcodeSuccess { + return nil, nil, msg, fmt.Errorf("parent answered %s", dns.RcodeToString[msg.Rcode]) + } + + glue = map[string][]string{} + + collect := func(records []dns.RR) { + for _, rr := range records { + switch t := rr.(type) { + case *dns.NS: + if strings.EqualFold(strings.TrimSuffix(t.Header().Name, "."), strings.TrimSuffix(fqdn, ".")) { + ns = append(ns, strings.ToLower(dns.Fqdn(t.Ns))) + } + case *dns.A: + name := strings.ToLower(dns.Fqdn(t.Header().Name)) + glue[name] = append(glue[name], t.A.String()) + case *dns.AAAA: + name := strings.ToLower(dns.Fqdn(t.Header().Name)) + glue[name] = append(glue[name], t.AAAA.String()) + } + } + } + collect(msg.Answer) + collect(msg.Ns) + collect(msg.Extra) + return +} + +// queryDS uses TCP because DS+RRSIG answers commonly exceed UDP MTU. +func queryDS(ctx context.Context, parentServer, fqdn string) (ds []*dns.DS, sigs []*dns.RRSIG, err error) { + q := dns.Question{Name: dns.Fqdn(fqdn), Qtype: dns.TypeDS, Qclass: dns.ClassINET} + + r, err := dnsExchange(ctx, "tcp", parentServer, q, true) + if err != nil { + return nil, nil, err + } + if r.Rcode != dns.RcodeSuccess { + return nil, nil, fmt.Errorf("parent answered %s for DS", dns.RcodeToString[r.Rcode]) + } + + for _, rr := range r.Answer { + switch t := rr.(type) { + case *dns.DS: + ds = append(ds, t) + case *dns.RRSIG: + sigs = append(sigs, t) + } + } + return +} + +// querySOA also returns the AA flag so callers can detect lame servers. +func querySOA(ctx context.Context, proto, server, fqdn string) (soa *dns.SOA, aa bool, err error) { + q := dns.Question{Name: dns.Fqdn(fqdn), Qtype: dns.TypeSOA, Qclass: dns.ClassINET} + r, err := dnsExchange(ctx, proto, server, q, false) + if err != nil { + return nil, false, err + } + if r.Rcode != dns.RcodeSuccess { + return nil, r.Authoritative, fmt.Errorf("server answered %s", dns.RcodeToString[r.Rcode]) + } + for _, rr := range r.Answer { + if t, ok := rr.(*dns.SOA); ok { + return t, r.Authoritative, nil + } + } + return nil, r.Authoritative, fmt.Errorf("no SOA in answer section") +} + +func queryNSAt(ctx context.Context, server, fqdn string) ([]string, error) { + q := dns.Question{Name: dns.Fqdn(fqdn), Qtype: dns.TypeNS, Qclass: dns.ClassINET} + r, err := dnsExchange(ctx, "", server, q, false) + if err != nil { + return nil, err + } + if r.Rcode != dns.RcodeSuccess { + return nil, fmt.Errorf("server answered %s", dns.RcodeToString[r.Rcode]) + } + var out []string + for _, rr := range r.Answer { + if t, ok := rr.(*dns.NS); ok { + out = append(out, strings.ToLower(dns.Fqdn(t.Ns))) + } + } + return out, nil +} + +func queryAddrsAt(ctx context.Context, server, host string) ([]string, error) { + var out []string + for _, qt := range []uint16{dns.TypeA, dns.TypeAAAA} { + r, err := dnsExchange(ctx, "", server, dns.Question{Name: dns.Fqdn(host), Qtype: qt, Qclass: dns.ClassINET}, false) + if err != nil { + continue + } + if r.Rcode != dns.RcodeSuccess { + continue + } + for _, rr := range r.Answer { + switch t := rr.(type) { + case *dns.A: + out = append(out, t.A.String()) + case *dns.AAAA: + out = append(out, t.AAAA.String()) + } + } + } + return out, nil +} + +func queryDNSKEY(ctx context.Context, server, fqdn string) ([]*dns.DNSKEY, error) { + q := dns.Question{Name: dns.Fqdn(fqdn), Qtype: dns.TypeDNSKEY, Qclass: dns.ClassINET} + r, err := dnsExchange(ctx, "tcp", server, q, true) + if err != nil { + return nil, err + } + if r.Rcode != dns.RcodeSuccess { + return nil, fmt.Errorf("server answered %s for DNSKEY", dns.RcodeToString[r.Rcode]) + } + var out []*dns.DNSKEY + for _, rr := range r.Answer { + if t, ok := rr.(*dns.DNSKEY); ok { + out = append(out, t) + } + } + return out, nil +} + +func dsEqual(a, b *dns.DS) bool { + return a.KeyTag == b.KeyTag && + a.Algorithm == b.Algorithm && + a.DigestType == b.DigestType && + strings.EqualFold(a.Digest, b.Digest) +} diff --git a/checker/helpers.go b/checker/helpers.go new file mode 100644 index 0000000..ad3dc98 --- /dev/null +++ b/checker/helpers.go @@ -0,0 +1,108 @@ +package checker + +import ( + "sort" + "strings" + + "github.com/miekg/dns" +) + +func normalizeNSList(ns []*dns.NS) []string { + out := make([]string, 0, len(ns)) + for _, n := range ns { + if n == nil { + continue + } + out = append(out, strings.ToLower(dns.Fqdn(n.Ns))) + } + sort.Strings(out) + return out +} + +func diffStringSets(want, got []string) (missing, extra []string) { + w := map[string]bool{} + for _, v := range want { + w[strings.ToLower(strings.TrimSuffix(v, "."))] = true + } + g := map[string]bool{} + for _, v := range got { + g[strings.ToLower(strings.TrimSuffix(v, "."))] = true + } + for k := range w { + if !g[k] { + missing = append(missing, k) + } + } + for k := range g { + if !w[k] { + extra = append(extra, k) + } + } + sort.Strings(missing) + sort.Strings(extra) + return +} + +func diffDS(want, got []*dns.DS) (missing, extra []*dns.DS) { + for _, w := range want { + found := false + for _, g := range got { + if dsEqual(w, g) { + found = true + break + } + } + if !found { + missing = append(missing, w) + } + } + for _, g := range got { + found := false + for _, w := range want { + if dsEqual(w, g) { + found = true + break + } + } + if !found { + extra = append(extra, g) + } + } + return +} + +func isInBailiwick(host, zone string) bool { + host = strings.ToLower(dns.Fqdn(host)) + zone = strings.ToLower(dns.Fqdn(zone)) + return host == zone || strings.HasSuffix(host, "."+zone) +} + +func dsMatchesAnyKey(ds []*dns.DS, keys []*dns.DNSKEY) bool { + for _, k := range keys { + for _, d := range ds { + expected := k.ToDS(d.DigestType) + if expected != nil && dsEqual(expected, d) { + return true + } + } + } + return false +} + +// dsRecordsToMiekg lets diff/matcher helpers stay miekg-typed. +func dsRecordsToMiekg(list []DSRecord) []*dns.DS { + out := make([]*dns.DS, 0, len(list)) + for _, d := range list { + out = append(out, d.ToMiekg()) + } + return out +} + +// dnskeysToMiekg restores miekg form so k.ToDS is callable. +func dnskeysToMiekg(list []DNSKEYRecord) []*dns.DNSKEY { + out := make([]*dns.DNSKEY, 0, len(list)) + for _, k := range list { + out = append(out, k.ToMiekg()) + } + return out +} diff --git a/checker/helpers_test.go b/checker/helpers_test.go new file mode 100644 index 0000000..1773854 --- /dev/null +++ b/checker/helpers_test.go @@ -0,0 +1,136 @@ +package checker + +import ( + "reflect" + "testing" + + "github.com/miekg/dns" +) + +func TestDiffStringSets(t *testing.T) { + cases := []struct { + name string + want, got []string + missing, extra []string + }{ + { + name: "identical", + want: []string{"a.example.", "b.example."}, + got: []string{"a.example.", "b.example."}, + missing: nil, extra: nil, + }, + { + name: "case and trailing dot are normalized", + want: []string{"A.Example."}, + got: []string{"a.example"}, + missing: nil, extra: nil, + }, + { + name: "missing and extra reported", + want: []string{"a.example.", "b.example."}, + got: []string{"b.example.", "c.example."}, + missing: []string{"a.example"}, + extra: []string{"c.example"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + gotMissing, gotExtra := diffStringSets(tc.want, tc.got) + if !reflect.DeepEqual(gotMissing, tc.missing) { + t.Errorf("missing: got %v want %v", gotMissing, tc.missing) + } + if !reflect.DeepEqual(gotExtra, tc.extra) { + t.Errorf("extra: got %v want %v", gotExtra, tc.extra) + } + }) + } +} + +func TestIsInBailiwick(t *testing.T) { + cases := []struct { + host, zone string + want bool + }{ + {"ns1.example.com.", "example.com.", true}, + {"ns1.example.com", "example.com", true}, + {"example.com.", "example.com.", true}, + {"ns1.other.com.", "example.com.", false}, + {"ns1.notexample.com.", "example.com.", false}, // suffix-but-not-subdomain trap + {"NS1.Example.COM", "example.com", true}, + } + for _, tc := range cases { + if got := isInBailiwick(tc.host, tc.zone); got != tc.want { + t.Errorf("isInBailiwick(%q,%q)=%v want %v", tc.host, tc.zone, got, tc.want) + } + } +} + +func TestHostPort(t *testing.T) { + cases := []struct { + host, port, want string + }{ + {"192.0.2.1", "53", "192.0.2.1:53"}, + {"2001:db8::1", "53", "[2001:db8::1]:53"}, + {"ns1.example.com.", "53", "ns1.example.com:53"}, + } + for _, tc := range cases { + if got := hostPort(tc.host, tc.port); got != tc.want { + t.Errorf("hostPort(%q,%q)=%q want %q", tc.host, tc.port, got, tc.want) + } + } +} + +func TestNormalizeNSList(t *testing.T) { + in := []*dns.NS{ + {Ns: "B.example.COM"}, + nil, // must be skipped + {Ns: "a.example.com."}, + } + want := []string{"a.example.com.", "b.example.com."} + got := normalizeNSList(in) + if !reflect.DeepEqual(got, want) { + t.Errorf("normalizeNSList: got %v want %v", got, want) + } +} + +func TestDiffDS(t *testing.T) { + a := &dns.DS{KeyTag: 1, Algorithm: 8, DigestType: 2, Digest: "AAAA"} + b := &dns.DS{KeyTag: 2, Algorithm: 8, DigestType: 2, Digest: "BBBB"} + c := &dns.DS{KeyTag: 1, Algorithm: 8, DigestType: 2, Digest: "aaaa"} // case-insensitive digest + + missing, extra := diffDS([]*dns.DS{a, b}, []*dns.DS{c}) + if len(missing) != 1 || missing[0] != b { + t.Errorf("missing: got %v want [b]", missing) + } + if len(extra) != 0 { + t.Errorf("extra: got %v want []", extra) + } +} + +func TestDSMatchesAnyKey(t *testing.T) { + // Build a DNSKEY and derive its DS, so we know they match. + key := &dns.DNSKEY{ + Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeDNSKEY, Class: dns.ClassINET}, + Flags: 257, + Protocol: 3, + Algorithm: dns.RSASHA256, + // A throwaway public key; ToDS only needs the wire form to be deterministic. + PublicKey: "AwEAAcMnWBKLuvG/LwnPVykcmpvnntwxfshHlHRhlY0F3oz8AMcuF8gw" + + "2Ge56vG9oqVxTzHl4Ss2dEqCQOjFlOVo+pa3JwIO1lUzbQ==", + } + matchingDS := key.ToDS(dns.SHA256) + if matchingDS == nil { + t.Fatal("could not derive DS from DNSKEY") + } + other := &dns.DS{KeyTag: 9999, Algorithm: 99, DigestType: 99, Digest: "DEAD"} + + if !dsMatchesAnyKey([]*dns.DS{matchingDS, other}, []*dns.DNSKEY{key}) { + t.Error("expected match between key and its derived DS") + } + if dsMatchesAnyKey([]*dns.DS{other}, []*dns.DNSKEY{key}) { + t.Error("unexpected match against unrelated DS") + } + if dsMatchesAnyKey(nil, []*dns.DNSKEY{key}) { + t.Error("no DS records: must not match") + } +} diff --git a/checker/interactive.go b/checker/interactive.go new file mode 100644 index 0000000..a066012 --- /dev/null +++ b/checker/interactive.go @@ -0,0 +1,135 @@ +//go:build standalone + +package checker + +import ( + "context" + "encoding/json" + "fmt" + "net" + "net/http" + "strings" + "sync" + + "github.com/miekg/dns" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +func (p *delegationProvider) RenderForm() []sdk.CheckerOptionField { + return []sdk.CheckerOptionField{ + { + Id: "domain", + Type: "string", + Label: "Delegated domain", + Placeholder: "sub.example.com", + Required: true, + Description: "Fully-qualified name of the delegated zone to check.", + }, + } +} + +func (p *delegationProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) { + domain := strings.TrimSpace(r.FormValue("domain")) + if domain == "" { + return nil, fmt.Errorf("domain is required") + } + fqdn := dns.Fqdn(domain) + labels := dns.SplitDomainName(fqdn) + if len(labels) < 2 { + return nil, fmt.Errorf("%q has no parent zone", domain) + } + parentZone := strings.Join(labels[1:], ".") + subdomain := labels[0] + + resolver := interactiveResolver() + + ctx := r.Context() + var ( + wg sync.WaitGroup + nsRecords []*dns.NS + dsRecords []*dns.DS + nsErr error + dsErr error + ) + wg.Add(2) + go func() { + defer wg.Done() + nsRecords, nsErr = lookupRecords[*dns.NS](ctx, resolver, fqdn, dns.TypeNS, false) + }() + go func() { + defer wg.Done() + dsRecords, dsErr = lookupRecords[*dns.DS](ctx, resolver, fqdn, dns.TypeDS, true) + }() + wg.Wait() + + if nsErr != nil { + return nil, fmt.Errorf("NS lookup for %s: %w", domain, nsErr) + } + if len(nsRecords) == 0 { + return nil, fmt.Errorf("no NS records found for %s", domain) + } + if dsErr != nil { + return nil, fmt.Errorf("DS lookup for %s: %w", domain, dsErr) + } + + body, err := json.Marshal(delegationService{NameServers: nsRecords, DS: dsRecords}) + if err != nil { + return nil, fmt.Errorf("marshal delegation service: %w", err) + } + + svc := serviceMessage{ + Type: "abstract.Delegation", + Service: body, + } + + return sdk.CheckerOptions{ + "domain_name": parentZone, + "subdomain": subdomain, + "service": svc, + }, nil +} + +var ( + resolverOnce sync.Once + resolverAddr string + + interactiveClient = &dns.Client{Timeout: dnsTimeout} +) + +func interactiveResolver() string { + resolverOnce.Do(func() { + cfg, err := dns.ClientConfigFromFile("/etc/resolv.conf") + if err != nil || len(cfg.Servers) == 0 { + resolverAddr = net.JoinHostPort("1.1.1.1", "53") + return + } + resolverAddr = net.JoinHostPort(cfg.Servers[0], cfg.Port) + }) + return resolverAddr +} + +func lookupRecords[T dns.RR](ctx context.Context, resolver, fqdn string, qtype uint16, edns bool) ([]T, error) { + msg := new(dns.Msg) + msg.SetQuestion(fqdn, qtype) + msg.RecursionDesired = true + if edns { + msg.SetEdns0(4096, true) + } + + in, _, err := interactiveClient.ExchangeContext(ctx, msg, resolver) + if err != nil { + return nil, err + } + if in.Rcode != dns.RcodeSuccess && in.Rcode != dns.RcodeNameError { + return nil, fmt.Errorf("rcode %s", dns.RcodeToString[in.Rcode]) + } + + var out []T + for _, rr := range in.Answer { + if t, ok := rr.(T); ok { + out = append(out, t) + } + } + return out, nil +} diff --git a/checker/provider.go b/checker/provider.go new file mode 100644 index 0000000..d04e40a --- /dev/null +++ b/checker/provider.go @@ -0,0 +1,48 @@ +package checker + +import ( + "fmt" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +func Provider() sdk.ObservationProvider { + return &delegationProvider{} +} + +type delegationProvider struct{} + +func (p *delegationProvider) Key() sdk.ObservationKey { + return ObservationKeyDelegation +} + +// ValidateOptions runs once per provider so each rule doesn't re-check. +func (p *delegationProvider) ValidateOptions(opts sdk.CheckerOptions) error { + if v, ok := opts["minNameServers"]; ok { + var f float64 + switch n := v.(type) { + case float64: + f = n + case float32: + f = float64(n) + case int: + f = float64(n) + case int32: + f = float64(n) + case int64: + f = float64(n) + case uint: + f = float64(n) + case uint32: + f = float64(n) + case uint64: + f = float64(n) + default: + return fmt.Errorf("minNameServers must be a number") + } + if f < 1 { + return fmt.Errorf("minNameServers must be >= 1") + } + } + return nil +} diff --git a/checker/report.go b/checker/report.go new file mode 100644 index 0000000..ac8a4b4 --- /dev/null +++ b/checker/report.go @@ -0,0 +1,262 @@ +package checker + +import ( + "encoding/json" + "fmt" + "html" + "sort" + "strings" + "time" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// GetHTMLReport falls back to a data-only render when the host hasn't +// threaded rule states into the context yet. +func (p *delegationProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) { + var data DelegationData + if raw := ctx.Data(); len(raw) > 0 { + if err := json.Unmarshal(raw, &data); err != nil { + return "", fmt.Errorf("decoding delegation data: %w", err) + } + } + + states := ctx.States() + + var b strings.Builder + b.WriteString(``) + b.WriteString(`Delegation report`) + + fmt.Fprintf(&b, `

Delegation of %s

`, html.EscapeString(strings.TrimSuffix(data.DelegatedFQDN, "."))) + + if len(states) == 0 { + b.WriteString(`

No rule states were threaded into this report; rendering raw observation only.

`) + writeDataOnly(&b, &data) + b.WriteString(``) + return b.String(), nil + } + + writeBanner(&b, states) + writeFixTheseFirst(&b, states) + writeAllStates(&b, states) + writeDataOnly(&b, &data) + + b.WriteString(``) + return b.String(), nil +} + +func (p *delegationProvider) ExtractMetrics(ctx sdk.ReportContext, collectedAt time.Time) ([]sdk.CheckMetric, error) { + var data DelegationData + if raw := ctx.Data(); len(raw) > 0 { + if err := json.Unmarshal(raw, &data); err != nil { + return nil, fmt.Errorf("decoding delegation data: %w", err) + } + } + + var metrics []sdk.CheckMetric + + metrics = append(metrics, sdk.CheckMetric{ + Name: "delegation.parent_views.count", + Value: float64(len(data.ParentViews)), + Timestamp: collectedAt, + }) + metrics = append(metrics, sdk.CheckMetric{ + Name: "delegation.child_servers.count", + Value: float64(len(data.Children)), + Timestamp: collectedAt, + }) + + byRuleStatus := map[string]map[sdk.Status]int{} + byStatus := map[sdk.Status]int{} + for _, s := range ctx.States() { + byStatus[s.Status]++ + if byRuleStatus[s.RuleName] == nil { + byRuleStatus[s.RuleName] = map[sdk.Status]int{} + } + byRuleStatus[s.RuleName][s.Status]++ + } + + for rule, perStatus := range byRuleStatus { + for status, n := range perStatus { + metrics = append(metrics, sdk.CheckMetric{ + Name: "delegation.rule.status", + Value: float64(n), + Labels: map[string]string{ + "rule": rule, + "status": status.String(), + }, + Timestamp: collectedAt, + }) + } + } + + for status, n := range byStatus { + if status == sdk.StatusOK { + continue + } + metrics = append(metrics, sdk.CheckMetric{ + Name: "delegation.findings.count", + Value: float64(n), + Labels: map[string]string{"status": status.String()}, + Timestamp: collectedAt, + }) + } + + return metrics, nil +} + +func worstStatus(states []sdk.CheckState) sdk.Status { + worst := sdk.StatusOK + for _, s := range states { + if s.Status > worst { + worst = s.Status + } + } + return worst +} + +func statusColor(s sdk.Status) string { + switch s { + case sdk.StatusOK: + return "#2e7d32" + case sdk.StatusInfo: + return "#0277bd" + case sdk.StatusWarn: + return "#ef6c00" + case sdk.StatusCrit: + return "#c62828" + case sdk.StatusError: + return "#6a1b9a" + default: + return "#555" + } +} + +func writeBanner(b *strings.Builder, states []sdk.CheckState) { + worst := worstStatus(states) + fmt.Fprintf(b, `

Overall: %s

`, + statusColor(worst), worst.String()) +} + +func writeFixTheseFirst(b *strings.Builder, states []sdk.CheckState) { + var fix []sdk.CheckState + for _, s := range states { + if s.Status >= sdk.StatusWarn { + fix = append(fix, s) + } + } + if len(fix) == 0 { + return + } + sort.SliceStable(fix, func(i, j int) bool { + if fix[i].Status != fix[j].Status { + return fix[i].Status > fix[j].Status + } + if fix[i].RuleName != fix[j].RuleName { + return fix[i].RuleName < fix[j].RuleName + } + return fix[i].Subject < fix[j].Subject + }) + b.WriteString(`

Fix these first

`) + writeStatesTable(b, fix) +} + +func writeAllStates(b *strings.Builder, states []sdk.CheckState) { + sorted := append([]sdk.CheckState(nil), states...) + sort.SliceStable(sorted, func(i, j int) bool { + if sorted[i].RuleName != sorted[j].RuleName { + return sorted[i].RuleName < sorted[j].RuleName + } + return sorted[i].Subject < sorted[j].Subject + }) + b.WriteString(`

All rule states

`) + writeStatesTable(b, sorted) +} + +func writeStatesTable(b *strings.Builder, states []sdk.CheckState) { + b.WriteString(``) + b.WriteString(``) + for _, s := range states { + fmt.Fprintf(b, ``, + statusColor(s.Status), + html.EscapeString(s.Status.String()), + html.EscapeString(s.RuleName), + html.EscapeString(s.Subject), + html.EscapeString(s.Message), + ) + } + b.WriteString(`
StatusRuleSubjectMessage
%s%s%s%s
`) +} + +func writeDataOnly(b *strings.Builder, data *DelegationData) { + b.WriteString(`

Observation

`) + if data.ParentDiscoveryError != "" { + fmt.Fprintf(b, `

Parent discovery error: %s

`, html.EscapeString(data.ParentDiscoveryError)) + } + + if len(data.DeclaredNS) > 0 { + fmt.Fprintf(b, `

Declared NS: %s

`, html.EscapeString(strings.Join(data.DeclaredNS, ", "))) + } + if len(data.DeclaredDS) > 0 { + var texts []string + for _, d := range data.DeclaredDS { + texts = append(texts, fmt.Sprintf("keytag=%d algo=%d digest-type=%d", d.KeyTag, d.Algorithm, d.DigestType)) + } + fmt.Fprintf(b, `

Declared DS: %s

`, html.EscapeString(strings.Join(texts, "; "))) + } + + if len(data.ParentViews) > 0 { + b.WriteString(`

Parent views

`) + } + + if len(data.Children) > 0 { + b.WriteString(`

Delegated servers

`) + } +} diff --git a/checker/rule.go b/checker/rule.go new file mode 100644 index 0000000..42f29c2 --- /dev/null +++ b/checker/rule.go @@ -0,0 +1,992 @@ +package checker + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/miekg/dns" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// Rules returns the full rule set. All rules share one DelegationData +// observation and emit one CheckState per evaluated subject. +func Rules() []sdk.CheckRule { + return []sdk.CheckRule{ + &minNameServersRule{}, + &parentDiscoveredRule{}, + &parentNSQueryRule{}, + &parentTCPRule{}, + &nsMatchesDeclaredRule{}, + &inBailiwickGlueRule{}, + &unnecessaryGlueRule{}, + &dsQueryRule{}, + &dsMatchesDeclaredRule{}, + &dsPresentAtParentRule{}, + &dsRRSIGValidityRule{}, + &nsResolvableRule{}, + &childReachableRule{}, + &childAuthoritativeRule{}, + &childSOASerialDriftRule{}, + &childTCPRule{}, + &childNSMatchesParentRule{}, + &childGlueMatchesParentRule{}, + &dnskeyQueryRule{}, + &dnskeyMatchesDSRule{}, + &nsHasAuthoritativeAnswerRule{}, + } +} + +func loadData(ctx context.Context, obs sdk.ObservationGetter, code string) (*DelegationData, []sdk.CheckState) { + var data DelegationData + if err := obs.Get(ctx, ObservationKeyDelegation, &data); err != nil { + return nil, []sdk.CheckState{{ + Status: sdk.StatusError, + Message: fmt.Sprintf("Failed to get delegation data: %v", err), + Code: code, + }} + } + return &data, nil +} + +// primaryParentView mirrors Collect's Phase-B source-of-truth choice. +func primaryParentView(views []ParentView) *ParentView { + for i := range views { + if views[i].UDPNSError == "" && len(views[i].NS) > 0 { + return &views[i] + } + } + return nil +} + +// ───────────────────────── checker-wide rules ───────────────────────── + +type minNameServersRule struct{} + +func (r *minNameServersRule) Name() string { return "delegation_min_name_servers" } +func (r *minNameServersRule) Description() string { + return "Checks that enough name servers are declared for the delegation (RFC 1034 recommends at least 2)" +} +func (r *minNameServersRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { + data, errState := loadData(ctx, obs, "delegation_too_few_ns") + if errState != nil { + return errState + } + minNS := sdk.GetIntOption(opts, "minNameServers", 2) + if len(data.DeclaredNS) < minNS { + return []sdk.CheckState{{ + Status: sdk.StatusWarn, + Code: "delegation_too_few_ns", + Message: fmt.Sprintf("only %d name server(s) declared, at least %d recommended", len(data.DeclaredNS), minNS), + Meta: map[string]any{"declared": len(data.DeclaredNS), "minimum": minNS}, + }} + } + return []sdk.CheckState{{ + Status: sdk.StatusOK, + Code: "delegation_too_few_ns", + Message: fmt.Sprintf("%d name server(s) declared", len(data.DeclaredNS)), + }} +} + +type parentDiscoveredRule struct{} + +func (r *parentDiscoveredRule) Name() string { return "delegation_parent_discovered" } +func (r *parentDiscoveredRule) Description() string { + return "Verifies that the parent zone's authoritative servers could be discovered" +} +func (r *parentDiscoveredRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { + data, errState := loadData(ctx, obs, "delegation_no_parent_ns") + if errState != nil { + return errState + } + if data.ParentDiscoveryError != "" { + return []sdk.CheckState{{ + Status: sdk.StatusCrit, + Code: "delegation_no_parent_ns", + Message: data.ParentDiscoveryError, + }} + } + if len(data.ParentNS) == 0 { + return []sdk.CheckState{{ + Status: sdk.StatusCrit, + Code: "delegation_no_parent_ns", + Message: "parent zone has no resolvable authoritative servers", + }} + } + return []sdk.CheckState{{ + Status: sdk.StatusOK, + Code: "delegation_no_parent_ns", + Message: fmt.Sprintf("%d parent authoritative server(s) discovered", len(data.ParentNS)), + }} +} + +// ───────────────────────── parent-side rules ───────────────────────── + +type parentNSQueryRule struct{} + +func (r *parentNSQueryRule) Name() string { return "delegation_parent_ns_query" } +func (r *parentNSQueryRule) Description() string { + return "Verifies that every parent authoritative server answers the NS query for the delegated FQDN" +} +func (r *parentNSQueryRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { + data, errState := loadData(ctx, obs, "delegation_parent_query_failed") + if errState != nil { + return errState + } + if len(data.ParentViews) == 0 { + return []sdk.CheckState{{ + Status: sdk.StatusUnknown, + Code: "delegation_parent_query_failed", + Message: "no parent server was queried", + }} + } + out := make([]sdk.CheckState, 0, len(data.ParentViews)) + for _, v := range data.ParentViews { + st := sdk.CheckState{Code: "delegation_parent_query_failed", Subject: v.Server} + switch { + case v.UDPNSError != "": + st.Status = sdk.StatusCrit + st.Message = fmt.Sprintf("parent NS query failed: %s", v.UDPNSError) + case len(v.NS) == 0: + st.Status = sdk.StatusCrit + st.Message = "parent returned an empty NS RRset" + default: + st.Status = sdk.StatusOK + st.Message = fmt.Sprintf("%d NS record(s) returned", len(v.NS)) + } + out = append(out, st) + } + return out +} + +type parentTCPRule struct{} + +func (r *parentTCPRule) Name() string { return "delegation_parent_tcp" } +func (r *parentTCPRule) Description() string { + return "Verifies that every parent authoritative server answers the NS query over TCP" +} +func (r *parentTCPRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { + data, errState := loadData(ctx, obs, "delegation_parent_tcp_failed") + if errState != nil { + return errState + } + if len(data.ParentViews) == 0 { + return []sdk.CheckState{{ + Status: sdk.StatusUnknown, + Code: "delegation_parent_tcp_failed", + Message: "no parent server was queried", + }} + } + requireTCP := sdk.GetBoolOption(opts, "requireTCP", true) + failStatus := sdk.StatusCrit + if !requireTCP { + failStatus = sdk.StatusWarn + } + out := make([]sdk.CheckState, 0, len(data.ParentViews)) + for _, v := range data.ParentViews { + st := sdk.CheckState{Code: "delegation_parent_tcp_failed", Subject: v.Server} + if v.TCPNSError != "" { + st.Status = failStatus + st.Message = fmt.Sprintf("parent NS query over TCP failed: %s", v.TCPNSError) + } else { + st.Status = sdk.StatusOK + st.Message = "TCP reachable" + } + out = append(out, st) + } + return out +} + +type nsMatchesDeclaredRule struct{} + +func (r *nsMatchesDeclaredRule) Name() string { return "delegation_ns_matches_declared" } +func (r *nsMatchesDeclaredRule) Description() string { + return "Verifies that the NS RRset served by the parent matches the service's declared name servers" +} +func (r *nsMatchesDeclaredRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { + data, errState := loadData(ctx, obs, "delegation_ns_mismatch") + if errState != nil { + return errState + } + var out []sdk.CheckState + for _, v := range data.ParentViews { + if v.UDPNSError != "" || len(v.NS) == 0 { + continue + } + missing, extra := diffStringSets(data.DeclaredNS, v.NS) + st := sdk.CheckState{Code: "delegation_ns_mismatch", Subject: v.Server} + if len(missing) > 0 || len(extra) > 0 { + st.Status = sdk.StatusCrit + st.Message = fmt.Sprintf("NS RRset does not match declared: missing=%v extra=%v", missing, extra) + st.Meta = map[string]any{"missing": missing, "extra": extra} + } else { + st.Status = sdk.StatusOK + st.Message = "NS RRset matches the declared service" + } + out = append(out, st) + } + if len(out) == 0 { + return []sdk.CheckState{{ + Status: sdk.StatusUnknown, + Code: "delegation_ns_mismatch", + Message: "no parent server returned an NS RRset", + }} + } + return out +} + +type inBailiwickGlueRule struct{} + +func (r *inBailiwickGlueRule) Name() string { return "delegation_in_bailiwick_glue" } +func (r *inBailiwickGlueRule) Description() string { + return "Verifies that every in-bailiwick NS hostname has glue records at the parent" +} +func (r *inBailiwickGlueRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { + data, errState := loadData(ctx, obs, "delegation_missing_glue") + if errState != nil { + return errState + } + if len(data.ParentViews) == 0 { + return []sdk.CheckState{{ + Status: sdk.StatusUnknown, + Code: "delegation_missing_glue", + Message: "no parent server was queried", + }} + } + var out []sdk.CheckState + for _, v := range data.ParentViews { + if v.UDPNSError != "" { + continue + } + for _, n := range v.NS { + if !isInBailiwick(n, data.DelegatedFQDN) { + continue + } + subject := fmt.Sprintf("%s@%s", n, v.Server) + if len(v.Glue[n]) == 0 { + out = append(out, sdk.CheckState{ + Status: sdk.StatusCrit, + Code: "delegation_missing_glue", + Subject: subject, + Message: "in-bailiwick NS has no glue", + }) + } else { + out = append(out, sdk.CheckState{ + Status: sdk.StatusOK, + Code: "delegation_missing_glue", + Subject: subject, + Message: fmt.Sprintf("%d glue address(es)", len(v.Glue[n])), + }) + } + } + } + if len(out) == 0 { + return []sdk.CheckState{{ + Status: sdk.StatusOK, + Code: "delegation_missing_glue", + Message: "no in-bailiwick NS, glue not required", + }} + } + return out +} + +type unnecessaryGlueRule struct{} + +func (r *unnecessaryGlueRule) Name() string { return "delegation_unnecessary_glue" } +func (r *unnecessaryGlueRule) Description() string { + return "Flags out-of-bailiwick NS hostnames for which the parent still returns glue" +} +func (r *unnecessaryGlueRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { + data, errState := loadData(ctx, obs, "delegation_unnecessary_glue") + if errState != nil { + return errState + } + var out []sdk.CheckState + for _, v := range data.ParentViews { + if v.UDPNSError != "" { + continue + } + for _, n := range v.NS { + if isInBailiwick(n, data.DelegatedFQDN) { + continue + } + subject := fmt.Sprintf("%s@%s", n, v.Server) + if len(v.Glue[n]) > 0 { + out = append(out, sdk.CheckState{ + Status: sdk.StatusWarn, + Code: "delegation_unnecessary_glue", + Subject: subject, + Message: "out-of-bailiwick NS has glue records at the parent", + }) + } else { + out = append(out, sdk.CheckState{ + Status: sdk.StatusOK, + Code: "delegation_unnecessary_glue", + Subject: subject, + Message: "no glue (expected)", + }) + } + } + } + if len(out) == 0 { + return []sdk.CheckState{{ + Status: sdk.StatusInfo, + Code: "delegation_unnecessary_glue", + Message: "no out-of-bailiwick NS to evaluate", + }} + } + return out +} + +type dsQueryRule struct{} + +func (r *dsQueryRule) Name() string { return "delegation_ds_query" } +func (r *dsQueryRule) Description() string { + return "Verifies that every parent authoritative server answers the DS query for the delegated FQDN" +} +func (r *dsQueryRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { + data, errState := loadData(ctx, obs, "delegation_ds_query_failed") + if errState != nil { + return errState + } + if len(data.ParentViews) == 0 { + return []sdk.CheckState{{ + Status: sdk.StatusUnknown, + Code: "delegation_ds_query_failed", + Message: "no parent server was queried", + }} + } + out := make([]sdk.CheckState, 0, len(data.ParentViews)) + for _, v := range data.ParentViews { + st := sdk.CheckState{Code: "delegation_ds_query_failed", Subject: v.Server} + if v.DSQueryError != "" { + st.Status = sdk.StatusWarn + st.Message = fmt.Sprintf("DS query failed: %s", v.DSQueryError) + } else { + st.Status = sdk.StatusOK + st.Message = fmt.Sprintf("%d DS record(s) returned", len(v.DS)) + } + out = append(out, st) + } + return out +} + +type dsMatchesDeclaredRule struct{} + +func (r *dsMatchesDeclaredRule) Name() string { return "delegation_ds_matches_declared" } +func (r *dsMatchesDeclaredRule) Description() string { + return "Verifies that the DS RRset served by the parent matches the service's declared DS records" +} +func (r *dsMatchesDeclaredRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { + data, errState := loadData(ctx, obs, "delegation_ds_mismatch") + if errState != nil { + return errState + } + declared := dsRecordsToMiekg(data.DeclaredDS) + var out []sdk.CheckState + for _, v := range data.ParentViews { + if v.DSQueryError != "" { + continue + } + got := dsRecordsToMiekg(v.DS) + if len(declared) == 0 && len(got) == 0 { + continue + } + missing, extra := diffDS(declared, got) + st := sdk.CheckState{Code: "delegation_ds_mismatch", Subject: v.Server} + if len(missing) == 0 && len(extra) == 0 { + st.Status = sdk.StatusOK + st.Message = "DS RRset matches the declared service" + } else { + if len(declared) == 0 { + st.Status = sdk.StatusWarn + } else { + st.Status = sdk.StatusCrit + } + st.Message = fmt.Sprintf("DS RRset does not match declared: missing=%d extra=%d", len(missing), len(extra)) + st.Meta = map[string]any{"missing": len(missing), "extra": len(extra)} + } + out = append(out, st) + } + if len(out) == 0 { + return []sdk.CheckState{{ + Status: sdk.StatusInfo, + Code: "delegation_ds_mismatch", + Message: "no DS data to compare", + }} + } + return out +} + +type dsPresentAtParentRule struct{} + +func (r *dsPresentAtParentRule) Name() string { return "delegation_ds_present_at_parent" } +func (r *dsPresentAtParentRule) Description() string { + return "Flags the case where the service declares DS records but the parent serves none" +} +func (r *dsPresentAtParentRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { + data, errState := loadData(ctx, obs, "delegation_ds_missing") + if errState != nil { + return errState + } + if len(data.DeclaredDS) == 0 { + return []sdk.CheckState{{ + Status: sdk.StatusInfo, + Code: "delegation_ds_missing", + Message: "service declares no DS records", + }} + } + anyDS := false + for _, v := range data.ParentViews { + if v.DSQueryError == "" && len(v.DS) > 0 { + anyDS = true + break + } + } + if anyDS { + return []sdk.CheckState{{ + Status: sdk.StatusOK, + Code: "delegation_ds_missing", + Message: "parent serves DS records for the delegation", + }} + } + status := sdk.StatusInfo + if sdk.GetBoolOption(opts, "requireDS", false) { + status = sdk.StatusCrit + } + return []sdk.CheckState{{ + Status: status, + Code: "delegation_ds_missing", + Message: "service declares DS records but parent serves none", + }} +} + +type dsRRSIGValidityRule struct{} + +func (r *dsRRSIGValidityRule) Name() string { return "delegation_ds_rrsig_validity" } +func (r *dsRRSIGValidityRule) Description() string { + return "Verifies that every RRSIG covering the DS RRset is inside its validity window" +} +func (r *dsRRSIGValidityRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { + data, errState := loadData(ctx, obs, "delegation_ds_rrsig_invalid") + if errState != nil { + return errState + } + now := time.Now() + var out []sdk.CheckState + for _, v := range data.ParentViews { + if v.DSQueryError != "" || len(v.DSRRSIGs) == 0 { + continue + } + worst := sdk.StatusOK + var reason string + for _, sig := range v.DSRRSIGs { + probe := &dns.RRSIG{Inception: sig.Inception, Expiration: sig.Expiration} + if !probe.ValidityPeriod(now) { + worst = sdk.StatusCrit + reason = rrsigReason(sig, now) + break + } + } + st := sdk.CheckState{Code: "delegation_ds_rrsig_invalid", Subject: v.Server, Status: worst} + if worst == sdk.StatusOK { + st.Message = "DS RRSIG within validity window" + } else { + st.Message = fmt.Sprintf("DS RRSIG: %s", reason) + } + out = append(out, st) + } + if len(out) == 0 { + return []sdk.CheckState{{ + Status: sdk.StatusInfo, + Code: "delegation_ds_rrsig_invalid", + Message: "no DS RRSIG to evaluate", + }} + } + return out +} + +// rrsigReason distinguishes "not yet valid" from "expired"; miekg's +// ValidityPeriod only returns a bool, so we redo the uint32-wraparound math. +func rrsigReason(sig DSRRSIGObservation, now time.Time) string { + utc := now.UTC().Unix() + modi := (int64(sig.Inception) - utc) / year68 + ti := int64(sig.Inception) + modi*year68 + mode := (int64(sig.Expiration) - utc) / year68 + te := int64(sig.Expiration) + mode*year68 + switch { + case ti > utc: + return "signature not yet valid" + case utc > te: + return "signature expired" + default: + return "signature outside its validity window" + } +} + +// ───────────────────────── child-side rules ───────────────────────── + +type nsResolvableRule struct{} + +func (r *nsResolvableRule) Name() string { return "delegation_ns_resolvable" } +func (r *nsResolvableRule) Description() string { + return "Verifies that every out-of-bailiwick NS hostname resolves to at least one address" +} +func (r *nsResolvableRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { + data, errState := loadData(ctx, obs, "delegation_ns_unresolvable") + if errState != nil { + return errState + } + var out []sdk.CheckState + for _, c := range data.Children { + if isInBailiwick(c.NSName, data.DelegatedFQDN) { + continue + } + st := sdk.CheckState{Code: "delegation_ns_unresolvable", Subject: c.NSName} + if c.ResolveError != "" { + st.Status = sdk.StatusCrit + st.Message = fmt.Sprintf("cannot resolve NS: %s", c.ResolveError) + } else { + st.Status = sdk.StatusOK + st.Message = fmt.Sprintf("%d address(es)", len(c.Addresses)) + } + out = append(out, st) + } + if len(out) == 0 { + return []sdk.CheckState{{ + Status: sdk.StatusInfo, + Code: "delegation_ns_unresolvable", + Message: "no out-of-bailiwick NS to resolve", + }} + } + return out +} + +type childReachableRule struct{} + +func (r *childReachableRule) Name() string { return "delegation_child_reachable" } +func (r *childReachableRule) Description() string { + return "Verifies that every delegated name server address answers over UDP" +} +func (r *childReachableRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { + data, errState := loadData(ctx, obs, "delegation_unreachable") + if errState != nil { + return errState + } + var out []sdk.CheckState + for _, c := range data.Children { + for _, a := range c.Addresses { + subject := fmt.Sprintf("%s (%s)", c.NSName, a.Address) + st := sdk.CheckState{Code: "delegation_unreachable", Subject: subject} + if a.UDPError != "" { + st.Status = sdk.StatusCrit + st.Message = fmt.Sprintf("UDP SOA query failed: %s", a.UDPError) + } else { + st.Status = sdk.StatusOK + st.Message = "UDP SOA query succeeded" + } + out = append(out, st) + } + } + if len(out) == 0 { + return []sdk.CheckState{{ + Status: sdk.StatusUnknown, + Code: "delegation_unreachable", + Message: "no delegated server address to probe", + }} + } + return out +} + +type childAuthoritativeRule struct{} + +func (r *childAuthoritativeRule) Name() string { return "delegation_child_authoritative" } +func (r *childAuthoritativeRule) Description() string { + return "Verifies that every reachable delegated server answers authoritatively (AA bit) for the zone" +} +func (r *childAuthoritativeRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { + data, errState := loadData(ctx, obs, "delegation_lame") + if errState != nil { + return errState + } + var out []sdk.CheckState + for _, c := range data.Children { + for _, a := range c.Addresses { + if a.UDPError != "" { + continue + } + subject := fmt.Sprintf("%s (%s)", c.NSName, a.Address) + st := sdk.CheckState{Code: "delegation_lame", Subject: subject} + if !a.Authoritative { + st.Status = sdk.StatusCrit + st.Message = "server is not authoritative for the zone" + } else { + st.Status = sdk.StatusOK + st.Message = "authoritative answer" + } + out = append(out, st) + } + } + if len(out) == 0 { + return []sdk.CheckState{{ + Status: sdk.StatusUnknown, + Code: "delegation_lame", + Message: "no reachable delegated server to probe", + }} + } + return out +} + +type childSOASerialDriftRule struct{} + +func (r *childSOASerialDriftRule) Name() string { return "delegation_child_soa_serial_drift" } +func (r *childSOASerialDriftRule) Description() string { + return "Verifies that all reachable addresses of a name server agree on the SOA serial" +} +func (r *childSOASerialDriftRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { + data, errState := loadData(ctx, obs, "delegation_soa_serial_drift") + if errState != nil { + return errState + } + var out []sdk.CheckState + for _, c := range data.Children { + seen := map[uint32]bool{} + for _, a := range c.Addresses { + if a.SOASerialKnown { + seen[a.SOASerial] = true + } + } + if len(seen) == 0 { + continue + } + st := sdk.CheckState{Code: "delegation_soa_serial_drift", Subject: c.NSName} + if len(seen) > 1 { + serials := make([]string, 0, len(seen)) + for s := range seen { + serials = append(serials, fmt.Sprintf("%d", s)) + } + st.Status = sdk.StatusWarn + st.Message = fmt.Sprintf("SOA serial drift across addresses: %s", strings.Join(serials, ", ")) + } else { + st.Status = sdk.StatusOK + st.Message = "all addresses agree on SOA serial" + } + out = append(out, st) + } + if len(out) == 0 { + return []sdk.CheckState{{ + Status: sdk.StatusUnknown, + Code: "delegation_soa_serial_drift", + Message: "no SOA serial observed", + }} + } + return out +} + +type childTCPRule struct{} + +func (r *childTCPRule) Name() string { return "delegation_child_tcp" } +func (r *childTCPRule) Description() string { + return "Verifies that every reachable delegated server also answers over TCP" +} +func (r *childTCPRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { + data, errState := loadData(ctx, obs, "delegation_tcp_failed") + if errState != nil { + return errState + } + requireTCP := sdk.GetBoolOption(opts, "requireTCP", true) + failStatus := sdk.StatusCrit + if !requireTCP { + failStatus = sdk.StatusWarn + } + var out []sdk.CheckState + for _, c := range data.Children { + for _, a := range c.Addresses { + if a.UDPError != "" { + continue + } + subject := fmt.Sprintf("%s (%s)", c.NSName, a.Address) + st := sdk.CheckState{Code: "delegation_tcp_failed", Subject: subject} + if a.TCPError != "" { + st.Status = failStatus + st.Message = fmt.Sprintf("TCP SOA query failed: %s", a.TCPError) + } else { + st.Status = sdk.StatusOK + st.Message = "TCP reachable" + } + out = append(out, st) + } + } + if len(out) == 0 { + return []sdk.CheckState{{ + Status: sdk.StatusUnknown, + Code: "delegation_tcp_failed", + Message: "no reachable delegated server to probe", + }} + } + return out +} + +type childNSMatchesParentRule struct{} + +func (r *childNSMatchesParentRule) Name() string { return "delegation_child_ns_matches_parent" } +func (r *childNSMatchesParentRule) Description() string { + return "Verifies that the NS RRset served by each delegated server agrees with the parent's view" +} +func (r *childNSMatchesParentRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { + data, errState := loadData(ctx, obs, "delegation_ns_drift") + if errState != nil { + return errState + } + primary := primaryParentView(data.ParentViews) + if primary == nil { + return []sdk.CheckState{{ + Status: sdk.StatusUnknown, + Code: "delegation_ns_drift", + Message: "no parent NS RRset to compare against", + }} + } + var out []sdk.CheckState + for _, c := range data.Children { + for _, a := range c.Addresses { + if a.UDPError != "" || a.ChildNSError != "" { + continue + } + subject := fmt.Sprintf("%s (%s)", c.NSName, a.Address) + missing, extra := diffStringSets(primary.NS, a.ChildNS) + st := sdk.CheckState{Code: "delegation_ns_drift", Subject: subject} + if len(missing) > 0 || len(extra) > 0 { + st.Status = sdk.StatusWarn + st.Message = fmt.Sprintf("child NS RRset differs from parent: missing=%v extra=%v", missing, extra) + st.Meta = map[string]any{"missing": missing, "extra": extra} + } else { + st.Status = sdk.StatusOK + st.Message = "child NS RRset matches parent" + } + out = append(out, st) + } + } + if len(out) == 0 { + return []sdk.CheckState{{ + Status: sdk.StatusUnknown, + Code: "delegation_ns_drift", + Message: "no child NS RRset observed", + }} + } + return out +} + +type childGlueMatchesParentRule struct{} + +func (r *childGlueMatchesParentRule) Name() string { return "delegation_child_glue_matches_parent" } +func (r *childGlueMatchesParentRule) Description() string { + return "Verifies that the addresses served by the child for in-bailiwick NS names match the parent glue" +} +func (r *childGlueMatchesParentRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { + data, errState := loadData(ctx, obs, "delegation_glue_mismatch") + if errState != nil { + return errState + } + primary := primaryParentView(data.ParentViews) + if primary == nil { + return []sdk.CheckState{{ + Status: sdk.StatusUnknown, + Code: "delegation_glue_mismatch", + Message: "no parent glue to compare against", + }} + } + allow := sdk.GetBoolOption(opts, "allowGlueMismatch", false) + failStatus := sdk.StatusCrit + if allow { + failStatus = sdk.StatusWarn + } + var out []sdk.CheckState + for _, c := range data.Children { + if !isInBailiwick(c.NSName, data.DelegatedFQDN) { + continue + } + for _, a := range c.Addresses { + if a.UDPError != "" { + continue + } + subject := fmt.Sprintf("%s (%s)", c.NSName, a.Address) + // Extras are allowed: child may have more interfaces than the + // parent publishes; only missing parent-glue matters. + missing, _ := diffStringSets(primary.Glue[c.NSName], a.ChildGlueAddrs) + st := sdk.CheckState{Code: "delegation_glue_mismatch", Subject: subject} + if len(missing) > 0 { + st.Status = failStatus + st.Message = fmt.Sprintf("child addresses for %s differ from parent glue: missing=%v", c.NSName, missing) + st.Meta = map[string]any{"missing": missing} + } else { + st.Status = sdk.StatusOK + st.Message = "child glue matches parent" + } + out = append(out, st) + } + } + // No in-bailiwick NS means there's no glue to compare; stay silent. + return out +} + +// ───────────────────────── DNSSEC rules ───────────────────────── + +func parentHasAnyDS(views []ParentView) bool { + for _, v := range views { + if len(v.DS) > 0 { + return true + } + } + return false +} + +type dnskeyQueryRule struct{} + +func (r *dnskeyQueryRule) Name() string { return "delegation_dnskey_query" } +func (r *dnskeyQueryRule) Description() string { + return "Verifies that the delegated servers answer DNSKEY queries when the parent publishes DS records" +} +func (r *dnskeyQueryRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { + data, errState := loadData(ctx, obs, "delegation_dnskey_query_failed") + if errState != nil { + return errState + } + if !parentHasAnyDS(data.ParentViews) { + return []sdk.CheckState{{ + Status: sdk.StatusUnknown, + Code: "delegation_dnskey_query_failed", + Message: "parent has no DS records, DNSKEY probe skipped", + }} + } + var out []sdk.CheckState + for _, c := range data.Children { + for _, a := range c.Addresses { + if a.UDPError != "" { + continue + } + subject := fmt.Sprintf("%s (%s)", c.NSName, a.Address) + st := sdk.CheckState{Code: "delegation_dnskey_query_failed", Subject: subject} + if a.DNSKEYError != "" { + st.Status = sdk.StatusWarn + st.Message = fmt.Sprintf("DNSKEY query failed: %s", a.DNSKEYError) + } else { + st.Status = sdk.StatusOK + st.Message = fmt.Sprintf("%d DNSKEY record(s) returned", len(a.DNSKEYs)) + } + out = append(out, st) + } + } + if len(out) == 0 { + return []sdk.CheckState{{ + Status: sdk.StatusUnknown, + Code: "delegation_dnskey_query_failed", + Message: "no reachable child server to probe", + }} + } + return out +} + +type dnskeyMatchesDSRule struct{} + +func (r *dnskeyMatchesDSRule) Name() string { return "delegation_dnskey_matches_ds" } +func (r *dnskeyMatchesDSRule) Description() string { + return "Verifies that at least one DNSKEY served by the child hashes to one of the DS records at the parent" +} +func (r *dnskeyMatchesDSRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { + data, errState := loadData(ctx, obs, "delegation_dnskey_no_match") + if errState != nil { + return errState + } + if !parentHasAnyDS(data.ParentViews) { + return []sdk.CheckState{{ + Status: sdk.StatusUnknown, + Code: "delegation_dnskey_no_match", + Message: "parent has no DS records, DNSKEY/DS match skipped", + }} + } + var parentDS []*dns.DS + for _, v := range data.ParentViews { + if len(v.DS) > 0 { + parentDS = dsRecordsToMiekg(v.DS) + break + } + } + var out []sdk.CheckState + for _, c := range data.Children { + var keys []*dns.DNSKEY + probed := false + for _, a := range c.Addresses { + if len(a.DNSKEYs) > 0 { + probed = true + keys = append(keys, dnskeysToMiekg(a.DNSKEYs)...) + } + } + if !probed { + continue + } + st := sdk.CheckState{Code: "delegation_dnskey_no_match", Subject: c.NSName} + if dsMatchesAnyKey(parentDS, keys) { + st.Status = sdk.StatusOK + st.Message = "at least one DNSKEY matches a parent DS record" + } else { + st.Status = sdk.StatusCrit + st.Message = "no DNSKEY served by this NS matches any parent DS record" + } + out = append(out, st) + } + if len(out) == 0 { + return []sdk.CheckState{{ + Status: sdk.StatusUnknown, + Code: "delegation_dnskey_no_match", + Message: "no DNSKEY observed at any child server", + }} + } + return out +} + +type nsHasAuthoritativeAnswerRule struct{} + +func (r *nsHasAuthoritativeAnswerRule) Name() string { + return "delegation_ns_has_authoritative_answer" +} +func (r *nsHasAuthoritativeAnswerRule) Description() string { + return "Verifies that every delegated NS produced at least one authoritative answer across all its addresses" +} +func (r *nsHasAuthoritativeAnswerRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { + data, errState := loadData(ctx, obs, "delegation_no_authoritative_answer") + if errState != nil { + return errState + } + var out []sdk.CheckState + for _, c := range data.Children { + if len(c.Addresses) == 0 { + continue + } + sawAA := false + for _, a := range c.Addresses { + if a.UDPError == "" && a.Authoritative { + sawAA = true + break + } + } + st := sdk.CheckState{Code: "delegation_no_authoritative_answer", Subject: c.NSName} + if sawAA { + st.Status = sdk.StatusOK + st.Message = "at least one address answered authoritatively" + } else { + st.Status = sdk.StatusCrit + st.Message = "no address of this NS answered authoritatively" + } + out = append(out, st) + } + if len(out) == 0 { + return []sdk.CheckState{{ + Status: sdk.StatusUnknown, + Code: "delegation_no_authoritative_answer", + Message: "no delegated NS to probe", + }} + } + return out +} diff --git a/checker/rule_test.go b/checker/rule_test.go new file mode 100644 index 0000000..86327dc --- /dev/null +++ b/checker/rule_test.go @@ -0,0 +1,439 @@ +package checker + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/miekg/dns" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// fakeObs is a tiny ObservationGetter that serves a single pre-built +// DelegationData payload for the delegation key. +type fakeObs struct { + data *DelegationData + err error +} + +func (f *fakeObs) Get(_ context.Context, key sdk.ObservationKey, dest any) error { + if f.err != nil { + return f.err + } + if key != ObservationKeyDelegation { + return &errString{"unexpected key " + string(key)} + } + raw, err := json.Marshal(f.data) + if err != nil { + return err + } + return json.Unmarshal(raw, dest) +} + +func (f *fakeObs) GetRelated(_ context.Context, _ sdk.ObservationKey) ([]sdk.RelatedObservation, error) { + return nil, nil +} + +type errString struct{ s string } + +func (e *errString) Error() string { return e.s } + +// statusByCode indexes states by Code, asserting one state per code. +func statusByCode(t *testing.T, states []sdk.CheckState) map[string]sdk.CheckState { + t.Helper() + out := map[string]sdk.CheckState{} + for _, s := range states { + out[s.Code+"|"+s.Subject] = s + } + return out +} + +func evalRule(t *testing.T, r sdk.CheckRule, data *DelegationData, opts sdk.CheckerOptions) []sdk.CheckState { + t.Helper() + if opts == nil { + opts = sdk.CheckerOptions{} + } + return r.Evaluate(context.Background(), &fakeObs{data: data}, opts) +} + +func TestMinNameServersRule(t *testing.T) { + r := &minNameServersRule{} + + t.Run("warn when below default minimum", func(t *testing.T) { + states := evalRule(t, r, &DelegationData{DeclaredNS: []string{"a."}}, nil) + if len(states) != 1 || states[0].Status != sdk.StatusWarn { + t.Fatalf("want one Warn state, got %+v", states) + } + }) + t.Run("ok when at minimum", func(t *testing.T) { + states := evalRule(t, r, &DelegationData{DeclaredNS: []string{"a.", "b."}}, nil) + if len(states) != 1 || states[0].Status != sdk.StatusOK { + t.Fatalf("want one OK state, got %+v", states) + } + }) + t.Run("respects custom minimum", func(t *testing.T) { + opts := sdk.CheckerOptions{"minNameServers": float64(3)} + states := evalRule(t, r, &DelegationData{DeclaredNS: []string{"a.", "b."}}, opts) + if states[0].Status != sdk.StatusWarn { + t.Fatalf("want Warn with min=3 and 2 NS, got %+v", states) + } + }) +} + +func TestParentDiscoveredRule(t *testing.T) { + r := &parentDiscoveredRule{} + cases := []struct { + name string + data *DelegationData + want sdk.Status + }{ + {"discovery error", &DelegationData{ParentDiscoveryError: "boom"}, sdk.StatusCrit}, + {"no parent ns", &DelegationData{}, sdk.StatusCrit}, + {"ok", &DelegationData{ParentNS: []string{"1.2.3.4:53"}}, sdk.StatusOK}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + states := evalRule(t, r, tc.data, nil) + if len(states) != 1 || states[0].Status != tc.want { + t.Fatalf("want %v, got %+v", tc.want, states) + } + }) + } +} + +func TestNSMatchesDeclaredRule(t *testing.T) { + r := &nsMatchesDeclaredRule{} + data := &DelegationData{ + DelegatedFQDN: "www.example.com.", + DeclaredNS: []string{"ns1.example.net.", "ns2.example.net."}, + ParentViews: []ParentView{ + {Server: "p1:53", NS: []string{"ns1.example.net.", "ns2.example.net."}}, // match + {Server: "p2:53", NS: []string{"ns1.example.net.", "ns3.example.net."}}, // mismatch + {Server: "p3:53", UDPNSError: "timeout"}, // skipped + }, + } + states := evalRule(t, r, data, nil) + idx := statusByCode(t, states) + if s := idx["delegation_ns_mismatch|p1:53"]; s.Status != sdk.StatusOK { + t.Errorf("p1: want OK, got %+v", s) + } + if s := idx["delegation_ns_mismatch|p2:53"]; s.Status != sdk.StatusCrit { + t.Errorf("p2: want Crit, got %+v", s) + } + if _, ok := idx["delegation_ns_mismatch|p3:53"]; ok { + t.Errorf("p3 should be skipped, got %+v", idx) + } +} + +func TestInBailiwickGlueRule(t *testing.T) { + r := &inBailiwickGlueRule{} + data := &DelegationData{ + DelegatedFQDN: "example.com.", + ParentViews: []ParentView{{ + Server: "p:53", + NS: []string{"ns1.example.com.", "ns2.elsewhere.net."}, + Glue: map[string][]string{ + "ns1.example.com.": {"192.0.2.1"}, + }, + }}, + } + states := evalRule(t, r, data, nil) + + var sawOK, sawMissing, sawOOB bool + for _, s := range states { + switch { + case strings.HasPrefix(s.Subject, "ns1.example.com."): + if s.Status == sdk.StatusOK { + sawOK = true + } + case strings.HasPrefix(s.Subject, "ns2.elsewhere.net."): + sawOOB = true // out-of-bailiwick: rule must not emit a state for it + } + if s.Status == sdk.StatusCrit { + sawMissing = true + } + } + if !sawOK { + t.Error("expected OK state for in-bailiwick NS with glue") + } + if sawMissing { + t.Error("did not expect Crit (no in-bailiwick NS is missing glue)") + } + if sawOOB { + t.Error("out-of-bailiwick NS must be ignored by inBailiwickGlueRule") + } +} + +func TestUnnecessaryGlueRule(t *testing.T) { + r := &unnecessaryGlueRule{} + data := &DelegationData{ + DelegatedFQDN: "example.com.", + ParentViews: []ParentView{{ + Server: "p:53", + NS: []string{"ns1.elsewhere.net."}, + Glue: map[string][]string{"ns1.elsewhere.net.": {"192.0.2.5"}}, + }}, + } + states := evalRule(t, r, data, nil) + if len(states) != 1 || states[0].Status != sdk.StatusWarn { + t.Fatalf("want single Warn, got %+v", states) + } +} + +func TestDSPresentAtParentRule_RequireDS(t *testing.T) { + r := &dsPresentAtParentRule{} + data := &DelegationData{ + DeclaredDS: []DSRecord{{KeyTag: 1, Algorithm: 8, DigestType: 2, Digest: "AAAA"}}, + ParentViews: []ParentView{{Server: "p:53"}}, // no DS at parent + } + t.Run("default is informational", func(t *testing.T) { + states := evalRule(t, r, data, nil) + if states[0].Status != sdk.StatusInfo { + t.Fatalf("want Info, got %+v", states) + } + }) + t.Run("requireDS escalates to Crit", func(t *testing.T) { + states := evalRule(t, r, data, sdk.CheckerOptions{"requireDS": true}) + if states[0].Status != sdk.StatusCrit { + t.Fatalf("want Crit with requireDS, got %+v", states) + } + }) +} + +func TestChildAuthoritativeRule(t *testing.T) { + r := &childAuthoritativeRule{} + data := &DelegationData{ + Children: []ChildNSView{{ + NSName: "ns1.example.com.", + Addresses: []ChildAddressView{ + {Address: "192.0.2.1", Authoritative: true}, + {Address: "192.0.2.2", Authoritative: false}, + {Address: "192.0.2.3", UDPError: "timeout"}, // skipped + }, + }}, + } + states := evalRule(t, r, data, nil) + if len(states) != 2 { + t.Fatalf("want 2 states (skip the UDP failure), got %d: %+v", len(states), states) + } + var foundCrit bool + for _, s := range states { + if s.Status == sdk.StatusCrit { + foundCrit = true + } + } + if !foundCrit { + t.Error("expected at least one Crit (the lame address)") + } +} + +func TestChildSOASerialDriftRule(t *testing.T) { + r := &childSOASerialDriftRule{} + data := &DelegationData{ + Children: []ChildNSView{{ + NSName: "ns1.example.com.", + Addresses: []ChildAddressView{ + {Address: "192.0.2.1", SOASerial: 1, SOASerialKnown: true}, + {Address: "192.0.2.2", SOASerial: 2, SOASerialKnown: true}, + }, + }, { + NSName: "ns2.example.com.", + Addresses: []ChildAddressView{ + {Address: "192.0.2.3", SOASerial: 7, SOASerialKnown: true}, + {Address: "192.0.2.4", SOASerial: 7, SOASerialKnown: true}, + }, + }}, + } + states := evalRule(t, r, data, nil) + if len(states) != 2 { + t.Fatalf("want 2 states, got %d", len(states)) + } + bySubject := map[string]sdk.Status{} + for _, s := range states { + bySubject[s.Subject] = s.Status + } + if bySubject["ns1.example.com."] != sdk.StatusWarn { + t.Errorf("ns1 drift: want Warn, got %v", bySubject["ns1.example.com."]) + } + if bySubject["ns2.example.com."] != sdk.StatusOK { + t.Errorf("ns2 agreement: want OK, got %v", bySubject["ns2.example.com."]) + } +} + +func TestChildTCPRule_OptionToggle(t *testing.T) { + r := &childTCPRule{} + data := &DelegationData{ + Children: []ChildNSView{{ + NSName: "ns1.example.com.", + Addresses: []ChildAddressView{ + {Address: "192.0.2.1", TCPError: "connection refused"}, + }, + }}, + } + t.Run("default requireTCP=true → Crit", func(t *testing.T) { + states := evalRule(t, r, data, nil) + if states[0].Status != sdk.StatusCrit { + t.Fatalf("want Crit, got %+v", states) + } + }) + t.Run("requireTCP=false → Warn", func(t *testing.T) { + states := evalRule(t, r, data, sdk.CheckerOptions{"requireTCP": false}) + if states[0].Status != sdk.StatusWarn { + t.Fatalf("want Warn, got %+v", states) + } + }) +} + +func TestChildGlueMatchesParentRule(t *testing.T) { + r := &childGlueMatchesParentRule{} + data := &DelegationData{ + DelegatedFQDN: "example.com.", + ParentViews: []ParentView{{ + Server: "p:53", + NS: []string{"ns1.example.com."}, + Glue: map[string][]string{"ns1.example.com.": {"192.0.2.1", "192.0.2.2"}}, + }}, + Children: []ChildNSView{{ + NSName: "ns1.example.com.", + Addresses: []ChildAddressView{ + {Address: "192.0.2.1", ChildGlueAddrs: []string{"192.0.2.1"}}, // missing .2 → mismatch + }, + }}, + } + t.Run("default → Crit", func(t *testing.T) { + states := evalRule(t, r, data, nil) + if states[0].Status != sdk.StatusCrit { + t.Fatalf("want Crit, got %+v", states) + } + }) + t.Run("allowGlueMismatch → Warn", func(t *testing.T) { + states := evalRule(t, r, data, sdk.CheckerOptions{"allowGlueMismatch": true}) + if states[0].Status != sdk.StatusWarn { + t.Fatalf("want Warn, got %+v", states) + } + }) +} + +func TestNSHasAuthoritativeAnswerRule(t *testing.T) { + r := &nsHasAuthoritativeAnswerRule{} + data := &DelegationData{ + Children: []ChildNSView{ + { + NSName: "ok.example.com.", + Addresses: []ChildAddressView{ + {Address: "192.0.2.1", Authoritative: false}, + {Address: "192.0.2.2", Authoritative: true}, + }, + }, + { + NSName: "lame.example.com.", + Addresses: []ChildAddressView{ + {Address: "192.0.2.3", Authoritative: false}, + }, + }, + }, + } + states := evalRule(t, r, data, nil) + bySubject := map[string]sdk.Status{} + for _, s := range states { + bySubject[s.Subject] = s.Status + } + if bySubject["ok.example.com."] != sdk.StatusOK { + t.Errorf("ok.example.com.: want OK, got %v", bySubject["ok.example.com."]) + } + if bySubject["lame.example.com."] != sdk.StatusCrit { + t.Errorf("lame.example.com.: want Crit, got %v", bySubject["lame.example.com."]) + } +} + +func TestDNSKEYMatchesDSRule_Match(t *testing.T) { + // Build a key, derive its DS, and verify the rule passes when child serves + // that key and parent serves that DS. + key := &dns.DNSKEY{ + Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeDNSKEY, Class: dns.ClassINET}, + Flags: 257, + Protocol: 3, + Algorithm: dns.RSASHA256, + PublicKey: "AwEAAcMnWBKLuvG/LwnPVykcmpvnntwxfshHlHRhlY0F3oz8AMcuF8gw" + + "2Ge56vG9oqVxTzHl4Ss2dEqCQOjFlOVo+pa3JwIO1lUzbQ==", + } + ds := key.ToDS(dns.SHA256) + if ds == nil { + t.Fatal("derive DS") + } + + data := &DelegationData{ + ParentViews: []ParentView{{Server: "p:53", DS: []DSRecord{NewDSRecord(ds)}}}, + Children: []ChildNSView{{ + NSName: "ns1.example.com.", + Addresses: []ChildAddressView{ + {Address: "192.0.2.1", DNSKEYs: []DNSKEYRecord{NewDNSKEYRecord(key)}}, + }, + }}, + } + r := &dnskeyMatchesDSRule{} + states := evalRule(t, r, data, nil) + if len(states) != 1 || states[0].Status != sdk.StatusOK { + t.Fatalf("want OK match, got %+v", states) + } +} + +func TestDNSKEYMatchesDSRule_NoMatch(t *testing.T) { + key := &dns.DNSKEY{ + Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeDNSKEY, Class: dns.ClassINET}, + Flags: 257, Protocol: 3, Algorithm: dns.RSASHA256, + PublicKey: "AwEAAcMnWBKLuvG/LwnPVykcmpvnntwxfshHlHRhlY0F3oz8AMcuF8gw" + + "2Ge56vG9oqVxTzHl4Ss2dEqCQOjFlOVo+pa3JwIO1lUzbQ==", + } + bogus := &dns.DS{KeyTag: 1, Algorithm: 8, DigestType: 2, Digest: "00"} + data := &DelegationData{ + ParentViews: []ParentView{{Server: "p:53", DS: []DSRecord{NewDSRecord(bogus)}}}, + Children: []ChildNSView{{ + NSName: "ns1.example.com.", + Addresses: []ChildAddressView{ + {Address: "192.0.2.1", DNSKEYs: []DNSKEYRecord{NewDNSKEYRecord(key)}}, + }, + }}, + } + states := evalRule(t, (&dnskeyMatchesDSRule{}), data, nil) + if len(states) != 1 || states[0].Status != sdk.StatusCrit { + t.Fatalf("want Crit, got %+v", states) + } +} + +func TestRulesReturnsAllRules(t *testing.T) { + rules := Rules() + if len(rules) == 0 { + t.Fatal("expected at least one rule") + } + // Every rule must have a non-empty name and description, and must be + // safely evaluable against an empty DelegationData (no panics). + seen := map[string]bool{} + for _, r := range rules { + if r.Name() == "" { + t.Errorf("rule %T has empty name", r) + } + if r.Description() == "" { + t.Errorf("rule %s has empty description", r.Name()) + } + if seen[r.Name()] { + t.Errorf("duplicate rule name: %s", r.Name()) + } + seen[r.Name()] = true + + states := r.Evaluate(context.Background(), &fakeObs{data: &DelegationData{}}, sdk.CheckerOptions{}) + if len(states) == 0 { + t.Errorf("rule %s returned no states for empty data", r.Name()) + } + } +} + +func TestLoadDataPropagatesError(t *testing.T) { + r := &minNameServersRule{} + states := r.Evaluate(context.Background(), &fakeObs{err: &errString{"boom"}}, sdk.CheckerOptions{}) + if len(states) != 1 || states[0].Status != sdk.StatusError { + t.Fatalf("want single Error state, got %+v", states) + } +} diff --git a/checker/types.go b/checker/types.go new file mode 100644 index 0000000..9037cce --- /dev/null +++ b/checker/types.go @@ -0,0 +1,143 @@ +package checker + +import ( + "encoding/json" + + "github.com/miekg/dns" +) + +const ObservationKeyDelegation = "delegation" + +// DelegationData is the raw, judgment-free observation produced by Collect. +// Severity classification belongs to the rules, not to the data. +type DelegationData struct { + DelegatedFQDN string `json:"delegated_fqdn"` + ParentZone string `json:"parent_zone"` + + // DeclaredNS/DeclaredDS come from the service definition, + // lowercased and FQDN-normalized for direct comparison. + DeclaredNS []string `json:"declared_ns,omitempty"` + DeclaredDS []DSRecord `json:"declared_ds,omitempty"` + + ParentDiscoveryError string `json:"parent_discovery_error,omitempty"` + ParentNS []string `json:"parent_ns,omitempty"` + ParentViews []ParentView `json:"parent_views,omitempty"` + + // Children is seeded from the first successful parent view only. + Children []ChildNSView `json:"children,omitempty"` +} + +type ParentView struct { + Server string `json:"server"` + UDPNSError string `json:"udp_ns_error,omitempty"` + TCPNSError string `json:"tcp_ns_error,omitempty"` + NS []string `json:"ns,omitempty"` + Glue map[string][]string `json:"glue,omitempty"` + DSQueryError string `json:"ds_query_error,omitempty"` + DS []DSRecord `json:"ds,omitempty"` + DSRRSIGs []DSRRSIGObservation `json:"ds_rrsigs,omitempty"` +} + +type ChildNSView struct { + NSName string `json:"ns_name"` + ResolveError string `json:"resolve_error,omitempty"` + Addresses []ChildAddressView `json:"addresses,omitempty"` +} + +type ChildAddressView struct { + Address string `json:"address"` + Server string `json:"server"` + UDPError string `json:"udp_error,omitempty"` + Authoritative bool `json:"authoritative"` + SOASerial uint32 `json:"soa_serial,omitempty"` + SOASerialKnown bool `json:"soa_serial_known,omitempty"` + TCPError string `json:"tcp_error,omitempty"` + ChildNS []string `json:"child_ns,omitempty"` + ChildNSError string `json:"child_ns_error,omitempty"` + ChildGlueAddrs []string `json:"child_glue_addrs,omitempty"` + DNSKEYError string `json:"dnskey_error,omitempty"` + DNSKEYs []DNSKEYRecord `json:"dnskeys,omitempty"` +} + +// DSRecord keeps both the rendered text (for humans) and the structured +// fields (for direct comparison). +type DSRecord struct { + Text string `json:"text"` + KeyTag uint16 `json:"keytag"` + Algorithm uint8 `json:"algorithm"` + DigestType uint8 `json:"digest_type"` + Digest string `json:"digest"` +} + +func (d DSRecord) ToMiekg() *dns.DS { + return &dns.DS{ + KeyTag: d.KeyTag, + Algorithm: d.Algorithm, + DigestType: d.DigestType, + Digest: d.Digest, + } +} + +func NewDSRecord(d *dns.DS) DSRecord { + return DSRecord{ + Text: d.String(), + KeyTag: d.KeyTag, + Algorithm: d.Algorithm, + DigestType: d.DigestType, + Digest: d.Digest, + } +} + +// DNSKEYRecord keeps the fields needed to recompute DS digests. +type DNSKEYRecord struct { + Name string `json:"name"` + Flags uint16 `json:"flags"` + Protocol uint8 `json:"protocol"` + Algorithm uint8 `json:"algorithm"` + PublicKey string `json:"public_key"` +} + +// ToMiekg restores miekg form so k.ToDS is callable. +func (k DNSKEYRecord) ToMiekg() *dns.DNSKEY { + name := k.Name + if name == "" { + name = "." + } + return &dns.DNSKEY{ + Hdr: dns.RR_Header{Name: dns.Fqdn(name), Rrtype: dns.TypeDNSKEY, Class: dns.ClassINET}, + Flags: k.Flags, + Protocol: k.Protocol, + Algorithm: k.Algorithm, + PublicKey: k.PublicKey, + } +} + +func NewDNSKEYRecord(k *dns.DNSKEY) DNSKEYRecord { + return DNSKEYRecord{ + Name: k.Hdr.Name, + Flags: k.Flags, + Protocol: k.Protocol, + Algorithm: k.Algorithm, + PublicKey: k.PublicKey, + } +} + +// DSRRSIGObservation: rules judge validity, not Collect. +type DSRRSIGObservation struct { + Inception uint32 `json:"inception"` + Expiration uint32 `json:"expiration"` +} + +// delegationService mirrors abstract.Delegation locally so this checker +// avoids importing the (heavy) happyDomain server module. +type delegationService struct { + NameServers []*dns.NS `json:"ns"` + DS []*dns.DS `json:"ds"` +} + +// serviceMessage mirrors happyDomain's envelope; only the embedded JSON +// is used downstream. +type serviceMessage struct { + Type string `json:"_svctype"` + Service json.RawMessage `json:"Service"` +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a589c05 --- /dev/null +++ b/go.mod @@ -0,0 +1,16 @@ +module git.happydns.org/checker-delegation + +go 1.25.0 + +require ( + git.happydns.org/checker-sdk-go v1.4.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..fc3939b --- /dev/null +++ b/go.sum @@ -0,0 +1,16 @@ +git.happydns.org/checker-sdk-go v1.4.0 h1:sO8EnF3suhNgYLRsbmCZWJOymH/oNMrOUqj3FEzJArs= +git.happydns.org/checker-sdk-go v1.4.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..26874d4 --- /dev/null +++ b/main.go @@ -0,0 +1,25 @@ +package main + +import ( + "flag" + "log" + + delegation "git.happydns.org/checker-delegation/checker" + "git.happydns.org/checker-sdk-go/checker/server" +) + +var listenAddr = flag.String("listen", ":8080", "HTTP listen address") + +// Version is overridden at link time: -ldflags "-X main.Version=1.2.3". +var Version = "custom-build" + +func main() { + flag.Parse() + + delegation.Version = Version + + srv := server.New(delegation.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..9678e3c --- /dev/null +++ b/plugin/plugin.go @@ -0,0 +1,17 @@ +// Command plugin is the happyDomain Go-plugin entrypoint, loaded at runtime. +package main + +import ( + delegation "git.happydns.org/checker-delegation/checker" + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// Version is overridden at link time: -ldflags "-X main.Version=1.2.3". +var Version = "custom-build" + +// NewCheckerPlugin is the symbol happyDomain resolves on plugin load. +func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) { + delegation.Version = Version + prvd := delegation.Provider() + return prvd.(sdk.CheckerDefinitionProvider).Definition(), prvd, nil +}