diff --git a/checker/collect.go b/checker/collect.go index b0b5fff..01077dd 100644 --- a/checker/collect.go +++ b/checker/collect.go @@ -58,12 +58,14 @@ func (p *delegationProvider) Collect(ctx context.Context, opts sdk.CheckerOption for _, ps := range parentServers { view := ParentView{Server: ps} - ns, glue, _, qerr := queryDelegation(ctx, ps, delegatedFQDN) + ns, glue, nsTTL, nsTTLKnown, qerr := queryDelegation(ctx, ps, delegatedFQDN) if qerr != nil { view.UDPNSError = qerr.Error() } else { view.NS = ns view.Glue = glue + view.NSTTL = nsTTL + view.NSTTLKnown = nsTTLKnown } if terr := queryDelegationTCP(ctx, ps, delegatedFQDN); terr != nil { @@ -97,6 +99,11 @@ func (p *delegationProvider) Collect(ctx context.Context, opts sdk.CheckerOption // Phase B: per-child observations, seeded only from parent data. for _, nsName := range primary.NS { child := ChildNSView{NSName: nsName} + + if target, cerr := queryCNAMETarget(ctx, nsName); cerr == nil && target != "" { + child.CNAMETarget = target + } + addrs := primary.Glue[nsName] if len(addrs) == 0 { // Out-of-bailiwick: no glue expected, fall back to the system resolver. diff --git a/checker/dns.go b/checker/dns.go index a5a45a3..4a2983c 100644 --- a/checker/dns.go +++ b/checker/dns.go @@ -108,15 +108,16 @@ func resolveZoneNSAddrs(ctx context.Context, zone string) ([]string, error) { // queryDelegation expects a referral response (no RD) and pulls NS + glue // from every section so misconfigured parents (NS in Answer) still parse. -func queryDelegation(ctx context.Context, parentServer, fqdn string) (ns []string, glue map[string][]string, msg *dns.Msg, err error) { +// nsTTL is the TTL of the first matching NS record (all RRset members share it). +func queryDelegation(ctx context.Context, parentServer, fqdn string) (ns []string, glue map[string][]string, nsTTL uint32, nsTTLKnown bool, 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 + msg, merr := dnsExchange(ctx, "", parentServer, q, true) + if merr != nil { + return nil, nil, 0, false, merr } if msg.Rcode != dns.RcodeSuccess { - return nil, nil, msg, fmt.Errorf("parent answered %s", dns.RcodeToString[msg.Rcode]) + return nil, nil, 0, false, fmt.Errorf("parent answered %s", dns.RcodeToString[msg.Rcode]) } glue = map[string][]string{} @@ -127,6 +128,10 @@ func queryDelegation(ctx context.Context, parentServer, fqdn string) (ns []strin case *dns.NS: if strings.EqualFold(strings.TrimSuffix(t.Header().Name, "."), strings.TrimSuffix(fqdn, ".")) { ns = append(ns, strings.ToLower(dns.Fqdn(t.Ns))) + if !nsTTLKnown { + nsTTL = t.Header().Ttl + nsTTLKnown = true + } } case *dns.A: name := strings.ToLower(dns.Fqdn(t.Header().Name)) @@ -143,6 +148,20 @@ func queryDelegation(ctx context.Context, parentServer, fqdn string) (ns []strin return } +// queryCNAMETarget returns the CNAME target if host is an alias, or empty +// string if it is not. Uses the system resolver, consistent with resolveHost. +func queryCNAMETarget(ctx context.Context, host string) (string, error) { + var resolver net.Resolver + canon, err := resolver.LookupCNAME(ctx, strings.TrimSuffix(host, ".")) + if err != nil { + return "", err + } + if strings.EqualFold(dns.Fqdn(canon), dns.Fqdn(host)) { + return "", nil + } + return strings.TrimSuffix(dns.Fqdn(canon), "."), nil +} + // queryDS uses TCP because DS+RRSIG answers commonly exceed UDP MTU. 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} diff --git a/checker/rule.go b/checker/rule.go index 95281cd..cc5cbc7 100644 --- a/checker/rule.go +++ b/checker/rule.go @@ -22,6 +22,7 @@ func Rules() []sdk.CheckRule { &parentTCPRule{}, &nsMatchesDeclaredRule{}, &parentNSConsistencyRule{}, + &nsTTLConsistencyRule{}, &inBailiwickGlueRule{}, &unnecessaryGlueRule{}, &dsQueryRule{}, @@ -30,6 +31,7 @@ func Rules() []sdk.CheckRule { &dsPresentAtParentRule{}, &dsRRSIGValidityRule{}, &nsResolvableRule{}, + &nsTargetNotCNAMERule{}, &childReachableRule{}, &childAuthoritativeRule{}, &childSOASerialDriftRule{}, @@ -532,6 +534,56 @@ func rrsigReason(sig DSRRSIGObservation, now time.Time) string { } } +type nsTTLConsistencyRule struct{} + +func (r *nsTTLConsistencyRule) Name() string { return "delegation_ns_ttl_consistency" } +func (r *nsTTLConsistencyRule) Description() string { + return "Verifies that all parent authoritative servers serve the NS RRset with the same TTL" +} +func (r *nsTTLConsistencyRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { + data, errState := loadData(ctx, obs, "delegation_ns_ttl_inconsistent") + if errState != nil { + return errState + } + type entry struct { + server string + ttl uint32 + } + var known []entry + for _, v := range data.ParentViews { + if v.NSTTLKnown { + known = append(known, entry{v.Server, v.NSTTL}) + } + } + if len(known) < 2 { + return []sdk.CheckState{{ + Status: sdk.StatusUnknown, + Code: "delegation_ns_ttl_inconsistent", + Message: "fewer than two parent servers returned an NS TTL", + }} + } + ref := known[0] + var bad []string + for _, e := range known[1:] { + if e.ttl != ref.ttl { + bad = append(bad, fmt.Sprintf("%s serves TTL %d (reference %d from %s)", e.server, e.ttl, ref.ttl, ref.server)) + } + } + if len(bad) > 0 { + return []sdk.CheckState{{ + Status: sdk.StatusWarn, + Code: "delegation_ns_ttl_inconsistent", + Message: fmt.Sprintf("NS TTL inconsistency: %s", strings.Join(bad, "; ")), + Meta: map[string]any{"reference_ttl": ref.ttl, "reference_server": ref.server}, + }} + } + return []sdk.CheckState{{ + Status: sdk.StatusOK, + Code: "delegation_ns_ttl_inconsistent", + Message: fmt.Sprintf("all parent servers agree on NS TTL (%ds)", ref.ttl), + }} +} + // ───────────────────────── child-side rules ───────────────────────── type nsResolvableRule struct{} @@ -570,6 +622,44 @@ func (r *nsResolvableRule) Evaluate(ctx context.Context, obs sdk.ObservationGett return out } +type nsTargetNotCNAMERule struct{} + +func (r *nsTargetNotCNAMERule) Name() string { return "delegation_ns_not_cname" } +func (r *nsTargetNotCNAMERule) Description() string { + return "Verifies that NS target names are not CNAME aliases (RFC 2181 §10.3)" +} +func (r *nsTargetNotCNAMERule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { + data, errState := loadData(ctx, obs, "delegation_ns_is_cname") + if errState != nil { + return errState + } + if len(data.Children) == 0 { + return []sdk.CheckState{{ + Status: sdk.StatusUnknown, + Code: "delegation_ns_is_cname", + Message: "no NS names to check", + }} + } + var out []sdk.CheckState + for _, c := range data.Children { + st := sdk.CheckState{Code: "delegation_ns_is_cname", Subject: c.NSName} + switch { + case c.CNAMETarget != "": + st.Status = sdk.StatusCrit + st.Message = fmt.Sprintf("NS target is a CNAME alias to %s (RFC 2181 §10.3 forbids this)", c.CNAMETarget) + st.Meta = map[string]any{"cname_target": c.CNAMETarget} + case c.ResolveError != "": + st.Status = sdk.StatusUnknown + st.Message = fmt.Sprintf("could not verify CNAME status: %s", c.ResolveError) + default: + st.Status = sdk.StatusOK + st.Message = "not a CNAME" + } + out = append(out, st) + } + return out +} + type childReachableRule struct{} func (r *childReachableRule) Name() string { return "delegation_child_reachable" } diff --git a/checker/types.go b/checker/types.go index 9037cce..a10e7de 100644 --- a/checker/types.go +++ b/checker/types.go @@ -32,6 +32,8 @@ type ParentView struct { UDPNSError string `json:"udp_ns_error,omitempty"` TCPNSError string `json:"tcp_ns_error,omitempty"` NS []string `json:"ns,omitempty"` + NSTTLKnown bool `json:"ns_ttl_known,omitempty"` + NSTTL uint32 `json:"ns_ttl,omitempty"` Glue map[string][]string `json:"glue,omitempty"` DSQueryError string `json:"ds_query_error,omitempty"` DS []DSRecord `json:"ds,omitempty"` @@ -40,6 +42,7 @@ type ParentView struct { type ChildNSView struct { NSName string `json:"ns_name"` + CNAMETarget string `json:"cname_target,omitempty"` ResolveError string `json:"resolve_error,omitempty"` Addresses []ChildAddressView `json:"addresses,omitempty"` }