Initial commit

This commit is contained in:
nemunaire 2026-04-08 04:18:58 +07:00
commit 00de0f9780
16 changed files with 1351 additions and 0 deletions

2
.gitignore vendored Normal file
View file

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

14
Dockerfile Normal file
View file

@ -0,0 +1,14 @@
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 -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-delegation .
FROM scratch
COPY --from=builder /checker-delegation /checker-delegation
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.

25
Makefile Normal file
View file

@ -0,0 +1,25 @@
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 clean
all: $(CHECKER_NAME)
$(CHECKER_NAME): $(CHECKER_SOURCES)
go build -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) .
clean:
rm -f $(CHECKER_NAME) $(CHECKER_NAME).so

122
README.md Normal file
View file

@ -0,0 +1,122 @@
# 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.
## 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.

496
checker/collect.go Normal file
View file

@ -0,0 +1,496 @@
package checker
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"time"
"github.com/miekg/dns"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Collect runs the delegation testsuite and returns a *DelegationData
// populated with findings.
//
// The collector resolves the parent zone's authoritative servers, asks each
// of them for the delegation of the target FQDN, then turns around and
// queries every delegated server using ONLY the NS names + glue learned
// from the parent. The child zone is never used 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 subdomain == "" {
return nil, fmt.Errorf("missing 'subdomain' option")
}
if parentZone == "" {
return nil, fmt.Errorf("missing 'domain_name' option")
}
delegatedFQDN := dns.Fqdn(strings.TrimSuffix(subdomain, ".") + "." + strings.TrimSuffix(parentZone, ".") + ".")
data := &DelegationData{
DelegatedFQDN: delegatedFQDN,
ParentZone: dns.Fqdn(parentZone),
}
requireDS := sdk.GetBoolOption(opts, "requireDS", false)
requireTCP := sdk.GetBoolOption(opts, "requireTCP", true)
minNS := sdk.GetIntOption(opts, "minNameServers", 2)
allowGlueMismatch := sdk.GetBoolOption(opts, "allowGlueMismatch", false)
// Declared NS / DS from the service.
declaredNS := normalizeNSList(svc.NameServers)
if len(declaredNS) < minNS {
data.Findings = append(data.Findings, DelegationFinding{
Code: "delegation_too_few_ns",
Severity: SeverityWarn,
Message: fmt.Sprintf("only %d name server(s) declared, RFC 1034 recommends at least %d", len(declaredNS), minNS),
})
}
// Resolve parent's authoritative servers.
_, parentServers, err := findParentZone(ctx, delegatedFQDN, parentZone)
if err != nil {
data.Findings = append(data.Findings, DelegationFinding{
Code: "delegation_no_parent_ns",
Severity: SeverityCrit,
Message: err.Error(),
})
return data, nil
}
data.ParentNS = parentServers
// Phase A: query every parent server.
type parentView struct {
server string
ns []string
glue map[string][]string
ds []*dns.DS
}
var views []parentView
for _, ps := range parentServers {
ns, glue, _, qerr := queryDelegation(ctx, ps, delegatedFQDN)
if qerr != nil {
data.Findings = append(data.Findings, DelegationFinding{
Code: "delegation_parent_query_failed",
Severity: SeverityCrit,
Message: fmt.Sprintf("parent NS query failed: %v", qerr),
Server: ps,
})
continue
}
if len(ns) == 0 {
data.Findings = append(data.Findings, DelegationFinding{
Code: "delegation_no_parent_ns",
Severity: SeverityCrit,
Message: "parent returned an empty NS RRset",
Server: ps,
})
continue
}
// TCP reachability of the parent for the same query.
if _, _, _, terr := queryDelegationTCP(ctx, ps, delegatedFQDN); terr != nil {
sev := SeverityCrit
if !requireTCP {
sev = SeverityWarn
}
data.Findings = append(data.Findings, DelegationFinding{
Code: "delegation_parent_tcp_failed",
Severity: sev,
Message: fmt.Sprintf("parent NS query over TCP failed: %v", terr),
Server: ps,
})
}
// Compare NS to the declared list.
missing, extra := diffStringSets(declaredNS, ns)
if len(missing) > 0 || len(extra) > 0 {
data.Findings = append(data.Findings, DelegationFinding{
Code: "delegation_ns_mismatch",
Severity: SeverityCrit,
Message: fmt.Sprintf("NS RRset at parent does not match declared service: missing=%v extra=%v", missing, extra),
Server: ps,
})
}
// Glue sanity: in-bailiwick NS must have glue, out-of-bailiwick NS must not.
for _, n := range ns {
inBailiwick := strings.HasSuffix(n, "."+delegatedFQDN) || strings.HasSuffix(n, delegatedFQDN)
if inBailiwick {
if len(glue[n]) == 0 {
data.Findings = append(data.Findings, DelegationFinding{
Code: "delegation_missing_glue",
Severity: SeverityCrit,
Message: fmt.Sprintf("in-bailiwick NS %s has no glue", n),
Server: ps,
})
}
} else {
if len(glue[n]) > 0 {
data.Findings = append(data.Findings, DelegationFinding{
Code: "delegation_unnecessary_glue",
Severity: SeverityWarn,
Message: fmt.Sprintf("out-of-bailiwick NS %s has glue records, which the parent should not return", n),
Server: ps,
})
}
}
}
// DS at parent.
ds, sigs, dserr := queryDS(ctx, ps, delegatedFQDN)
if dserr != nil {
data.Findings = append(data.Findings, DelegationFinding{
Code: "delegation_ds_query_failed",
Severity: SeverityWarn,
Message: fmt.Sprintf("DS query failed: %v", dserr),
Server: ps,
})
} else {
// Compare DS with declared service DS.
declaredDS := svc.DS
if len(declaredDS) > 0 || len(ds) > 0 {
dsMissing, dsExtra := diffDS(declaredDS, ds)
if len(dsMissing) > 0 || len(dsExtra) > 0 {
sev := SeverityCrit
if len(declaredDS) == 0 {
// Service does not declare any DS but parent has some — warn only.
sev = SeverityWarn
}
data.Findings = append(data.Findings, DelegationFinding{
Code: "delegation_ds_mismatch",
Severity: sev,
Message: fmt.Sprintf("DS RRset at parent does not match declared service: missing=%d extra=%d", len(dsMissing), len(dsExtra)),
Server: ps,
})
}
}
if len(declaredDS) > 0 && len(ds) == 0 {
sev := SeverityInfo
if requireDS {
sev = SeverityCrit
}
data.Findings = append(data.Findings, DelegationFinding{
Code: "delegation_ds_missing",
Severity: sev,
Message: "service declares DS records but parent serves none",
Server: ps,
})
}
// Validate DS RRSIG validity period if a signature is present.
for _, sig := range sigs {
if !sig.ValidityPeriod(time.Now()) {
data.Findings = append(data.Findings, DelegationFinding{
Code: "delegation_ds_rrsig_invalid",
Severity: SeverityCrit,
Message: fmt.Sprintf("DS RRSIG: %s", validityWindow(sig)),
Server: ps,
})
}
}
if len(ds) > 0 {
dsTexts := make([]string, len(ds))
for i, d := range ds {
dsTexts[i] = d.String()
}
data.ParentDS = dsTexts
}
}
views = append(views, parentView{server: ps, ns: ns, glue: glue, ds: ds})
}
if len(views) == 0 {
// All parent servers failed; no point in continuing.
return data, nil
}
// Pick the first successful parent view as the source of truth for
// Phase B. We rely on the per-parent NS_mismatch findings already
// emitted above to flag inconsistencies between parents.
parent := views[0]
data.AdvertisedNS = parent.ns
data.AdvertisedGlue = parent.glue
// Phase B: query each child name server using only parent-supplied data.
data.ChildSerials = map[string]uint32{}
for _, nsName := range parent.ns {
addrs := parent.glue[nsName]
if len(addrs) == 0 {
// Out-of-bailiwick: resolve via the system resolver.
resolved, rerr := resolveHost(ctx, nsName)
if rerr != nil {
data.Findings = append(data.Findings, DelegationFinding{
Code: "delegation_ns_unresolvable",
Severity: SeverityCrit,
Message: fmt.Sprintf("cannot resolve NS %s: %v", nsName, rerr),
Server: nsName,
})
continue
}
addrs = resolved
}
var lastSerial uint32
var sawAA bool
for _, addr := range addrs {
srv := hostPort(addr, "53")
// UDP reachability + AA check.
soa, aa, qerr := querySOA(ctx, "", srv, delegatedFQDN)
if qerr != nil {
data.Findings = append(data.Findings, DelegationFinding{
Code: "delegation_unreachable",
Severity: SeverityCrit,
Message: fmt.Sprintf("UDP SOA query failed at %s (%s): %v", nsName, addr, qerr),
Server: srv,
})
continue
}
if !aa {
data.Findings = append(data.Findings, DelegationFinding{
Code: "delegation_lame",
Severity: SeverityCrit,
Message: fmt.Sprintf("server %s (%s) is not authoritative for %s", nsName, addr, delegatedFQDN),
Server: srv,
})
continue
}
sawAA = true
if soa != nil {
if lastSerial != 0 && lastSerial != soa.Serial {
data.Findings = append(data.Findings, DelegationFinding{
Code: "delegation_soa_serial_drift",
Severity: SeverityWarn,
Message: fmt.Sprintf("SOA serial drift on %s: %d vs %d", nsName, lastSerial, soa.Serial),
Server: srv,
})
}
lastSerial = soa.Serial
data.ChildSerials[srv] = soa.Serial
}
// TCP reachability.
if _, _, terr := querySOA(ctx, "tcp", srv, delegatedFQDN); terr != nil {
sev := SeverityCrit
if !requireTCP {
sev = SeverityWarn
}
data.Findings = append(data.Findings, DelegationFinding{
Code: "delegation_tcp_failed",
Severity: sev,
Message: fmt.Sprintf("TCP SOA query failed at %s (%s): %v", nsName, addr, terr),
Server: srv,
})
}
// NS RRset agreement with parent.
childNS, nerr := queryNSAt(ctx, srv, delegatedFQDN)
if nerr == nil {
missing, extra := diffStringSets(parent.ns, childNS)
if len(missing) > 0 || len(extra) > 0 {
data.Findings = append(data.Findings, DelegationFinding{
Code: "delegation_ns_drift",
Severity: SeverityWarn,
Message: fmt.Sprintf("child NS RRset differs from parent: missing=%v extra=%v", missing, extra),
Server: srv,
})
}
}
// In-bailiwick glue agreement.
if isInBailiwick(nsName, delegatedFQDN) {
childAddrs, _ := queryAddrsAt(ctx, srv, nsName)
missing, _ := diffStringSets(parent.glue[nsName], childAddrs)
if len(missing) > 0 {
sev := SeverityCrit
if allowGlueMismatch {
sev = SeverityWarn
}
data.Findings = append(data.Findings, DelegationFinding{
Code: "delegation_glue_mismatch",
Severity: sev,
Message: fmt.Sprintf("addresses served by child for %s differ from parent glue: missing=%v", nsName, missing),
Server: srv,
})
}
}
// DNSKEY hand-off, only if the parent has DS records.
if len(parent.ds) > 0 {
keys, kerr := queryDNSKEY(ctx, srv, delegatedFQDN)
if kerr != nil {
data.Findings = append(data.Findings, DelegationFinding{
Code: "delegation_dnskey_query_failed",
Severity: SeverityWarn,
Message: fmt.Sprintf("DNSKEY query failed at %s: %v", nsName, kerr),
Server: srv,
})
} else if !dsMatchesAnyKey(parent.ds, keys) {
data.Findings = append(data.Findings, DelegationFinding{
Code: "delegation_dnskey_no_match",
Severity: SeverityCrit,
Message: fmt.Sprintf("none of the DNSKEY records served by %s match the DS published by the parent", nsName),
Server: srv,
})
}
}
}
if !sawAA && len(addrs) > 0 {
// At least record we tried.
data.Findings = append(data.Findings, DelegationFinding{
Code: "delegation_no_authoritative_answer",
Severity: SeverityCrit,
Message: fmt.Sprintf("no authoritative answer obtained from any address of %s", nsName),
Server: nsName,
})
}
}
return data, nil
}
// queryDelegationTCP is the TCP variant of queryDelegation. It is split out
// so the per-server findings keep their UDP/TCP roles distinct.
func queryDelegationTCP(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, "tcp", 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])
}
return
}
// loadService extracts the abstract.Delegation payload from the auto-filled
// "service" option. We parse it into our local minimal type so this checker
// does not have to import 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
}
// normalizeNSList lowercases and FQDN-normalizes a list of NS records.
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
}
// diffStringSets returns the elements of "want" missing from "got" and the
// elements of "got" not present in "want".
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
}
// diffDS returns the DS records present in "want" but missing from "got"
// and vice-versa.
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
}
// isInBailiwick reports whether host sits inside zone.
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)
}
// dsMatchesAnyKey reports whether at least one of the DNSKEY records hashes
// to one of the DS records.
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
}

83
checker/definition.go Normal file
View file

@ -0,0 +1,83 @@
package checker
import (
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Version is the checker version reported in CheckerDefinition.Version.
var Version = "built-in"
// Definition returns the CheckerDefinition for the delegation checker.
func 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: []sdk.CheckRule{
Rule(),
},
Interval: &sdk.CheckIntervalSpec{
Min: 5 * time.Minute,
Max: 24 * time.Hour,
Default: 1 * time.Hour,
},
}
}

296
checker/dns.go Normal file
View file

@ -0,0 +1,296 @@
package checker
import (
"context"
"fmt"
"net"
"strings"
"time"
"github.com/miekg/dns"
)
// year68 mirrors the constant from miekg/dns used to wrap RRSIG validity
// periods around 2^32 seconds (≈68 years), as in the adlin checker.
const year68 = int64(1 << 31)
// dnsTimeout is the per-query deadline used by every helper here.
const dnsTimeout = 5 * time.Second
// dnsExchange sends a single query to the given server using the requested
// transport ("" for UDP, "tcp"). The server address must already include a
// port. RecursionDesired is forced off — this checker only talks to
// authoritative servers.
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)
}
deadline, ok := ctx.Deadline()
if ok {
if d := time.Until(deadline); d > 0 && d < client.Timeout {
client.Timeout = d
}
}
r, _, err := client.Exchange(m, server)
if err != nil {
return nil, err
}
if r == nil {
return nil, fmt.Errorf("nil response from %s", server)
}
return r, nil
}
// hostPort returns "host:port", correctly bracketing IPv6 literals.
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 resolves an NS hostname to its A and AAAA addresses using the
// system resolver. It is used as a fallback when no glue is provided by the
// parent for an out-of-bailiwick NS.
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 walks up the labels of fqdn until it finds the closest
// enclosing zone (the one that has its own SOA), and returns the FQDN of
// that zone along with its authoritative server addresses (resolved from
// its NS RRset). The walk stops as soon as a SOA query at the system
// resolver returns NOERROR with an answer.
//
// If hintParent is non-empty, it is used as the assumed parent and we only
// resolve its NS — this matches happyDomain's data model where the parent
// zone is known.
func findParentZone(ctx context.Context, fqdn, hintParent string) (zone string, servers []string, err error) {
zone = dns.Fqdn(hintParent)
if zone == "" || zone == "." {
// Walk up.
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 the list of "host:53" entries for every NS of
// the given zone, as seen by the system resolver. It is used to discover the
// parent's authoritative servers.
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 queries the given parent server for the NS RRset of fqdn
// and extracts the advertised NS names plus any glue records found in the
// Additional section. The query is sent without RD; the response is the
// classical "referral" packet.
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 asks the parent server for the DS RRset of fqdn and returns the
// DS records plus any RRSIGs found in the same section.
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 asks the given authoritative server for the SOA of fqdn and
// returns the SOA record plus the AA flag from the response header.
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")
}
// queryNSAt asks the given authoritative server for the NS RRset of fqdn.
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
}
// queryAddrsAt asks an authoritative server for the A and AAAA records of
// host (typically an in-bailiwick NS hostname).
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
}
// queryDNSKEY asks the given child server for the DNSKEY RRset of fqdn.
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
}
// dsEqual returns true when two DS records refer to the same key material.
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)
}
// validityWindow returns a human-readable explanation of why a signature is
// outside its validity period, mirroring the year68 logic from the adlin
// checker.
func validityWindow(sig *dns.RRSIG) string {
utc := time.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
if ti > utc {
return "signature not yet valid"
} else if utc > te {
return "signature expired"
}
return "signature outside its validity window"
}

53
checker/evaluate.go Normal file
View file

@ -0,0 +1,53 @@
package checker
import (
"fmt"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Evaluate folds findings into a single CheckState. The status is the
// highest severity observed: any Crit makes the whole result Crit, any Warn
// makes it Warn, otherwise OK.
func Evaluate(data *DelegationData) sdk.CheckState {
status := sdk.StatusOK
var crit, warn, info int
for _, f := range data.Findings {
switch f.Severity {
case SeverityCrit:
crit++
status = sdk.StatusCrit
case SeverityWarn:
warn++
if status != sdk.StatusCrit {
status = sdk.StatusWarn
}
case SeverityInfo:
info++
if status == sdk.StatusOK {
status = sdk.StatusInfo
}
}
}
var msg string
if len(data.Findings) == 0 {
msg = fmt.Sprintf("Delegation of %s is healthy", data.DelegatedFQDN)
} else {
msg = fmt.Sprintf("Delegation of %s: %d critical, %d warning, %d info", data.DelegatedFQDN, crit, warn, info)
}
return sdk.CheckState{
Status: status,
Message: msg,
Code: "delegation_result",
Meta: map[string]any{
"findings": data.Findings,
"delegated_fqdn": data.DelegatedFQDN,
"parent_zone": data.ParentZone,
"advertised_ns": data.AdvertisedNS,
"parent_ds": data.ParentDS,
"child_serials": data.ChildSerials,
},
}
}

22
checker/provider.go Normal file
View file

@ -0,0 +1,22 @@
package checker
import (
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Provider returns a new delegation observation provider.
func Provider() sdk.ObservationProvider {
return &delegationProvider{}
}
type delegationProvider struct{}
func (p *delegationProvider) Key() sdk.ObservationKey {
return ObservationKeyDelegation
}
// Definition implements sdk.CheckerDefinitionProvider so the SDK server can
// expose /definition without an extra argument.
func (p *delegationProvider) Definition() *sdk.CheckerDefinition {
return Definition()
}

46
checker/rule.go Normal file
View file

@ -0,0 +1,46 @@
package checker
import (
"context"
"fmt"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Rule returns the delegation check rule.
func Rule() sdk.CheckRule {
return &delegationRule{}
}
type delegationRule struct{}
func (r *delegationRule) Name() string { return "delegation_check" }
func (r *delegationRule) Description() string {
return "Verifies a DNS delegation against its parent zone and the delegated name servers"
}
func (r *delegationRule) ValidateOptions(opts sdk.CheckerOptions) error {
if v, ok := opts["minNameServers"]; ok {
f, ok := v.(float64)
if !ok {
return fmt.Errorf("minNameServers must be a number")
}
if f < 1 {
return fmt.Errorf("minNameServers must be >= 1")
}
}
return nil
}
func (r *delegationRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) sdk.CheckState {
var data DelegationData
if err := obs.Get(ctx, ObservationKeyDelegation, &data); err != nil {
return sdk.CheckState{
Status: sdk.StatusError,
Message: fmt.Sprintf("Failed to get delegation data: %v", err),
Code: "delegation_error",
}
}
return Evaluate(&data)
}

89
checker/types.go Normal file
View file

@ -0,0 +1,89 @@
package checker
import (
"encoding/json"
"github.com/miekg/dns"
)
// ObservationKeyDelegation is the observation key for delegation data.
const ObservationKeyDelegation = "delegation"
// Severity classifies a finding emitted by the delegation checker.
type Severity string
const (
SeverityInfo Severity = "info"
SeverityWarn Severity = "warn"
SeverityCrit Severity = "crit"
)
// DelegationFinding describes a single observation produced while running
// the delegation testsuite.
type DelegationFinding struct {
// Code is a stable machine-readable identifier (e.g. "delegation_ns_mismatch").
Code string `json:"code"`
// Severity grades the finding.
Severity Severity `json:"severity"`
// Message is a human-readable explanation.
Message string `json:"message"`
// Server is the DNS server that exhibited the finding (parent or child),
// when applicable. Empty for findings tied to the service definition itself.
Server string `json:"server,omitempty"`
}
// DelegationData is the observation payload stored by the checker. It carries
// every finding emitted by the testsuite plus the raw observed state from the
// parent and from each delegated server.
type DelegationData struct {
// DelegatedFQDN is the FQDN of the delegated zone (subdomain + parent).
DelegatedFQDN string `json:"delegated_fqdn"`
// ParentZone is the FQDN of the parent zone that delegates DelegatedFQDN.
ParentZone string `json:"parent_zone"`
// ParentNS lists the parent zone's authoritative servers that were
// queried (FQDNs of NS records).
ParentNS []string `json:"parent_ns,omitempty"`
// AdvertisedNS holds the NS RRset returned by the parent for the
// delegated FQDN, normalized as lowercase FQDNs.
AdvertisedNS []string `json:"advertised_ns,omitempty"`
// AdvertisedGlue maps an in-bailiwick NS hostname to the glue addresses
// returned by the parent for that name.
AdvertisedGlue map[string][]string `json:"advertised_glue,omitempty"`
// ParentDS lists the DS records returned by the parent for the
// delegated FQDN, in their textual presentation form.
ParentDS []string `json:"parent_ds,omitempty"`
// ChildSerials maps an NS hostname to the SOA serial it returns for
// the delegated FQDN.
ChildSerials map[string]uint32 `json:"child_serials,omitempty"`
// Findings is the list of issues / observations produced by the run.
Findings []DelegationFinding `json:"findings"`
}
// delegationService is the minimal local mirror of happyDomain's
// `services/abstract.Delegation` type. It is duplicated on purpose so that
// this checker does not have to import the (heavy) happyDomain server module
// just to decode the service payload. github.com/miekg/dns marshals
// dns.NS / dns.DS to JSON in the same shape happyDomain uses.
type delegationService struct {
NameServers []*dns.NS `json:"ns"`
DS []*dns.DS `json:"ds"`
}
// serviceMessage is the minimal local mirror of happyDomain's ServiceMessage
// envelope. We only need the embedded service JSON; the rest of the meta
// fields are ignored.
type serviceMessage struct {
Type string `json:"_svctype"`
Domain string `json:"_domain"`
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 v0.0.1
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 v0.0.1 h1:4RxCJr73HWKxjOyU/6NJMO8lXJmH0gMLA68EzTqLbQI=
git.happydns.org/checker-sdk-go v0.0.1/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=

28
main.go Normal file
View file

@ -0,0 +1,28 @@
package main
import (
"flag"
"log"
delegation "git.happydns.org/checker-delegation/checker"
sdk "git.happydns.org/checker-sdk-go/checker"
)
var listenAddr = flag.String("listen", ":8080", "HTTP listen address")
// Version is the standalone binary's version. It defaults to "custom-build"
// and is meant to be overridden by the CI at link time:
//
// go build -ldflags "-X main.Version=1.2.3" .
var Version = "custom-build"
func main() {
flag.Parse()
delegation.Version = Version
server := sdk.NewServer(delegation.Provider())
if err := server.ListenAndServe(*listenAddr); err != nil {
log.Fatalf("server error: %v", err)
}
}

22
plugin/plugin.go Normal file
View file

@ -0,0 +1,22 @@
// Command plugin is the happyDomain plugin entrypoint for the delegation
// checker. It is built as a Go plugin (`go build -buildmode=plugin`) and
// loaded at runtime by happyDomain.
package main
import (
delegation "git.happydns.org/checker-delegation/checker"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Version is the plugin's version. It defaults to "custom-build" and is
// meant to be overridden by the CI at link time:
//
// go build -buildmode=plugin -ldflags "-X main.Version=1.2.3" -o checker-delegation.so ./plugin
var Version = "custom-build"
// NewCheckerPlugin is the symbol resolved by happyDomain when loading the
// .so file.
func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
delegation.Version = Version
return delegation.Definition(), delegation.Provider(), nil
}