The SDK split the HTTP server scaffolding into the new checker-sdk-go/checker/server subpackage and CheckRule.Evaluate now returns []CheckState. Update main.go to import server and call server.New, switch the rule and the package-level Evaluate helper to the new slice return type, and isolate the interactive form code behind the standalone build tag so plugin/builtin builds skip net/http and html/template entirely.
496 lines
14 KiB
Go
496 lines
14 KiB
Go
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
|
|
}
|