Initial commit

This commit is contained in:
nemunaire 2026-04-08 04:18:58 +07:00
commit 7e0f29075e
21 changed files with 3112 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
checker-delegation
*.so

15
Dockerfile Normal file
View file

@ -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"]

21
LICENSE Normal file
View file

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

28
Makefile Normal file
View file

@ -0,0 +1,28 @@
CHECKER_NAME := checker-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

27
NOTICE Normal file
View file

@ -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

132
README.md Normal file
View file

@ -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.

218
checker/collect.go Normal file
View file

@ -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
}

82
checker/definition.go Normal file
View file

@ -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,
},
}
}

250
checker/dns.go Normal file
View file

@ -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)
}

108
checker/helpers.go Normal file
View file

@ -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
}

136
checker/helpers_test.go Normal file
View file

@ -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")
}
}

135
checker/interactive.go Normal file
View file

@ -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
}

48
checker/provider.go Normal file
View file

@ -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
}

262
checker/report.go Normal file
View file

@ -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(`<!doctype html><html><head><meta charset="utf-8">`)
b.WriteString(`<title>Delegation report</title></head><body style="font-family:sans-serif">`)
fmt.Fprintf(&b, `<h1>Delegation of %s</h1>`, html.EscapeString(strings.TrimSuffix(data.DelegatedFQDN, ".")))
if len(states) == 0 {
b.WriteString(`<p><em>No rule states were threaded into this report; rendering raw observation only.</em></p>`)
writeDataOnly(&b, &data)
b.WriteString(`</body></html>`)
return b.String(), nil
}
writeBanner(&b, states)
writeFixTheseFirst(&b, states)
writeAllStates(&b, states)
writeDataOnly(&b, &data)
b.WriteString(`</body></html>`)
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, `<p style="padding:.5em 1em;background:%s;color:#fff;display:inline-block;border-radius:4px">Overall: <strong>%s</strong></p>`,
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(`<h2>Fix these first</h2>`)
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(`<h2>All rule states</h2>`)
writeStatesTable(b, sorted)
}
func writeStatesTable(b *strings.Builder, states []sdk.CheckState) {
b.WriteString(`<table style="border-collapse:collapse" cellpadding="4" border="1">`)
b.WriteString(`<thead><tr><th>Status</th><th>Rule</th><th>Subject</th><th>Message</th></tr></thead><tbody>`)
for _, s := range states {
fmt.Fprintf(b, `<tr><td style="color:%s;font-weight:bold">%s</td><td>%s</td><td>%s</td><td>%s</td></tr>`,
statusColor(s.Status),
html.EscapeString(s.Status.String()),
html.EscapeString(s.RuleName),
html.EscapeString(s.Subject),
html.EscapeString(s.Message),
)
}
b.WriteString(`</tbody></table>`)
}
func writeDataOnly(b *strings.Builder, data *DelegationData) {
b.WriteString(`<h2>Observation</h2>`)
if data.ParentDiscoveryError != "" {
fmt.Fprintf(b, `<p><strong>Parent discovery error:</strong> %s</p>`, html.EscapeString(data.ParentDiscoveryError))
}
if len(data.DeclaredNS) > 0 {
fmt.Fprintf(b, `<p><strong>Declared NS:</strong> %s</p>`, 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, `<p><strong>Declared DS:</strong> %s</p>`, html.EscapeString(strings.Join(texts, "; ")))
}
if len(data.ParentViews) > 0 {
b.WriteString(`<h3>Parent views</h3><ul>`)
for _, v := range data.ParentViews {
fmt.Fprintf(b, `<li><strong>%s</strong>: NS=[%s], glue=%d, DS=%d`,
html.EscapeString(v.Server),
html.EscapeString(strings.Join(v.NS, ", ")),
len(v.Glue), len(v.DS))
if v.UDPNSError != "" {
fmt.Fprintf(b, `, UDP err=%s`, html.EscapeString(v.UDPNSError))
}
if v.TCPNSError != "" {
fmt.Fprintf(b, `, TCP err=%s`, html.EscapeString(v.TCPNSError))
}
if v.DSQueryError != "" {
fmt.Fprintf(b, `, DS err=%s`, html.EscapeString(v.DSQueryError))
}
b.WriteString(`</li>`)
}
b.WriteString(`</ul>`)
}
if len(data.Children) > 0 {
b.WriteString(`<h3>Delegated servers</h3><ul>`)
for _, c := range data.Children {
fmt.Fprintf(b, `<li><strong>%s</strong>`, html.EscapeString(c.NSName))
if c.ResolveError != "" {
fmt.Fprintf(b, ` (resolve err: %s)`, html.EscapeString(c.ResolveError))
}
if len(c.Addresses) > 0 {
b.WriteString(`<ul>`)
for _, a := range c.Addresses {
fmt.Fprintf(b, `<li>%s, AA=%t`, html.EscapeString(a.Address), a.Authoritative)
if a.SOASerialKnown {
fmt.Fprintf(b, `, SOA=%d`, a.SOASerial)
}
if a.UDPError != "" {
fmt.Fprintf(b, `, UDP err=%s`, html.EscapeString(a.UDPError))
}
if a.TCPError != "" {
fmt.Fprintf(b, `, TCP err=%s`, html.EscapeString(a.TCPError))
}
if a.DNSKEYError != "" {
fmt.Fprintf(b, `, DNSKEY err=%s`, html.EscapeString(a.DNSKEYError))
} else if len(a.DNSKEYs) > 0 {
fmt.Fprintf(b, `, DNSKEYs=%d`, len(a.DNSKEYs))
}
b.WriteString(`</li>`)
}
b.WriteString(`</ul>`)
}
b.WriteString(`</li>`)
}
b.WriteString(`</ul>`)
}
}

992
checker/rule.go Normal file
View file

@ -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
}

439
checker/rule_test.go Normal file
View file

@ -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)
}
}

143
checker/types.go Normal file
View file

@ -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"`
}

16
go.mod Normal file
View file

@ -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
)

16
go.sum Normal file
View file

@ -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=

25
main.go Normal file
View file

@ -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)
}
}

17
plugin/plugin.go Normal file
View file

@ -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
}