Initial commit

This commit is contained in:
nemunaire 2026-04-08 04:18:58 +07:00
commit 57148a56fa
15 changed files with 1418 additions and 0 deletions

517
checker/collect.go Normal file
View file

@ -0,0 +1,517 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
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
}

104
checker/definition.go Normal file
View file

@ -0,0 +1,104 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
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,
},
}
}

317
checker/dns.go Normal file
View file

@ -0,0 +1,317 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
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"
}

74
checker/evaluate.go Normal file
View file

@ -0,0 +1,74 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
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,
},
}
}

43
checker/provider.go Normal file
View file

@ -0,0 +1,43 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
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()
}

67
checker/rule.go Normal file
View file

@ -0,0 +1,67 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
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)
}

110
checker/types.go Normal file
View file

@ -0,0 +1,110 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
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"`
}