feat: add NS TTL consistency and NS-target CNAME checks
Observe the NS RRset TTL from each parent server (ParentView.NSTTL) and whether each NS target name is a CNAME alias (ChildNSView.CNAMETarget). Two new rules judge the collected facts: - delegation_ns_ttl_inconsistent: warns when parent servers disagree on the NS TTL, which indicates zone-data inconsistency between primaries. - delegation_ns_is_cname: flags NS targets that are CNAME aliases as critical, per RFC 2181 §10.3 which forbids aliased NS names.
This commit is contained in:
parent
a16e01e1d4
commit
70c548284e
4 changed files with 125 additions and 6 deletions
|
|
@ -58,12 +58,14 @@ func (p *delegationProvider) Collect(ctx context.Context, opts sdk.CheckerOption
|
||||||
for _, ps := range parentServers {
|
for _, ps := range parentServers {
|
||||||
view := ParentView{Server: ps}
|
view := ParentView{Server: ps}
|
||||||
|
|
||||||
ns, glue, _, qerr := queryDelegation(ctx, ps, delegatedFQDN)
|
ns, glue, nsTTL, nsTTLKnown, qerr := queryDelegation(ctx, ps, delegatedFQDN)
|
||||||
if qerr != nil {
|
if qerr != nil {
|
||||||
view.UDPNSError = qerr.Error()
|
view.UDPNSError = qerr.Error()
|
||||||
} else {
|
} else {
|
||||||
view.NS = ns
|
view.NS = ns
|
||||||
view.Glue = glue
|
view.Glue = glue
|
||||||
|
view.NSTTL = nsTTL
|
||||||
|
view.NSTTLKnown = nsTTLKnown
|
||||||
}
|
}
|
||||||
|
|
||||||
if terr := queryDelegationTCP(ctx, ps, delegatedFQDN); terr != nil {
|
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.
|
// Phase B: per-child observations, seeded only from parent data.
|
||||||
for _, nsName := range primary.NS {
|
for _, nsName := range primary.NS {
|
||||||
child := ChildNSView{NSName: nsName}
|
child := ChildNSView{NSName: nsName}
|
||||||
|
|
||||||
|
if target, cerr := queryCNAMETarget(ctx, nsName); cerr == nil && target != "" {
|
||||||
|
child.CNAMETarget = target
|
||||||
|
}
|
||||||
|
|
||||||
addrs := primary.Glue[nsName]
|
addrs := primary.Glue[nsName]
|
||||||
if len(addrs) == 0 {
|
if len(addrs) == 0 {
|
||||||
// Out-of-bailiwick: no glue expected, fall back to the system resolver.
|
// Out-of-bailiwick: no glue expected, fall back to the system resolver.
|
||||||
|
|
|
||||||
|
|
@ -108,15 +108,16 @@ func resolveZoneNSAddrs(ctx context.Context, zone string) ([]string, error) {
|
||||||
|
|
||||||
// queryDelegation expects a referral response (no RD) and pulls NS + glue
|
// queryDelegation expects a referral response (no RD) and pulls NS + glue
|
||||||
// from every section so misconfigured parents (NS in Answer) still parse.
|
// 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}
|
q := dns.Question{Name: dns.Fqdn(fqdn), Qtype: dns.TypeNS, Qclass: dns.ClassINET}
|
||||||
|
|
||||||
msg, err = dnsExchange(ctx, "", parentServer, q, true)
|
msg, merr := dnsExchange(ctx, "", parentServer, q, true)
|
||||||
if err != nil {
|
if merr != nil {
|
||||||
return nil, nil, nil, err
|
return nil, nil, 0, false, merr
|
||||||
}
|
}
|
||||||
if msg.Rcode != dns.RcodeSuccess {
|
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{}
|
glue = map[string][]string{}
|
||||||
|
|
@ -127,6 +128,10 @@ func queryDelegation(ctx context.Context, parentServer, fqdn string) (ns []strin
|
||||||
case *dns.NS:
|
case *dns.NS:
|
||||||
if strings.EqualFold(strings.TrimSuffix(t.Header().Name, "."), strings.TrimSuffix(fqdn, ".")) {
|
if strings.EqualFold(strings.TrimSuffix(t.Header().Name, "."), strings.TrimSuffix(fqdn, ".")) {
|
||||||
ns = append(ns, strings.ToLower(dns.Fqdn(t.Ns)))
|
ns = append(ns, strings.ToLower(dns.Fqdn(t.Ns)))
|
||||||
|
if !nsTTLKnown {
|
||||||
|
nsTTL = t.Header().Ttl
|
||||||
|
nsTTLKnown = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
case *dns.A:
|
case *dns.A:
|
||||||
name := strings.ToLower(dns.Fqdn(t.Header().Name))
|
name := strings.ToLower(dns.Fqdn(t.Header().Name))
|
||||||
|
|
@ -143,6 +148,20 @@ func queryDelegation(ctx context.Context, parentServer, fqdn string) (ns []strin
|
||||||
return
|
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.
|
// 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) {
|
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}
|
q := dns.Question{Name: dns.Fqdn(fqdn), Qtype: dns.TypeDS, Qclass: dns.ClassINET}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ func Rules() []sdk.CheckRule {
|
||||||
&parentTCPRule{},
|
&parentTCPRule{},
|
||||||
&nsMatchesDeclaredRule{},
|
&nsMatchesDeclaredRule{},
|
||||||
&parentNSConsistencyRule{},
|
&parentNSConsistencyRule{},
|
||||||
|
&nsTTLConsistencyRule{},
|
||||||
&inBailiwickGlueRule{},
|
&inBailiwickGlueRule{},
|
||||||
&unnecessaryGlueRule{},
|
&unnecessaryGlueRule{},
|
||||||
&dsQueryRule{},
|
&dsQueryRule{},
|
||||||
|
|
@ -30,6 +31,7 @@ func Rules() []sdk.CheckRule {
|
||||||
&dsPresentAtParentRule{},
|
&dsPresentAtParentRule{},
|
||||||
&dsRRSIGValidityRule{},
|
&dsRRSIGValidityRule{},
|
||||||
&nsResolvableRule{},
|
&nsResolvableRule{},
|
||||||
|
&nsTargetNotCNAMERule{},
|
||||||
&childReachableRule{},
|
&childReachableRule{},
|
||||||
&childAuthoritativeRule{},
|
&childAuthoritativeRule{},
|
||||||
&childSOASerialDriftRule{},
|
&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 ─────────────────────────
|
// ───────────────────────── child-side rules ─────────────────────────
|
||||||
|
|
||||||
type nsResolvableRule struct{}
|
type nsResolvableRule struct{}
|
||||||
|
|
@ -570,6 +622,44 @@ func (r *nsResolvableRule) Evaluate(ctx context.Context, obs sdk.ObservationGett
|
||||||
return out
|
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{}
|
type childReachableRule struct{}
|
||||||
|
|
||||||
func (r *childReachableRule) Name() string { return "delegation_child_reachable" }
|
func (r *childReachableRule) Name() string { return "delegation_child_reachable" }
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,8 @@ type ParentView struct {
|
||||||
UDPNSError string `json:"udp_ns_error,omitempty"`
|
UDPNSError string `json:"udp_ns_error,omitempty"`
|
||||||
TCPNSError string `json:"tcp_ns_error,omitempty"`
|
TCPNSError string `json:"tcp_ns_error,omitempty"`
|
||||||
NS []string `json:"ns,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"`
|
Glue map[string][]string `json:"glue,omitempty"`
|
||||||
DSQueryError string `json:"ds_query_error,omitempty"`
|
DSQueryError string `json:"ds_query_error,omitempty"`
|
||||||
DS []DSRecord `json:"ds,omitempty"`
|
DS []DSRecord `json:"ds,omitempty"`
|
||||||
|
|
@ -40,6 +42,7 @@ type ParentView struct {
|
||||||
|
|
||||||
type ChildNSView struct {
|
type ChildNSView struct {
|
||||||
NSName string `json:"ns_name"`
|
NSName string `json:"ns_name"`
|
||||||
|
CNAMETarget string `json:"cname_target,omitempty"`
|
||||||
ResolveError string `json:"resolve_error,omitempty"`
|
ResolveError string `json:"resolve_error,omitempty"`
|
||||||
Addresses []ChildAddressView `json:"addresses,omitempty"`
|
Addresses []ChildAddressView `json:"addresses,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue