checker-resolver-propagation/checker/zone.go
Pierre-Olivier Mercier 7d23348098
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
Derive probed record types from the working zone
Stop blindly probing a fixed list (which always included CAA): read the
auto-filled zone and only probe the RR types each owner actually has,
keeping SOA/NS at the apex. The recordTypes option still works as an
explicit override; missing zone falls back to the legacy default.
2026-05-25 18:30:38 +08:00

188 lines
5.2 KiB
Go

package checker
import (
"encoding/json"
"sort"
"strings"
"github.com/miekg/dns"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// rawZone is the minimal slice of happyDomain's Zone JSON we consume to
// derive the RR types actually present at each owner. It mirrors the
// shape used by sibling checkers (see checker-legacy-records).
type rawZone struct {
DomainName string `json:"domain_name,omitempty"`
Services map[string][]rawService `json:"services"`
}
type rawService struct {
Type string `json:"_svctype"`
Domain string `json:"_domain"`
Service json.RawMessage `json:"Service"`
}
// fallbackQTypes is the legacy default applied when no zone is available
// and the user did not set recordTypes explicitly.
var fallbackQTypes = []uint16{
dns.TypeSOA, dns.TypeNS, dns.TypeA, dns.TypeAAAA,
dns.TypeMX, dns.TypeTXT, dns.TypeCAA,
}
// resolveQTypes returns the RR types to probe at each owner name plus the
// union across all owners (for reporting/metrics).
//
// Precedence:
// 1. Explicit "recordTypes" option → apply that list to every owner.
// 2. Auto-filled "zone" option → derive per-owner types from the zone's
// services. The apex always carries SOA+NS even if the zone payload
// omits them. Owners with no derivable types fall back to A,AAAA so
// the probe still surfaces NXDOMAIN drift for user-requested
// subdomains that are not present in the zone.
// 3. Neither → use the legacy default at every owner.
func resolveQTypes(opts sdk.CheckerOptions, recordTypesOpt, apex string, names []string) (map[string][]uint16, []uint16, error) {
if recordTypesOpt != "" {
qts := parseQTypes(recordTypesOpt)
if len(qts) == 0 {
return nil, nil, &invalidTypesError{raw: recordTypesOpt}
}
return uniformOwnerQTypes(names, qts), qts, nil
}
zone, _ := readWorkingZone(opts)
if zone == nil {
return uniformOwnerQTypes(names, fallbackQTypes), append([]uint16(nil), fallbackQTypes...), nil
}
owner := map[string]map[uint16]bool{}
for _, n := range names {
owner[n] = map[uint16]bool{}
}
for sub, services := range zone.Services {
full := joinSubdomain(sub, apex)
set, ok := owner[full]
if !ok {
continue
}
for _, svc := range services {
for _, qt := range typesFromService(svc) {
set[qt] = true
}
}
}
// SOA + NS at apex are foundational; the rules depend on them.
apexLower := strings.ToLower(dns.Fqdn(apex))
if set, ok := owner[apexLower]; ok {
set[dns.TypeSOA] = true
set[dns.TypeNS] = true
}
out := make(map[string][]uint16, len(names))
unionSet := map[uint16]bool{}
for _, n := range names {
set := owner[n]
if len(set) == 0 {
// Owner present in the probe list but unknown to the zone:
// keep a minimal probe so a missing-record finding can fire.
set = map[uint16]bool{dns.TypeA: true, dns.TypeAAAA: true}
}
qts := sortedTypes(set)
out[n] = qts
for _, qt := range qts {
unionSet[qt] = true
}
}
return out, sortedTypes(unionSet), nil
}
func uniformOwnerQTypes(names []string, qts []uint16) map[string][]uint16 {
out := make(map[string][]uint16, len(names))
for _, n := range names {
out[n] = qts
}
return out
}
func sortedTypes(set map[uint16]bool) []uint16 {
out := make([]uint16, 0, len(set))
for q := range set {
out = append(out, q)
}
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
return out
}
// readWorkingZone parses the "zone" auto-fill option. The host may pass
// the value either as a native struct (in-process plugin) or as a JSON
// object (HTTP path); we round-trip through JSON in both cases for a
// single decoding path. A missing zone is not an error — standalone /
// HTTP callers may simply not provide one.
func readWorkingZone(opts sdk.CheckerOptions) (*rawZone, error) {
v, ok := opts["zone"]
if !ok || v == nil {
return nil, nil
}
raw, err := json.Marshal(v)
if err != nil {
return nil, err
}
z := &rawZone{}
if err := json.Unmarshal(raw, z); err != nil {
return nil, err
}
return z, nil
}
// typesFromService extracts every RR type referenced by a service body.
// happyDomain service envelopes are opaque to us (the registry is in the
// host), so we scan the JSON for any nested "Rrtype": <number> field —
// every dns.RR_Header instance carries one, which catches MX, CAA,
// orphan, CNAME, SRV, … without needing a per-service decoder.
func typesFromService(svc rawService) []uint16 {
if len(svc.Service) == 0 {
return nil
}
var v any
if err := json.Unmarshal(svc.Service, &v); err != nil {
return nil
}
seen := map[uint16]bool{}
collectRrtypes(v, seen)
if len(seen) == 0 {
return nil
}
out := make([]uint16, 0, len(seen))
for q := range seen {
out = append(out, q)
}
return out
}
func collectRrtypes(v any, out map[uint16]bool) {
switch x := v.(type) {
case map[string]any:
for k, vv := range x {
if k == "Rrtype" {
if n, ok := vv.(float64); ok && n > 0 && n < 65536 {
out[uint16(n)] = true
}
continue
}
collectRrtypes(vv, out)
}
case []any:
for _, vv := range x {
collectRrtypes(vv, out)
}
}
}
type invalidTypesError struct{ raw string }
func (e *invalidTypesError) Error() string {
return "no valid record types in \"" + e.raw + "\""
}