From 7e8faa71692a4517c968e2a94beb24b4e218c533 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 16 May 2026 13:14:05 +0800 Subject: [PATCH 1/5] refactor: deduplicate primary parent view selection in Collect Replace the inline loop with a call to the existing primaryParentView() helper so the selection algorithm lives in exactly one place. --- checker/collect.go | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/checker/collect.go b/checker/collect.go index f4fda00..0f6ad6d 100644 --- a/checker/collect.go +++ b/checker/collect.go @@ -89,13 +89,7 @@ func (p *delegationProvider) Collect(ctx context.Context, opts sdk.CheckerOption } // If no parent answered with an NS RRset, skip Phase B; rules flag the gap. - var primary *ParentView - for i := range data.ParentViews { - if data.ParentViews[i].UDPNSError == "" && len(data.ParentViews[i].NS) > 0 { - primary = &data.ParentViews[i] - break - } - } + primary := primaryParentView(data.ParentViews) if primary == nil { return data, nil } From 3366cebf7dd4c9ca96e34caf1ca9fc61984f5e60 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 16 May 2026 13:18:25 +0800 Subject: [PATCH 2/5] refactor: always probe DNSKEY regardless of parent DS presence Move the "skip DNSKEY when no parent DS" decision out of Collect and into the rules, so the prober stays a pure observer. The dnskeyQueryRule and dnskeyMatchesDSRule already return StatusUnknown when no parent DS is present. --- checker/collect.go | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/checker/collect.go b/checker/collect.go index 0f6ad6d..b0b5fff 100644 --- a/checker/collect.go +++ b/checker/collect.go @@ -142,22 +142,12 @@ func (p *delegationProvider) Collect(ctx context.Context, opts sdk.CheckerOption av.ChildGlueAddrs = addrsAt } - // DNSKEY is only useful when there's a parent DS to match against. - parentHasDS := false - for _, pv := range data.ParentViews { - if len(pv.DS) > 0 { - parentHasDS = true - break - } - } - if parentHasDS { - keys, kerr := queryDNSKEY(ctx, srv, delegatedFQDN) - if kerr != nil { - av.DNSKEYError = kerr.Error() - } else { - for _, k := range keys { - av.DNSKEYs = append(av.DNSKEYs, NewDNSKEYRecord(k)) - } + keys, kerr := queryDNSKEY(ctx, srv, delegatedFQDN) + if kerr != nil { + av.DNSKEYError = kerr.Error() + } else { + for _, k := range keys { + av.DNSKEYs = append(av.DNSKEYs, NewDNSKEYRecord(k)) } } From 557bed4330b51a46f69fef3de7b30c555d5252dd Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 16 May 2026 13:26:00 +0800 Subject: [PATCH 3/5] test: extend rule coverage to all 20 rules and edge cases --- checker/rule_test.go | 721 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 721 insertions(+) diff --git a/checker/rule_test.go b/checker/rule_test.go index 86327dc..aee9baf 100644 --- a/checker/rule_test.go +++ b/checker/rule_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "strings" "testing" + "time" "github.com/miekg/dns" @@ -437,3 +438,723 @@ func TestLoadDataPropagatesError(t *testing.T) { t.Fatalf("want single Error state, got %+v", states) } } + +// ------------------------- parentNSQueryRule ------------------------- + +func TestParentNSQueryRule(t *testing.T) { + r := &parentNSQueryRule{} + + t.Run("no parent views → Unknown", func(t *testing.T) { + states := evalRule(t, r, &DelegationData{}, nil) + if len(states) != 1 || states[0].Status != sdk.StatusUnknown { + t.Fatalf("want Unknown, got %+v", states) + } + }) + t.Run("UDP error → Crit", func(t *testing.T) { + data := &DelegationData{ + ParentViews: []ParentView{{Server: "p:53", UDPNSError: "timeout"}}, + } + states := evalRule(t, r, data, nil) + if len(states) != 1 || states[0].Status != sdk.StatusCrit { + t.Fatalf("want Crit, got %+v", states) + } + }) + t.Run("empty NS RRset → Crit", func(t *testing.T) { + data := &DelegationData{ + ParentViews: []ParentView{{Server: "p:53", NS: []string{}}}, + } + states := evalRule(t, r, data, nil) + if len(states) != 1 || states[0].Status != sdk.StatusCrit { + t.Fatalf("want Crit, got %+v", states) + } + }) + t.Run("OK when NS records returned", func(t *testing.T) { + data := &DelegationData{ + ParentViews: []ParentView{{Server: "p:53", NS: []string{"ns1.example.com."}}}, + } + states := evalRule(t, r, data, nil) + if len(states) != 1 || states[0].Status != sdk.StatusOK { + t.Fatalf("want OK, got %+v", states) + } + }) + t.Run("per-server subjects", func(t *testing.T) { + data := &DelegationData{ + ParentViews: []ParentView{ + {Server: "p1:53", NS: []string{"ns1.example.com."}}, + {Server: "p2:53", UDPNSError: "refused"}, + }, + } + states := evalRule(t, r, data, nil) + if len(states) != 2 { + t.Fatalf("want 2 states, got %d", len(states)) + } + idx := statusByCode(t, states) + if idx["delegation_parent_query_failed|p1:53"].Status != sdk.StatusOK { + t.Errorf("p1: want OK") + } + if idx["delegation_parent_query_failed|p2:53"].Status != sdk.StatusCrit { + t.Errorf("p2: want Crit") + } + }) +} + +// ------------------------- parentTCPRule ------------------------- + +func TestParentTCPRule(t *testing.T) { + r := &parentTCPRule{} + + t.Run("no parent views → Unknown", func(t *testing.T) { + states := evalRule(t, r, &DelegationData{}, nil) + if states[0].Status != sdk.StatusUnknown { + t.Fatalf("want Unknown, got %+v", states) + } + }) + t.Run("TCP error with requireTCP=true → Crit", func(t *testing.T) { + data := &DelegationData{ + ParentViews: []ParentView{{Server: "p:53", TCPNSError: "refused"}}, + } + states := evalRule(t, r, data, nil) + if states[0].Status != sdk.StatusCrit { + t.Fatalf("want Crit, got %+v", states) + } + }) + t.Run("TCP error with requireTCP=false → Warn", func(t *testing.T) { + data := &DelegationData{ + ParentViews: []ParentView{{Server: "p:53", TCPNSError: "refused"}}, + } + states := evalRule(t, r, data, sdk.CheckerOptions{"requireTCP": false}) + if states[0].Status != sdk.StatusWarn { + t.Fatalf("want Warn, got %+v", states) + } + }) + t.Run("no TCP error → OK", func(t *testing.T) { + data := &DelegationData{ + ParentViews: []ParentView{{Server: "p:53"}}, + } + states := evalRule(t, r, data, nil) + if states[0].Status != sdk.StatusOK { + t.Fatalf("want OK, got %+v", states) + } + }) +} + +// ------------------------- dsQueryRule ------------------------- + +func TestDSQueryRule(t *testing.T) { + r := &dsQueryRule{} + + t.Run("no parent views → Unknown", func(t *testing.T) { + states := evalRule(t, r, &DelegationData{}, nil) + if states[0].Status != sdk.StatusUnknown { + t.Fatalf("want Unknown, got %+v", states) + } + }) + t.Run("DS query error → Warn", func(t *testing.T) { + data := &DelegationData{ + ParentViews: []ParentView{{Server: "p:53", DSQueryError: "timeout"}}, + } + states := evalRule(t, r, data, nil) + if states[0].Status != sdk.StatusWarn { + t.Fatalf("want Warn, got %+v", states) + } + }) + t.Run("DS query OK with records", func(t *testing.T) { + data := &DelegationData{ + ParentViews: []ParentView{{Server: "p:53", DS: []DSRecord{{KeyTag: 1, Algorithm: 8, DigestType: 2, Digest: "AAAA"}}}}, + } + states := evalRule(t, r, data, nil) + if states[0].Status != sdk.StatusOK { + t.Fatalf("want OK, got %+v", states) + } + }) + t.Run("DS query OK with empty answer", func(t *testing.T) { + data := &DelegationData{ + ParentViews: []ParentView{{Server: "p:53"}}, + } + states := evalRule(t, r, data, nil) + if states[0].Status != sdk.StatusOK { + t.Fatalf("want OK for NOERROR+empty, got %+v", states) + } + }) +} + +// ------------------------- dsMatchesDeclaredRule ------------------------- + +func TestDSMatchesDeclaredRule(t *testing.T) { + r := &dsMatchesDeclaredRule{} + ds1 := DSRecord{KeyTag: 1, Algorithm: 8, DigestType: 2, Digest: "AAAA"} + ds2 := DSRecord{KeyTag: 2, Algorithm: 8, DigestType: 2, Digest: "BBBB"} + + t.Run("no DS data to compare → Info", func(t *testing.T) { + states := evalRule(t, r, &DelegationData{}, nil) + if states[0].Status != sdk.StatusInfo { + t.Fatalf("want Info, got %+v", states) + } + }) + t.Run("DS RRset matches declared → OK", func(t *testing.T) { + data := &DelegationData{ + DeclaredDS: []DSRecord{ds1}, + ParentViews: []ParentView{{Server: "p:53", DS: []DSRecord{ds1}}}, + } + states := evalRule(t, r, data, nil) + if states[0].Status != sdk.StatusOK { + t.Fatalf("want OK, got %+v", states) + } + }) + t.Run("DS missing from parent → Crit when declared non-empty", func(t *testing.T) { + data := &DelegationData{ + DeclaredDS: []DSRecord{ds1, ds2}, + ParentViews: []ParentView{{Server: "p:53", DS: []DSRecord{ds1}}}, + } + states := evalRule(t, r, data, nil) + if states[0].Status != sdk.StatusCrit { + t.Fatalf("want Crit, got %+v", states) + } + }) + t.Run("extra DS at parent with no declared → Warn", func(t *testing.T) { + data := &DelegationData{ + DeclaredDS: []DSRecord{}, + ParentViews: []ParentView{{Server: "p:53", DS: []DSRecord{ds1}}}, + } + states := evalRule(t, r, data, nil) + if states[0].Status != sdk.StatusWarn { + t.Fatalf("want Warn, got %+v", states) + } + }) + t.Run("DS query error → view skipped", func(t *testing.T) { + data := &DelegationData{ + DeclaredDS: []DSRecord{ds1}, + ParentViews: []ParentView{ + {Server: "p1:53", DSQueryError: "timeout"}, + {Server: "p2:53", DS: []DSRecord{ds1}}, + }, + } + states := evalRule(t, r, data, nil) + idx := statusByCode(t, states) + if _, ok := idx["delegation_ds_mismatch|p1:53"]; ok { + t.Error("p1 with DS query error must be skipped") + } + if idx["delegation_ds_mismatch|p2:53"].Status != sdk.StatusOK { + t.Error("p2: want OK") + } + }) +} + +// ------------------------- dsPresentAtParentRule ------------------------- + +func TestDSPresentAtParentRule(t *testing.T) { + r := &dsPresentAtParentRule{} + + t.Run("no declared DS → Info", func(t *testing.T) { + states := evalRule(t, r, &DelegationData{}, nil) + if states[0].Status != sdk.StatusInfo { + t.Fatalf("want Info, got %+v", states) + } + }) + t.Run("declared DS and parent serves DS → OK", func(t *testing.T) { + data := &DelegationData{ + DeclaredDS: []DSRecord{{KeyTag: 1, Algorithm: 8, DigestType: 2, Digest: "AAAA"}}, + ParentViews: []ParentView{{Server: "p:53", DS: []DSRecord{{KeyTag: 1, Algorithm: 8, DigestType: 2, Digest: "AAAA"}}}}, + } + states := evalRule(t, r, data, nil) + if states[0].Status != sdk.StatusOK { + t.Fatalf("want OK, got %+v", states) + } + }) +} + +// ------------------------- dsRRSIGValidityRule ------------------------- + +func nowUint32() uint32 { return uint32(time.Now().UTC().Unix()) } + +func TestDSRRSIGValidityRule(t *testing.T) { + r := &dsRRSIGValidityRule{} + + t.Run("no DS RRSIGs → Info", func(t *testing.T) { + data := &DelegationData{ + ParentViews: []ParentView{{Server: "p:53", DS: []DSRecord{{KeyTag: 1}}}}, + } + states := evalRule(t, r, data, nil) + if states[0].Status != sdk.StatusInfo { + t.Fatalf("want Info, got %+v", states) + } + }) + t.Run("DS query error → view skipped", func(t *testing.T) { + data := &DelegationData{ + ParentViews: []ParentView{{Server: "p:53", DSQueryError: "timeout", DSRRSIGs: []DSRRSIGObservation{{ + Inception: nowUint32() - 3600, + Expiration: nowUint32() + 86400, + }}}}, + } + states := evalRule(t, r, data, nil) + if len(states) != 1 && states[0].Status != sdk.StatusInfo { + t.Fatalf("view with DS query error must be skipped, got %+v", states) + } + }) + t.Run("valid RRSIG → OK", func(t *testing.T) { + now := nowUint32() + data := &DelegationData{ + ParentViews: []ParentView{{ + Server: "p:53", + DSRRSIGs: []DSRRSIGObservation{{ + Inception: now - 3600, + Expiration: now + 86400, + }}, + }}, + } + states := evalRule(t, r, data, nil) + if states[0].Status != sdk.StatusOK { + t.Fatalf("want OK, got %+v", states) + } + }) + t.Run("expired RRSIG → Crit", func(t *testing.T) { + now := nowUint32() + data := &DelegationData{ + ParentViews: []ParentView{{ + Server: "p:53", + DSRRSIGs: []DSRRSIGObservation{{ + Inception: now - 172800, + Expiration: now - 3600, + }}, + }}, + } + states := evalRule(t, r, data, nil) + if states[0].Status != sdk.StatusCrit { + t.Fatalf("want Crit for expired, got %+v", states) + } + if !strings.Contains(states[0].Message, "expired") { + t.Errorf("message should mention 'expired', got %q", states[0].Message) + } + }) + t.Run("not-yet-valid RRSIG → Crit", func(t *testing.T) { + now := nowUint32() + data := &DelegationData{ + ParentViews: []ParentView{{ + Server: "p:53", + DSRRSIGs: []DSRRSIGObservation{{ + Inception: now + 3600, + Expiration: now + 172800, + }}, + }}, + } + states := evalRule(t, r, data, nil) + if states[0].Status != sdk.StatusCrit { + t.Fatalf("want Crit for not-yet-valid, got %+v", states) + } + if !strings.Contains(states[0].Message, "not yet valid") { + t.Errorf("message should mention 'not yet valid', got %q", states[0].Message) + } + }) +} + +// ------------------------- nsResolvableRule ------------------------- + +func TestNSResolvableRule(t *testing.T) { + r := &nsResolvableRule{} + + t.Run("no out-of-bailiwick NS → Info", func(t *testing.T) { + data := &DelegationData{ + DelegatedFQDN: "example.com.", + Children: []ChildNSView{{ + NSName: "ns1.example.com.", // in-bailiwick, must be skipped + }}, + } + states := evalRule(t, r, data, nil) + if states[0].Status != sdk.StatusInfo { + t.Fatalf("want Info, got %+v", states) + } + }) + t.Run("resolve error → Crit", func(t *testing.T) { + data := &DelegationData{ + DelegatedFQDN: "example.com.", + Children: []ChildNSView{{ + NSName: "ns1.elsewhere.net.", + ResolveError: "NXDOMAIN", + }}, + } + states := evalRule(t, r, data, nil) + if states[0].Status != sdk.StatusCrit { + t.Fatalf("want Crit, got %+v", states) + } + }) + t.Run("resolved → OK", func(t *testing.T) { + data := &DelegationData{ + DelegatedFQDN: "example.com.", + Children: []ChildNSView{{ + NSName: "ns1.elsewhere.net.", + Addresses: []ChildAddressView{{Address: "192.0.2.1"}}, + }}, + } + states := evalRule(t, r, data, nil) + if states[0].Status != sdk.StatusOK { + t.Fatalf("want OK, got %+v", states) + } + }) + t.Run("in-bailiwick skipped even if resolve error set", func(t *testing.T) { + data := &DelegationData{ + DelegatedFQDN: "example.com.", + Children: []ChildNSView{{ + NSName: "ns1.example.com.", + ResolveError: "NXDOMAIN", + }}, + } + states := evalRule(t, r, data, nil) + // In-bailiwick NS must be ignored; only Info state for "no out-of-bailiwick NS". + if states[0].Status != sdk.StatusInfo { + t.Fatalf("want Info (in-bailiwick skipped), got %+v", states) + } + }) +} + +// ------------------------- childReachableRule ------------------------- + +func TestChildReachableRule(t *testing.T) { + r := &childReachableRule{} + + t.Run("no child addresses → Unknown", func(t *testing.T) { + states := evalRule(t, r, &DelegationData{}, nil) + if states[0].Status != sdk.StatusUnknown { + t.Fatalf("want Unknown, got %+v", states) + } + }) + t.Run("UDP error → Crit", func(t *testing.T) { + data := &DelegationData{ + Children: []ChildNSView{{ + NSName: "ns1.example.com.", + Addresses: []ChildAddressView{{Address: "192.0.2.1", UDPError: "timeout"}}, + }}, + } + states := evalRule(t, r, data, nil) + if states[0].Status != sdk.StatusCrit { + t.Fatalf("want Crit, got %+v", states) + } + }) + t.Run("reachable → OK", func(t *testing.T) { + data := &DelegationData{ + Children: []ChildNSView{{ + NSName: "ns1.example.com.", + Addresses: []ChildAddressView{{Address: "192.0.2.1", Authoritative: true}}, + }}, + } + states := evalRule(t, r, data, nil) + if states[0].Status != sdk.StatusOK { + t.Fatalf("want OK, got %+v", states) + } + }) +} + +// ------------------------- childNSMatchesParentRule ------------------------- + +func TestChildNSMatchesParentRule(t *testing.T) { + r := &childNSMatchesParentRule{} + + t.Run("no primary parent view → Unknown", func(t *testing.T) { + data := &DelegationData{ + ParentViews: []ParentView{{Server: "p:53", UDPNSError: "timeout"}}, + } + states := evalRule(t, r, data, nil) + if states[0].Status != sdk.StatusUnknown { + t.Fatalf("want Unknown, got %+v", states) + } + }) + t.Run("child NS matches parent → OK", func(t *testing.T) { + data := &DelegationData{ + ParentViews: []ParentView{{ + Server: "p:53", + NS: []string{"ns1.example.com.", "ns2.example.com."}, + }}, + Children: []ChildNSView{{ + NSName: "ns1.example.com.", + Addresses: []ChildAddressView{{ + Address: "192.0.2.1", + ChildNS: []string{"ns1.example.com.", "ns2.example.com."}, + }}, + }}, + } + states := evalRule(t, r, data, nil) + if states[0].Status != sdk.StatusOK { + t.Fatalf("want OK, got %+v", states) + } + }) + t.Run("child NS drift → Warn", func(t *testing.T) { + data := &DelegationData{ + ParentViews: []ParentView{{ + Server: "p:53", + NS: []string{"ns1.example.com.", "ns2.example.com."}, + }}, + Children: []ChildNSView{{ + NSName: "ns1.example.com.", + Addresses: []ChildAddressView{{ + Address: "192.0.2.1", + ChildNS: []string{"ns1.example.com.", "ns3.example.com."}, + }}, + }}, + } + states := evalRule(t, r, data, nil) + if states[0].Status != sdk.StatusWarn { + t.Fatalf("want Warn, got %+v", states) + } + }) + t.Run("UDP error or NS query error → view skipped", func(t *testing.T) { + data := &DelegationData{ + ParentViews: []ParentView{{ + Server: "p:53", + NS: []string{"ns1.example.com."}, + }}, + Children: []ChildNSView{{ + NSName: "ns1.example.com.", + Addresses: []ChildAddressView{ + {Address: "192.0.2.1", UDPError: "timeout"}, + {Address: "192.0.2.2", ChildNSError: "refused"}, + }, + }}, + } + states := evalRule(t, r, data, nil) + // Both addresses must be skipped → Unknown + if states[0].Status != sdk.StatusUnknown { + t.Fatalf("want Unknown (all addresses skipped), got %+v", states) + } + }) +} + +// ------------------------- dnskeyQueryRule ------------------------- + +func TestDNSKEYQueryRule(t *testing.T) { + r := &dnskeyQueryRule{} + + t.Run("no parent DS → Unknown", func(t *testing.T) { + data := &DelegationData{ + ParentViews: []ParentView{{Server: "p:53"}}, + Children: []ChildNSView{{NSName: "ns1.example.com.", Addresses: []ChildAddressView{{Address: "192.0.2.1"}}}}, + } + states := evalRule(t, r, data, nil) + if states[0].Status != sdk.StatusUnknown { + t.Fatalf("want Unknown when no parent DS, got %+v", states) + } + }) + t.Run("DNSKEY query error → Warn", func(t *testing.T) { + data := &DelegationData{ + ParentViews: []ParentView{{Server: "p:53", DS: []DSRecord{{KeyTag: 1}}}}, + Children: []ChildNSView{{ + NSName: "ns1.example.com.", + Addresses: []ChildAddressView{{Address: "192.0.2.1", DNSKEYError: "timeout"}}, + }}, + } + states := evalRule(t, r, data, nil) + if states[0].Status != sdk.StatusWarn { + t.Fatalf("want Warn, got %+v", states) + } + }) + t.Run("DNSKEY query succeeds → OK", func(t *testing.T) { + key := &dns.DNSKEY{ + Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeDNSKEY, Class: dns.ClassINET}, + Flags: 257, Protocol: 3, Algorithm: dns.RSASHA256, + PublicKey: "AwEAAcMnWBKLuvG/LwnPVykcmpvnntwxfshHlHRhlY0F3oz8AMcuF8gw2Ge56vG9oqVxTzHl4Ss2dEqCQOjFlOVo+pa3JwIO1lUzbQ==", + } + data := &DelegationData{ + ParentViews: []ParentView{{Server: "p:53", DS: []DSRecord{{KeyTag: 1}}}}, + Children: []ChildNSView{{ + NSName: "ns1.example.com.", + Addresses: []ChildAddressView{{Address: "192.0.2.1", DNSKEYs: []DNSKEYRecord{NewDNSKEYRecord(key)}}}, + }}, + } + states := evalRule(t, r, data, nil) + if states[0].Status != sdk.StatusOK { + t.Fatalf("want OK, got %+v", states) + } + }) + t.Run("UDP error → address skipped, Unknown if no usable address", func(t *testing.T) { + data := &DelegationData{ + ParentViews: []ParentView{{Server: "p:53", DS: []DSRecord{{KeyTag: 1}}}}, + Children: []ChildNSView{{ + NSName: "ns1.example.com.", + Addresses: []ChildAddressView{{Address: "192.0.2.1", UDPError: "timeout"}}, + }}, + } + states := evalRule(t, r, data, nil) + if states[0].Status != sdk.StatusUnknown { + t.Fatalf("want Unknown (UDP error skips address), got %+v", states) + } + }) +} + +// ------------------------- dnskeyMatchesDSRule edge cases ------------------------- + +func TestDNSKEYMatchesDSRule_NoDSAtParent(t *testing.T) { + data := &DelegationData{ + ParentViews: []ParentView{{Server: "p:53"}}, // no DS + Children: []ChildNSView{{ + NSName: "ns1.example.com.", + Addresses: []ChildAddressView{{Address: "192.0.2.1"}}, + }}, + } + states := evalRule(t, (&dnskeyMatchesDSRule{}), data, nil) + if states[0].Status != sdk.StatusUnknown { + t.Fatalf("want Unknown when parent has no DS, got %+v", states) + } +} + +func TestDNSKEYMatchesDSRule_NoKeysObserved(t *testing.T) { + data := &DelegationData{ + ParentViews: []ParentView{{Server: "p:53", DS: []DSRecord{{KeyTag: 1}}}}, + Children: []ChildNSView{{ + NSName: "ns1.example.com.", + Addresses: []ChildAddressView{{Address: "192.0.2.1", DNSKEYError: "timeout"}}, + }}, + } + states := evalRule(t, (&dnskeyMatchesDSRule{}), data, nil) + if states[0].Status != sdk.StatusUnknown { + t.Fatalf("want Unknown when no DNSKEY observed, got %+v", states) + } +} + +// ------------------------- inBailiwickGlueRule missing-glue branch ------------------------- + +func TestInBailiwickGlueRule_MissingGlue(t *testing.T) { + r := &inBailiwickGlueRule{} + data := &DelegationData{ + DelegatedFQDN: "example.com.", + ParentViews: []ParentView{{ + Server: "p:53", + NS: []string{"ns1.example.com."}, + Glue: map[string][]string{}, // no glue for in-bailiwick NS + }}, + } + states := evalRule(t, r, data, nil) + var sawCrit bool + for _, s := range states { + if s.Status == sdk.StatusCrit { + sawCrit = true + } + } + if !sawCrit { + t.Error("want Crit when in-bailiwick NS has no glue") + } +} + +// ------------------------- unnecessaryGlueRule OK/Info branches ------------------------- + +func TestUnnecessaryGlueRule_NoExtraGlue(t *testing.T) { + r := &unnecessaryGlueRule{} + data := &DelegationData{ + DelegatedFQDN: "example.com.", + ParentViews: []ParentView{{ + Server: "p:53", + NS: []string{"ns1.elsewhere.net."}, + Glue: map[string][]string{}, // out-of-bailiwick with no glue + }}, + } + states := evalRule(t, r, data, nil) + if len(states) != 1 || states[0].Status != sdk.StatusOK { + t.Fatalf("want OK for out-of-bailiwick NS without glue, got %+v", states) + } +} + +func TestUnnecessaryGlueRule_NoOutOfBailiwickNS(t *testing.T) { + r := &unnecessaryGlueRule{} + data := &DelegationData{ + DelegatedFQDN: "example.com.", + ParentViews: []ParentView{{ + Server: "p:53", + NS: []string{"ns1.example.com."}, + Glue: map[string][]string{"ns1.example.com.": {"192.0.2.1"}}, + }}, + } + states := evalRule(t, r, data, nil) + if len(states) != 1 || states[0].Status != sdk.StatusInfo { + t.Fatalf("want Info when all NS are in-bailiwick, got %+v", states) + } +} + +// ------------------------- childGlueMatchesParentRule edge cases ------------------------- + +func TestChildGlueMatchesParentRule_NoPrimary(t *testing.T) { + r := &childGlueMatchesParentRule{} + data := &DelegationData{ + DelegatedFQDN: "example.com.", + ParentViews: []ParentView{{Server: "p:53", UDPNSError: "timeout"}}, + } + states := evalRule(t, r, data, nil) + if states[0].Status != sdk.StatusUnknown { + t.Fatalf("want Unknown when no primary parent view, got %+v", states) + } +} + +func TestChildGlueMatchesParentRule_OutOfBailiwickSkipped(t *testing.T) { + r := &childGlueMatchesParentRule{} + data := &DelegationData{ + DelegatedFQDN: "example.com.", + ParentViews: []ParentView{{ + Server: "p:53", + NS: []string{"ns1.elsewhere.net."}, + Glue: map[string][]string{}, + }}, + Children: []ChildNSView{{ + NSName: "ns1.elsewhere.net.", // out-of-bailiwick + Addresses: []ChildAddressView{{Address: "192.0.2.1", ChildGlueAddrs: []string{"192.0.2.99"}}}, + }}, + } + states := evalRule(t, r, data, nil) + // No in-bailiwick NS → no states at all (rule stays silent). + if len(states) != 0 { + t.Fatalf("want no states for out-of-bailiwick NS, got %+v", states) + } +} + +// ------------------------- nsHasAuthoritativeAnswerRule no-children branch ------------------------- + +func TestNSHasAuthoritativeAnswerRule_NoChildren(t *testing.T) { + r := &nsHasAuthoritativeAnswerRule{} + states := evalRule(t, r, &DelegationData{}, nil) + if states[0].Status != sdk.StatusUnknown { + t.Fatalf("want Unknown when no children, got %+v", states) + } +} + +func TestNSHasAuthoritativeAnswerRule_NoAddresses(t *testing.T) { + r := &nsHasAuthoritativeAnswerRule{} + data := &DelegationData{ + Children: []ChildNSView{{NSName: "ns1.example.com.", Addresses: nil}}, + } + // NS with no addresses is skipped by the rule. + states := evalRule(t, r, data, nil) + if states[0].Status != sdk.StatusUnknown { + t.Fatalf("want Unknown when NS has no addresses, got %+v", states) + } +} + +// ------------------------- primaryParentView ------------------------- + +func TestPrimaryParentView(t *testing.T) { + t.Run("nil when all views failed", func(t *testing.T) { + views := []ParentView{ + {Server: "p1:53", UDPNSError: "timeout"}, + {Server: "p2:53", UDPNSError: "refused"}, + } + if primaryParentView(views) != nil { + t.Error("want nil") + } + }) + t.Run("nil when NS RRsets are empty", func(t *testing.T) { + views := []ParentView{{Server: "p:53", NS: []string{}}} + if primaryParentView(views) != nil { + t.Error("want nil when NS slice is empty") + } + }) + t.Run("returns first successful view", func(t *testing.T) { + views := []ParentView{ + {Server: "p1:53", UDPNSError: "timeout"}, + {Server: "p2:53", NS: []string{"ns1.example.com."}}, + {Server: "p3:53", NS: []string{"ns2.example.com."}}, + } + got := primaryParentView(views) + if got == nil || got.Server != "p2:53" { + t.Errorf("want p2:53, got %v", got) + } + }) + t.Run("nil for empty slice", func(t *testing.T) { + if primaryParentView(nil) != nil { + t.Error("want nil for empty slice") + } + }) +} From a16e01e1d4b14b19257e3de07fb5db44d738d573 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 16 May 2026 17:02:29 +0800 Subject: [PATCH 4/5] feat: add 6 new delegation rules for deeper validation Add rules for duplicate NS detection, cross-parent NS/DS consistency, and DNSKEY integrity (KSK presence, protocol field, full KSK coverage by DS). --- checker/rule.go | 306 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 306 insertions(+) diff --git a/checker/rule.go b/checker/rule.go index 42f29c2..95281cd 100644 --- a/checker/rule.go +++ b/checker/rule.go @@ -16,14 +16,17 @@ import ( func Rules() []sdk.CheckRule { return []sdk.CheckRule{ &minNameServersRule{}, + &duplicateNSRule{}, &parentDiscoveredRule{}, &parentNSQueryRule{}, &parentTCPRule{}, &nsMatchesDeclaredRule{}, + &parentNSConsistencyRule{}, &inBailiwickGlueRule{}, &unnecessaryGlueRule{}, &dsQueryRule{}, &dsMatchesDeclaredRule{}, + &parentDSConsistencyRule{}, &dsPresentAtParentRule{}, &dsRRSIGValidityRule{}, &nsResolvableRule{}, @@ -35,6 +38,9 @@ func Rules() []sdk.CheckRule { &childGlueMatchesParentRule{}, &dnskeyQueryRule{}, &dnskeyMatchesDSRule{}, + &dnskeyKSKPresentRule{}, + &dnskeyProtocolRule{}, + &dsCoversAllKSKsRule{}, &nsHasAuthoritativeAnswerRule{}, } } @@ -946,6 +952,306 @@ func (r *dnskeyMatchesDSRule) Evaluate(ctx context.Context, obs sdk.ObservationG return out } +// ------------------------- declaration checks ------------------------- + +type duplicateNSRule struct{} + +func (r *duplicateNSRule) Name() string { return "delegation_duplicate_ns" } +func (r *duplicateNSRule) Description() string { + return "Checks that the declared NS list contains no duplicate name server names" +} +func (r *duplicateNSRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { + data, errState := loadData(ctx, obs, "delegation_duplicate_ns") + if errState != nil { + return errState + } + // DeclaredNS is already sorted and lowercased by normalizeNSList. + var dups []string + for i := 1; i < len(data.DeclaredNS); i++ { + if data.DeclaredNS[i] == data.DeclaredNS[i-1] { + dups = append(dups, data.DeclaredNS[i]) + } + } + if len(dups) > 0 { + return []sdk.CheckState{{ + Status: sdk.StatusWarn, + Code: "delegation_duplicate_ns", + Message: fmt.Sprintf("declared NS list contains duplicates: %v", dups), + Meta: map[string]any{"duplicates": dups}, + }} + } + return []sdk.CheckState{{ + Status: sdk.StatusOK, + Code: "delegation_duplicate_ns", + Message: "no duplicate name server names in declared list", + }} +} + +// ------------------------- parent-side consistency ------------------------- + +type parentNSConsistencyRule struct{} + +func (r *parentNSConsistencyRule) Name() string { return "delegation_parent_ns_consistency" } +func (r *parentNSConsistencyRule) Description() string { + return "Verifies that all parent authoritative servers return the same NS RRset for the delegated zone" +} +func (r *parentNSConsistencyRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { + data, errState := loadData(ctx, obs, "delegation_parent_ns_inconsistent") + if errState != nil { + return errState + } + var valid []ParentView + for _, v := range data.ParentViews { + if v.UDPNSError == "" && len(v.NS) > 0 { + valid = append(valid, v) + } + } + if len(valid) < 2 { + return []sdk.CheckState{{ + Status: sdk.StatusUnknown, + Code: "delegation_parent_ns_inconsistent", + Message: "fewer than 2 parent servers returned an NS RRset; cross-server comparison not possible", + }} + } + ref := valid[0] + out := make([]sdk.CheckState, 0, len(valid)-1) + for _, v := range valid[1:] { + missing, extra := diffStringSets(ref.NS, v.NS) + st := sdk.CheckState{Code: "delegation_parent_ns_inconsistent", Subject: v.Server} + if len(missing) > 0 || len(extra) > 0 { + st.Status = sdk.StatusCrit + st.Message = fmt.Sprintf("NS RRset differs from %s: missing=%v extra=%v", ref.Server, missing, extra) + st.Meta = map[string]any{"reference": ref.Server, "missing": missing, "extra": extra} + } else { + st.Status = sdk.StatusOK + st.Message = fmt.Sprintf("NS RRset matches %s", ref.Server) + } + out = append(out, st) + } + return out +} + +type parentDSConsistencyRule struct{} + +func (r *parentDSConsistencyRule) Name() string { return "delegation_parent_ds_consistency" } +func (r *parentDSConsistencyRule) Description() string { + return "Verifies that all parent authoritative servers return the same DS RRset for the delegated zone" +} +func (r *parentDSConsistencyRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { + data, errState := loadData(ctx, obs, "delegation_parent_ds_inconsistent") + if errState != nil { + return errState + } + var valid []ParentView + for _, v := range data.ParentViews { + if v.DSQueryError == "" { + valid = append(valid, v) + } + } + if len(valid) < 2 { + return []sdk.CheckState{{ + Status: sdk.StatusUnknown, + Code: "delegation_parent_ds_inconsistent", + Message: "fewer than 2 parent servers returned a DS response; cross-server comparison not possible", + }} + } + ref := valid[0] + refDS := dsRecordsToMiekg(ref.DS) + out := make([]sdk.CheckState, 0, len(valid)-1) + for _, v := range valid[1:] { + missing, extra := diffDS(refDS, dsRecordsToMiekg(v.DS)) + st := sdk.CheckState{Code: "delegation_parent_ds_inconsistent", Subject: v.Server} + if len(missing) > 0 || len(extra) > 0 { + st.Status = sdk.StatusCrit + st.Message = fmt.Sprintf("DS RRset differs from %s: missing=%d extra=%d", ref.Server, len(missing), len(extra)) + st.Meta = map[string]any{"reference": ref.Server, "missing": len(missing), "extra": len(extra)} + } else { + st.Status = sdk.StatusOK + st.Message = fmt.Sprintf("DS RRset matches %s", ref.Server) + } + out = append(out, st) + } + return out +} + +// ------------------------- DNSKEY integrity rules ------------------------- + +type dnskeyKSKPresentRule struct{} + +func (r *dnskeyKSKPresentRule) Name() string { return "delegation_dnskey_ksk_present" } +func (r *dnskeyKSKPresentRule) Description() string { + return "Verifies that at least one DNSKEY with the SEP bit set (KSK, flags=257) is present when the parent publishes DS records" +} +func (r *dnskeyKSKPresentRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { + data, errState := loadData(ctx, obs, "delegation_dnskey_no_ksk") + if errState != nil { + return errState + } + if !parentHasAnyDS(data.ParentViews) { + return []sdk.CheckState{{ + Status: sdk.StatusUnknown, + Code: "delegation_dnskey_no_ksk", + Message: "parent has no DS records, KSK check skipped", + }} + } + var out []sdk.CheckState + for _, c := range data.Children { + var keys []DNSKEYRecord + for _, a := range c.Addresses { + keys = append(keys, a.DNSKEYs...) + } + if len(keys) == 0 { + continue + } + hasKSK := false + for _, k := range keys { + if k.Flags&0x0001 != 0 { + hasKSK = true + break + } + } + st := sdk.CheckState{Code: "delegation_dnskey_no_ksk", Subject: c.NSName} + if hasKSK { + st.Status = sdk.StatusOK + st.Message = "at least one KSK (SEP bit set, flags=257) found" + } else { + st.Status = sdk.StatusCrit + st.Message = "no KSK (SEP bit, flags=257) found among served DNSKEY records" + } + out = append(out, st) + } + if len(out) == 0 { + return []sdk.CheckState{{ + Status: sdk.StatusUnknown, + Code: "delegation_dnskey_no_ksk", + Message: "no DNSKEY records observed at any child server", + }} + } + return out +} + +type dnskeyProtocolRule struct{} + +func (r *dnskeyProtocolRule) Name() string { return "delegation_dnskey_protocol" } +func (r *dnskeyProtocolRule) Description() string { + return "Verifies that every DNSKEY record has Protocol=3 as required by RFC 4034 §2.1" +} +func (r *dnskeyProtocolRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { + data, errState := loadData(ctx, obs, "delegation_dnskey_bad_protocol") + if errState != nil { + return errState + } + hasSomeKeys := false + var out []sdk.CheckState + for _, c := range data.Children { + // Deduplicate by public key to avoid reporting the same key once per address. + seen := map[string]bool{} + for _, a := range c.Addresses { + for _, k := range a.DNSKEYs { + hasSomeKeys = true + if seen[k.PublicKey] { + continue + } + seen[k.PublicKey] = true + if k.Protocol != 3 { + out = append(out, sdk.CheckState{ + Status: sdk.StatusCrit, + Code: "delegation_dnskey_bad_protocol", + Subject: fmt.Sprintf("keytag=%d@%s", k.ToMiekg().KeyTag(), c.NSName), + Message: fmt.Sprintf("DNSKEY has Protocol=%d, must be 3 (RFC 4034 §2.1)", k.Protocol), + Meta: map[string]any{"protocol": k.Protocol}, + }) + } + } + } + } + if len(out) == 0 { + if !hasSomeKeys { + return []sdk.CheckState{{ + Status: sdk.StatusUnknown, + Code: "delegation_dnskey_bad_protocol", + Message: "no DNSKEY records observed", + }} + } + return []sdk.CheckState{{ + Status: sdk.StatusOK, + Code: "delegation_dnskey_bad_protocol", + Message: "all DNSKEY records have Protocol=3", + }} + } + return out +} + +type dsCoversAllKSKsRule struct{} + +func (r *dsCoversAllKSKsRule) Name() string { return "delegation_dnskey_ksk_uncovered" } +func (r *dsCoversAllKSKsRule) Description() string { + return "Verifies that every KSK (SEP bit set) served by the child has a corresponding DS record at the parent" +} +func (r *dsCoversAllKSKsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { + data, errState := loadData(ctx, obs, "delegation_dnskey_ksk_uncovered") + if errState != nil { + return errState + } + if !parentHasAnyDS(data.ParentViews) { + return []sdk.CheckState{{ + Status: sdk.StatusUnknown, + Code: "delegation_dnskey_ksk_uncovered", + Message: "parent has no DS records, KSK coverage check skipped", + }} + } + var parentDS []*dns.DS + for _, v := range data.ParentViews { + if len(v.DS) > 0 { + parentDS = dsRecordsToMiekg(v.DS) + break + } + } + var out []sdk.CheckState + for _, c := range data.Children { + // Collect unique KSKs across all addresses of this NS, keyed by public key. + kskByKey := map[string]*dns.DNSKEY{} + for _, a := range c.Addresses { + for _, k := range a.DNSKEYs { + if k.Flags&0x0001 != 0 { + mk := k.ToMiekg() + kskByKey[mk.PublicKey] = mk + } + } + } + for _, ksk := range kskByKey { + covered := false + for _, d := range parentDS { + expected := ksk.ToDS(d.DigestType) + if expected != nil && dsEqual(expected, d) { + covered = true + break + } + } + st := sdk.CheckState{ + Code: "delegation_dnskey_ksk_uncovered", + Subject: fmt.Sprintf("keytag=%d@%s", ksk.KeyTag(), c.NSName), + } + if covered { + st.Status = sdk.StatusOK + st.Message = "KSK has a matching DS record at the parent" + } else { + st.Status = sdk.StatusCrit + st.Message = "KSK has no matching DS record at the parent" + } + out = append(out, st) + } + } + if len(out) == 0 { + return []sdk.CheckState{{ + Status: sdk.StatusUnknown, + Code: "delegation_dnskey_ksk_uncovered", + Message: "no KSK observed at any child server", + }} + } + return out +} + type nsHasAuthoritativeAnswerRule struct{} func (r *nsHasAuthoritativeAnswerRule) Name() string { From 70c548284e20d84bc677258510423bd495a7f934 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 16 May 2026 21:21:12 +0800 Subject: [PATCH 5/5] feat: add NS TTL consistency and NS-target CNAME checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- checker/collect.go | 9 ++++- checker/dns.go | 29 ++++++++++++--- checker/rule.go | 90 ++++++++++++++++++++++++++++++++++++++++++++++ checker/types.go | 3 ++ 4 files changed, 125 insertions(+), 6 deletions(-) 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"` }