439 lines
13 KiB
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)
|
|
}
|
|
}
|