checker-authoritative-consi.../checker/collect.go
Pierre-Olivier Mercier 744a75b25d
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/tag Build is failing
checker: resolve relative NS labels using the zone origin
NS hostnames stored in happyDomain's abstract.Origin service are encoded
relative to the zone apex (e.g. "ns0" instead of "ns0.example.com.").
normalizeNSList was calling dns.Fqdn() directly, turning "ns0" into the
useless single-label "ns0." rather than the correct FQDN.

Expose domain_name in ServiceOpts so happyDomain auto-fills the zone
apex at service scope, then use sdk.JoinRelative in normalizeNSList to
absolutize relative labels.  Absolute FQDNs (trailing dot) are kept
as-is so external nameservers round-trip safely.
2026-05-16 21:37:05 +08:00

233 lines
5.4 KiB
Go

package checker
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"sync"
"github.com/miekg/dns"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Gathers raw per-NS DNS answers. No severity or pass/fail is decided here;
// rules turn the resulting ObservationData into CheckStates.
func (p *authoritativeConsistencyProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
svc, err := loadService(opts)
if err != nil {
return nil, err
}
zone, err := loadZone(opts, svc)
if err != nil {
return nil, err
}
checkEDNS := sdk.GetBoolOption(opts, "checkEDNS", true)
useParentNS := sdk.GetBoolOption(opts, "useParentNS", true)
data := &ObservationData{
Zone: dns.Fqdn(zone),
HasSOA: svc.SOA != nil,
DeclaredNS: normalizeNSList(svc.NameServers, zone),
Results: map[string]*NSResult{},
}
if svc.SOA != nil {
data.DeclaredSerial = svc.SOA.Serial
}
if useParentNS {
parentNS, perr := parentReferral(ctx, data.Zone)
if perr != nil {
data.ParentQueryError = perr.Error()
} else {
data.ParentNS = parentNS
}
}
data.Probed = unionStrings(data.DeclaredNS, data.ParentNS)
if len(data.Probed) == 0 {
return data, nil
}
// Cap fan-out: an unbounded Origin NS list would otherwise spawn one
// goroutine and a fresh batch of UDP/TCP sockets per name.
const maxConcurrentProbes = 16
sem := make(chan struct{}, maxConcurrentProbes)
var wg sync.WaitGroup
var mu sync.Mutex
for _, nsName := range data.Probed {
nsName := nsName
wg.Add(1)
sem <- struct{}{}
go func() {
defer wg.Done()
defer func() { <-sem }()
res := probeNS(ctx, data.Zone, nsName, checkEDNS)
mu.Lock()
data.Results[nsName] = res
mu.Unlock()
}()
}
wg.Wait()
return data, nil
}
// First authoritative answer wins as the canonical view of this NS;
// subsequent addresses only contribute reachability/error state. Avoids
// dual-homed servers appearing twice in the drift matrix while still
// surfacing IPv4/IPv6-specific failures.
func probeNS(ctx context.Context, zone, nsName string, checkEDNS bool) *NSResult {
res := &NSResult{Name: nsName}
addrs, err := resolveHost(ctx, nsName)
if err != nil {
res.ResolveError = err.Error()
return res
}
if len(addrs) == 0 {
res.ResolveError = "no A/AAAA records"
return res
}
res.Addresses = addrs
for _, addr := range addrs {
srv := hostPort(addr, "53")
soa, aa, rtt, qerr := querySOA(ctx, "", srv, zone)
if qerr != nil {
res.appendError("UDP %s: %v", addr, qerr)
continue
}
res.UDPReachable = true
if res.LatencyMs == 0 {
res.LatencyMs = rtt.Milliseconds()
}
if aa {
res.Authoritative = true
}
if soa != nil && res.SOA == nil {
res.SOA = soa
res.Serial = soa.Serial
}
if _, _, _, terr := querySOA(ctx, "tcp", srv, zone); terr != nil {
res.appendError("TCP %s: %v", addr, terr)
} else {
res.TCPReachable = true
}
if checkEDNS {
if eerr := probeEDNS0(ctx, srv, zone); eerr != nil {
res.appendError("EDNS0 %s: %v", addr, eerr)
} else {
res.EDNSSupported = true
}
}
if nss, nerr := queryNSAt(ctx, srv, zone); nerr == nil && len(res.NSRRset) == 0 {
sort.Strings(nss)
res.NSRRset = nss
}
}
return res
}
func loadService(opts sdk.CheckerOptions) (*originService, error) {
svc, ok := sdk.GetOption[serviceMessage](opts, "service")
if !ok {
return nil, fmt.Errorf("missing 'service' option")
}
switch svc.Type {
case "", "abstract.Origin", "abstract.NSOnlyOrigin":
default:
return nil, fmt.Errorf("service is %s, expected abstract.Origin or abstract.NSOnlyOrigin", svc.Type)
}
var d originService
if err := json.Unmarshal(svc.Service, &d); err != nil {
return nil, fmt.Errorf("decoding origin service: %w", err)
}
return &d, nil
}
// Falls back to the service's SOA owner name when domain_name is unset.
func loadZone(opts sdk.CheckerOptions, svc *originService) (string, error) {
if v, ok := sdk.GetOption[string](opts, "domain_name"); ok && v != "" {
return dns.Fqdn(v), nil
}
if svc.SOA != nil && svc.SOA.Header().Name != "" {
return dns.Fqdn(svc.SOA.Header().Name), nil
}
return "", fmt.Errorf("no zone name provided (missing 'domain_name' option and SOA header)")
}
func normalizeNSList(ns []*dns.NS, origin string) []string {
out := make([]string, 0, len(ns))
for _, n := range ns {
if n == nil {
continue
}
name := n.Ns
if !strings.HasSuffix(name, ".") {
name = sdk.JoinRelative(name, strings.TrimSuffix(origin, "."))
}
out = append(out, strings.ToLower(dns.Fqdn(name)))
}
sort.Strings(out)
return out
}
func unionStrings(a, b []string) []string {
seen := map[string]bool{}
var out []string
for _, s := range a {
if !seen[s] {
seen[s] = true
out = append(out, s)
}
}
for _, s := range b {
if !seen[s] {
seen[s] = true
out = append(out, s)
}
}
sort.Strings(out)
return out
}
func diffStringSets(want, got []string) (missing, extra []string) {
w := map[string]bool{}
for _, v := range want {
w[strings.ToLower(strings.TrimSuffix(v, "."))] = true
}
g := map[string]bool{}
for _, v := range got {
g[strings.ToLower(strings.TrimSuffix(v, "."))] = true
}
for k := range w {
if !g[k] {
missing = append(missing, k)
}
}
for k := range g {
if !w[k] {
extra = append(extra, k)
}
}
sort.Strings(missing)
sort.Strings(extra)
return
}
// RFC 1982 serial-number arithmetic (handles wraparound).
func serialLess(a, b uint32) bool {
diff := b - a
return diff != 0 && diff < (1<<31)
}