checker-delegation/checker/rule_test.go

439 lines
13 KiB
Go

package checker
import (
"context"
"encoding/json"
"strings"
"testing"
"github.com/miekg/dns"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// fakeObs is a tiny ObservationGetter that serves a single pre-built
// DelegationData payload for the delegation key.
type fakeObs struct {
data *DelegationData
err error
}
func (f *fakeObs) Get(_ context.Context, key sdk.ObservationKey, dest any) error {
if f.err != nil {
return f.err
}
if key != ObservationKeyDelegation {
return &errString{"unexpected key " + string(key)}
}
raw, err := json.Marshal(f.data)
if err != nil {
return err
}
return json.Unmarshal(raw, dest)
}
func (f *fakeObs) GetRelated(_ context.Context, _ sdk.ObservationKey) ([]sdk.RelatedObservation, error) {
return nil, nil
}
type errString struct{ s string }
func (e *errString) Error() string { return e.s }
// statusByCode indexes states by Code, asserting one state per code.
func statusByCode(t *testing.T, states []sdk.CheckState) map[string]sdk.CheckState {
t.Helper()
out := map[string]sdk.CheckState{}
for _, s := range states {
out[s.Code+"|"+s.Subject] = s
}
return out
}
func evalRule(t *testing.T, r sdk.CheckRule, data *DelegationData, opts sdk.CheckerOptions) []sdk.CheckState {
t.Helper()
if opts == nil {
opts = sdk.CheckerOptions{}
}
return r.Evaluate(context.Background(), &fakeObs{data: data}, opts)
}
func TestMinNameServersRule(t *testing.T) {
r := &minNameServersRule{}
t.Run("warn when below default minimum", func(t *testing.T) {
states := evalRule(t, r, &DelegationData{DeclaredNS: []string{"a."}}, nil)
if len(states) != 1 || states[0].Status != sdk.StatusWarn {
t.Fatalf("want one Warn state, got %+v", states)
}
})
t.Run("ok when at minimum", func(t *testing.T) {
states := evalRule(t, r, &DelegationData{DeclaredNS: []string{"a.", "b."}}, nil)
if len(states) != 1 || states[0].Status != sdk.StatusOK {
t.Fatalf("want one OK state, got %+v", states)
}
})
t.Run("respects custom minimum", func(t *testing.T) {
opts := sdk.CheckerOptions{"minNameServers": float64(3)}
states := evalRule(t, r, &DelegationData{DeclaredNS: []string{"a.", "b."}}, opts)
if states[0].Status != sdk.StatusWarn {
t.Fatalf("want Warn with min=3 and 2 NS, got %+v", states)
}
})
}
func TestParentDiscoveredRule(t *testing.T) {
r := &parentDiscoveredRule{}
cases := []struct {
name string
data *DelegationData
want sdk.Status
}{
{"discovery error", &DelegationData{ParentDiscoveryError: "boom"}, sdk.StatusCrit},
{"no parent ns", &DelegationData{}, sdk.StatusCrit},
{"ok", &DelegationData{ParentNS: []string{"1.2.3.4:53"}}, sdk.StatusOK},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
states := evalRule(t, r, tc.data, nil)
if len(states) != 1 || states[0].Status != tc.want {
t.Fatalf("want %v, got %+v", tc.want, states)
}
})
}
}
func TestNSMatchesDeclaredRule(t *testing.T) {
r := &nsMatchesDeclaredRule{}
data := &DelegationData{
DelegatedFQDN: "www.example.com.",
DeclaredNS: []string{"ns1.example.net.", "ns2.example.net."},
ParentViews: []ParentView{
{Server: "p1:53", NS: []string{"ns1.example.net.", "ns2.example.net."}}, // match
{Server: "p2:53", NS: []string{"ns1.example.net.", "ns3.example.net."}}, // mismatch
{Server: "p3:53", UDPNSError: "timeout"}, // skipped
},
}
states := evalRule(t, r, data, nil)
idx := statusByCode(t, states)
if s := idx["delegation_ns_mismatch|p1:53"]; s.Status != sdk.StatusOK {
t.Errorf("p1: want OK, got %+v", s)
}
if s := idx["delegation_ns_mismatch|p2:53"]; s.Status != sdk.StatusCrit {
t.Errorf("p2: want Crit, got %+v", s)
}
if _, ok := idx["delegation_ns_mismatch|p3:53"]; ok {
t.Errorf("p3 should be skipped, got %+v", idx)
}
}
func TestInBailiwickGlueRule(t *testing.T) {
r := &inBailiwickGlueRule{}
data := &DelegationData{
DelegatedFQDN: "example.com.",
ParentViews: []ParentView{{
Server: "p:53",
NS: []string{"ns1.example.com.", "ns2.elsewhere.net."},
Glue: map[string][]string{
"ns1.example.com.": {"192.0.2.1"},
},
}},
}
states := evalRule(t, r, data, nil)
var sawOK, sawMissing, sawOOB bool
for _, s := range states {
switch {
case strings.HasPrefix(s.Subject, "ns1.example.com."):
if s.Status == sdk.StatusOK {
sawOK = true
}
case strings.HasPrefix(s.Subject, "ns2.elsewhere.net."):
sawOOB = true // out-of-bailiwick: rule must not emit a state for it
}
if s.Status == sdk.StatusCrit {
sawMissing = true
}
}
if !sawOK {
t.Error("expected OK state for in-bailiwick NS with glue")
}
if sawMissing {
t.Error("did not expect Crit (no in-bailiwick NS is missing glue)")
}
if sawOOB {
t.Error("out-of-bailiwick NS must be ignored by inBailiwickGlueRule")
}
}
func TestUnnecessaryGlueRule(t *testing.T) {
r := &unnecessaryGlueRule{}
data := &DelegationData{
DelegatedFQDN: "example.com.",
ParentViews: []ParentView{{
Server: "p:53",
NS: []string{"ns1.elsewhere.net."},
Glue: map[string][]string{"ns1.elsewhere.net.": {"192.0.2.5"}},
}},
}
states := evalRule(t, r, data, nil)
if len(states) != 1 || states[0].Status != sdk.StatusWarn {
t.Fatalf("want single Warn, got %+v", states)
}
}
func TestDSPresentAtParentRule_RequireDS(t *testing.T) {
r := &dsPresentAtParentRule{}
data := &DelegationData{
DeclaredDS: []DSRecord{{KeyTag: 1, Algorithm: 8, DigestType: 2, Digest: "AAAA"}},
ParentViews: []ParentView{{Server: "p:53"}}, // no DS at parent
}
t.Run("default is informational", func(t *testing.T) {
states := evalRule(t, r, data, nil)
if states[0].Status != sdk.StatusInfo {
t.Fatalf("want Info, got %+v", states)
}
})
t.Run("requireDS escalates to Crit", func(t *testing.T) {
states := evalRule(t, r, data, sdk.CheckerOptions{"requireDS": true})
if states[0].Status != sdk.StatusCrit {
t.Fatalf("want Crit with requireDS, got %+v", states)
}
})
}
func TestChildAuthoritativeRule(t *testing.T) {
r := &childAuthoritativeRule{}
data := &DelegationData{
Children: []ChildNSView{{
NSName: "ns1.example.com.",
Addresses: []ChildAddressView{
{Address: "192.0.2.1", Authoritative: true},
{Address: "192.0.2.2", Authoritative: false},
{Address: "192.0.2.3", UDPError: "timeout"}, // skipped
},
}},
}
states := evalRule(t, r, data, nil)
if len(states) != 2 {
t.Fatalf("want 2 states (skip the UDP failure), got %d: %+v", len(states), states)
}
var foundCrit bool
for _, s := range states {
if s.Status == sdk.StatusCrit {
foundCrit = true
}
}
if !foundCrit {
t.Error("expected at least one Crit (the lame address)")
}
}
func TestChildSOASerialDriftRule(t *testing.T) {
r := &childSOASerialDriftRule{}
data := &DelegationData{
Children: []ChildNSView{{
NSName: "ns1.example.com.",
Addresses: []ChildAddressView{
{Address: "192.0.2.1", SOASerial: 1, SOASerialKnown: true},
{Address: "192.0.2.2", SOASerial: 2, SOASerialKnown: true},
},
}, {
NSName: "ns2.example.com.",
Addresses: []ChildAddressView{
{Address: "192.0.2.3", SOASerial: 7, SOASerialKnown: true},
{Address: "192.0.2.4", SOASerial: 7, SOASerialKnown: true},
},
}},
}
states := evalRule(t, r, data, nil)
if len(states) != 2 {
t.Fatalf("want 2 states, got %d", len(states))
}
bySubject := map[string]sdk.Status{}
for _, s := range states {
bySubject[s.Subject] = s.Status
}
if bySubject["ns1.example.com."] != sdk.StatusWarn {
t.Errorf("ns1 drift: want Warn, got %v", bySubject["ns1.example.com."])
}
if bySubject["ns2.example.com."] != sdk.StatusOK {
t.Errorf("ns2 agreement: want OK, got %v", bySubject["ns2.example.com."])
}
}
func TestChildTCPRule_OptionToggle(t *testing.T) {
r := &childTCPRule{}
data := &DelegationData{
Children: []ChildNSView{{
NSName: "ns1.example.com.",
Addresses: []ChildAddressView{
{Address: "192.0.2.1", TCPError: "connection refused"},
},
}},
}
t.Run("default requireTCP=true → Crit", func(t *testing.T) {
states := evalRule(t, r, data, nil)
if states[0].Status != sdk.StatusCrit {
t.Fatalf("want Crit, got %+v", states)
}
})
t.Run("requireTCP=false → Warn", func(t *testing.T) {
states := evalRule(t, r, data, sdk.CheckerOptions{"requireTCP": false})
if states[0].Status != sdk.StatusWarn {
t.Fatalf("want Warn, got %+v", states)
}
})
}
func TestChildGlueMatchesParentRule(t *testing.T) {
r := &childGlueMatchesParentRule{}
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", "192.0.2.2"}},
}},
Children: []ChildNSView{{
NSName: "ns1.example.com.",
Addresses: []ChildAddressView{
{Address: "192.0.2.1", ChildGlueAddrs: []string{"192.0.2.1"}}, // missing .2 → mismatch
},
}},
}
t.Run("default → Crit", func(t *testing.T) {
states := evalRule(t, r, data, nil)
if states[0].Status != sdk.StatusCrit {
t.Fatalf("want Crit, got %+v", states)
}
})
t.Run("allowGlueMismatch → Warn", func(t *testing.T) {
states := evalRule(t, r, data, sdk.CheckerOptions{"allowGlueMismatch": true})
if states[0].Status != sdk.StatusWarn {
t.Fatalf("want Warn, got %+v", states)
}
})
}
func TestNSHasAuthoritativeAnswerRule(t *testing.T) {
r := &nsHasAuthoritativeAnswerRule{}
data := &DelegationData{
Children: []ChildNSView{
{
NSName: "ok.example.com.",
Addresses: []ChildAddressView{
{Address: "192.0.2.1", Authoritative: false},
{Address: "192.0.2.2", Authoritative: true},
},
},
{
NSName: "lame.example.com.",
Addresses: []ChildAddressView{
{Address: "192.0.2.3", Authoritative: false},
},
},
},
}
states := evalRule(t, r, data, nil)
bySubject := map[string]sdk.Status{}
for _, s := range states {
bySubject[s.Subject] = s.Status
}
if bySubject["ok.example.com."] != sdk.StatusOK {
t.Errorf("ok.example.com.: want OK, got %v", bySubject["ok.example.com."])
}
if bySubject["lame.example.com."] != sdk.StatusCrit {
t.Errorf("lame.example.com.: want Crit, got %v", bySubject["lame.example.com."])
}
}
func TestDNSKEYMatchesDSRule_Match(t *testing.T) {
// Build a key, derive its DS, and verify the rule passes when child serves
// that key and parent serves that DS.
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/LwnPVykcmpvnntwxfshHlHRhlY0F3oz8AMcuF8gw" +
"2Ge56vG9oqVxTzHl4Ss2dEqCQOjFlOVo+pa3JwIO1lUzbQ==",
}
ds := key.ToDS(dns.SHA256)
if ds == nil {
t.Fatal("derive DS")
}
data := &DelegationData{
ParentViews: []ParentView{{Server: "p:53", DS: []DSRecord{NewDSRecord(ds)}}},
Children: []ChildNSView{{
NSName: "ns1.example.com.",
Addresses: []ChildAddressView{
{Address: "192.0.2.1", DNSKEYs: []DNSKEYRecord{NewDNSKEYRecord(key)}},
},
}},
}
r := &dnskeyMatchesDSRule{}
states := evalRule(t, r, data, nil)
if len(states) != 1 || states[0].Status != sdk.StatusOK {
t.Fatalf("want OK match, got %+v", states)
}
}
func TestDNSKEYMatchesDSRule_NoMatch(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/LwnPVykcmpvnntwxfshHlHRhlY0F3oz8AMcuF8gw" +
"2Ge56vG9oqVxTzHl4Ss2dEqCQOjFlOVo+pa3JwIO1lUzbQ==",
}
bogus := &dns.DS{KeyTag: 1, Algorithm: 8, DigestType: 2, Digest: "00"}
data := &DelegationData{
ParentViews: []ParentView{{Server: "p:53", DS: []DSRecord{NewDSRecord(bogus)}}},
Children: []ChildNSView{{
NSName: "ns1.example.com.",
Addresses: []ChildAddressView{
{Address: "192.0.2.1", DNSKEYs: []DNSKEYRecord{NewDNSKEYRecord(key)}},
},
}},
}
states := evalRule(t, (&dnskeyMatchesDSRule{}), data, nil)
if len(states) != 1 || states[0].Status != sdk.StatusCrit {
t.Fatalf("want Crit, got %+v", states)
}
}
func TestRulesReturnsAllRules(t *testing.T) {
rules := Rules()
if len(rules) == 0 {
t.Fatal("expected at least one rule")
}
// Every rule must have a non-empty name and description, and must be
// safely evaluable against an empty DelegationData (no panics).
seen := map[string]bool{}
for _, r := range rules {
if r.Name() == "" {
t.Errorf("rule %T has empty name", r)
}
if r.Description() == "" {
t.Errorf("rule %s has empty description", r.Name())
}
if seen[r.Name()] {
t.Errorf("duplicate rule name: %s", r.Name())
}
seen[r.Name()] = true
states := r.Evaluate(context.Background(), &fakeObs{data: &DelegationData{}}, sdk.CheckerOptions{})
if len(states) == 0 {
t.Errorf("rule %s returned no states for empty data", r.Name())
}
}
}
func TestLoadDataPropagatesError(t *testing.T) {
r := &minNameServersRule{}
states := r.Evaluate(context.Background(), &fakeObs{err: &errString{"boom"}}, sdk.CheckerOptions{})
if len(states) != 1 || states[0].Status != sdk.StatusError {
t.Fatalf("want single Error state, got %+v", states)
}
}