The "Busiest times" grid's hours without data used the class "empty", which picked up the padding of empty messages. One CNAME to another no longer warns; three or more get an info note showing the chain. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
397 lines
13 KiB
Go
397 lines
13 KiB
Go
package validate
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"jarl/internal/dnsname"
|
|
"jarl/internal/model"
|
|
"jarl/internal/rr"
|
|
)
|
|
|
|
type builder struct {
|
|
s *model.State
|
|
id int64
|
|
}
|
|
|
|
func newBuilder() *builder { return &builder{s: model.NewState()} }
|
|
|
|
func (b *builder) next() int64 { b.id++; return b.id }
|
|
|
|
func (b *builder) view(name string, clients ...string) *model.View {
|
|
v := &model.View{ID: b.next(), Name: name, Position: len(b.s.Views), MatchClients: clients}
|
|
b.s.Views[v.ID] = v
|
|
return v
|
|
}
|
|
|
|
func (b *builder) zone(name string, view *model.View, typ string) *model.Zone {
|
|
z := &model.Zone{ID: b.next(), Name: dnsname.Fqdn(name), Type: typ, DefaultTTL: 3600,
|
|
SOA: model.SOA{MName: "ns1." + dnsname.Fqdn(name), RName: "hostmaster." + dnsname.Fqdn(name), Serial: 1, Refresh: 3600, Retry: 900, Expire: 1209600, Minimum: 300}}
|
|
if view != nil {
|
|
z.ViewID = view.ID
|
|
}
|
|
b.s.Zones[z.ID] = z
|
|
return z
|
|
}
|
|
|
|
// primary creates a valid primary zone with NS and glue.
|
|
func (b *builder) primary(name string, view *model.View) *model.Zone {
|
|
z := b.zone(name, view, model.ZonePrimary)
|
|
b.rec(z, "@", "NS", "ns1")
|
|
b.rec(z, "ns1", "A", "192.0.2.1")
|
|
return z
|
|
}
|
|
|
|
func (b *builder) rec(z *model.Zone, name, typ, data string) *model.Record {
|
|
owner, err := dnsname.Owner(name, z.Name)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
p, err := rr.Parse(z.Name, owner, typ, data)
|
|
if err != nil {
|
|
panic(name + " " + typ + " " + data + ": " + err.Error())
|
|
}
|
|
r := &model.Record{ID: b.next(), ZoneID: z.ID, Name: owner, Type: p.Type, Data: p.Data}
|
|
b.s.Records[r.ID] = r
|
|
return r
|
|
}
|
|
|
|
func (b *builder) opt(scope string, scopeID int64, name, value string) *model.Option {
|
|
o := &model.Option{ID: b.next(), Scope: scope, ScopeID: scopeID, Name: name, Value: value}
|
|
b.s.Options[o.ID] = o
|
|
return o
|
|
}
|
|
|
|
func codes(ps []Problem, sev Severity) []string {
|
|
var out []string
|
|
for _, p := range ps {
|
|
if p.Severity == sev {
|
|
out = append(out, p.Code)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func hasCode(ps []Problem, code string) bool {
|
|
for _, p := range ps {
|
|
if p.Code == code {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func TestValidZoneIsClean(t *testing.T) {
|
|
b := newBuilder()
|
|
z := b.primary("example.com", nil)
|
|
b.rec(z, "www", "A", "192.0.2.10")
|
|
b.rec(z, "www", "AAAA", "2001:db8::10")
|
|
b.rec(z, "@", "MX", "10 mail")
|
|
b.rec(z, "mail", "A", "192.0.2.25")
|
|
b.rec(z, "@", "TXT", "v=spf1 mx -all")
|
|
b.rec(z, "_dmarc", "TXT", "v=DMARC1; p=none")
|
|
b.rec(z, "ftp", "CNAME", "www")
|
|
b.rec(z, "_sip._tcp", "SRV", "0 5 5060 www")
|
|
b.rec(z, "*", "A", "192.0.2.99")
|
|
b.rec(z, "sub", "NS", "ns.sub")
|
|
b.rec(z, "ns.sub", "A", "192.0.2.53")
|
|
b.rec(z, "sub", "DS", "12345 13 2 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
|
|
rz := b.primary("2.0.192.in-addr.arpa", nil)
|
|
rz.SOA.MName = "ns1.example.com."
|
|
b.rec(rz, "10", "PTR", "www.example.com.")
|
|
b.opt(model.ScopeOptions, 0, "recursion", "yes")
|
|
b.opt(model.ScopeOptions, 0, "allow-recursion", "{ 192.168.0.0/16; localhost; }")
|
|
ps := State(b.s, Options{})
|
|
for _, p := range ps {
|
|
if p.Severity != Info {
|
|
t.Errorf("unexpected problem: %s (%s)", p, p.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRecordConflicts(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
build func(b *builder, z *model.Zone)
|
|
code string
|
|
sev Severity
|
|
}{
|
|
{"cname and A", func(b *builder, z *model.Zone) {
|
|
b.rec(z, "www", "CNAME", "ns1")
|
|
b.rec(z, "www", "A", "192.0.2.2")
|
|
}, "cname_conflict", Error},
|
|
{"two cnames", func(b *builder, z *model.Zone) {
|
|
b.rec(z, "www", "CNAME", "ns1")
|
|
b.rec(z, "www", "CNAME", "other.example.org.")
|
|
}, "cname_multiple", Error},
|
|
{"cname at apex", func(b *builder, z *model.Zone) { b.rec(z, "@", "CNAME", "other.example.org.") }, "cname_apex", Error},
|
|
{"duplicate", func(b *builder, z *model.Zone) {
|
|
b.rec(z, "x", "A", "192.0.2.2")
|
|
b.rec(z, "x", "A", "192.0.2.2")
|
|
}, "record_duplicate", Error},
|
|
{"ttl mismatch", func(b *builder, z *model.Zone) {
|
|
r := b.rec(z, "x", "A", "192.0.2.2")
|
|
r.TTL = model.Ptr(uint32(60))
|
|
b.rec(z, "x", "A", "192.0.2.3")
|
|
}, "rrset_ttl_mismatch", Error},
|
|
{"mx to cname", func(b *builder, z *model.Zone) {
|
|
b.rec(z, "mail", "CNAME", "ns1")
|
|
b.rec(z, "@", "MX", "10 mail")
|
|
}, "mx_cname", Error},
|
|
{"srv to cname", func(b *builder, z *model.Zone) {
|
|
b.rec(z, "alias", "CNAME", "ns1")
|
|
b.rec(z, "_sip._tcp", "SRV", "0 0 5060 alias")
|
|
}, "srv_cname", Error},
|
|
{"missing glue", func(b *builder, z *model.Zone) { b.rec(z, "sub", "NS", "ns.sub") }, "glue_missing", Error},
|
|
{"apex ns without address", func(b *builder, z *model.Zone) { b.rec(z, "@", "NS", "ns2") }, "ns_no_address", Error},
|
|
{"ds not at delegation", func(b *builder, z *model.Zone) {
|
|
b.rec(z, "x", "DS", "12345 13 2 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
|
|
}, "ds_not_at_delegation", Error},
|
|
{"below dname", func(b *builder, z *model.Zone) {
|
|
b.rec(z, "d", "DNAME", "other.example.org.")
|
|
b.rec(z, "x.d", "A", "192.0.2.2")
|
|
}, "below_dname", Error},
|
|
{"occluded", func(b *builder, z *model.Zone) {
|
|
b.rec(z, "sub", "NS", "ns.other.org.")
|
|
b.rec(z, "www.sub", "A", "192.0.2.2")
|
|
}, "occluded", Warning},
|
|
{"cname loop", func(b *builder, z *model.Zone) {
|
|
b.rec(z, "a", "CNAME", "b")
|
|
b.rec(z, "b", "CNAME", "a")
|
|
}, "cname_loop", Error},
|
|
{"cname chain", func(b *builder, z *model.Zone) {
|
|
b.rec(z, "a", "CNAME", "b")
|
|
b.rec(z, "b", "CNAME", "c")
|
|
b.rec(z, "c", "CNAME", "ns1")
|
|
}, "cname_chain", Info},
|
|
{"dangling cname", func(b *builder, z *model.Zone) { b.rec(z, "a", "CNAME", "nothing") }, "cname_dangling", Warning},
|
|
{"null mx not alone", func(b *builder, z *model.Zone) {
|
|
b.rec(z, "@", "MX", "0 .")
|
|
b.rec(z, "@", "MX", "10 ns1")
|
|
}, "null_mx_not_alone", Error},
|
|
{"two spf", func(b *builder, z *model.Zone) {
|
|
b.rec(z, "@", "TXT", "v=spf1 mx -all")
|
|
b.rec(z, "@", "TXT", "v=spf1 a -all")
|
|
}, "spf_multiple", Warning},
|
|
{"mx target missing", func(b *builder, z *model.Zone) { b.rec(z, "@", "MX", "10 nomail") }, "target_no_address", Warning},
|
|
{"ns to cname", func(b *builder, z *model.Zone) {
|
|
b.rec(z, "nsalias", "CNAME", "ns1")
|
|
b.rec(z, "@", "NS", "nsalias")
|
|
}, "ns_cname", Error},
|
|
}
|
|
for _, c := range cases {
|
|
b := newBuilder()
|
|
z := b.primary("example.com", nil)
|
|
c.build(b, z)
|
|
ps := State(b.s, Options{})
|
|
found := false
|
|
for _, p := range ps {
|
|
if p.Code == c.code && p.Severity == c.sev {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("%s: expected %s %s, got errors %v warnings %v", c.name, c.sev, c.code, codes(ps, Error), codes(ps, Warning))
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestZoneRules(t *testing.T) {
|
|
b := newBuilder()
|
|
b.zone("empty.com", nil, model.ZonePrimary) // no NS
|
|
b.zone("sec.com", nil, model.ZoneSecondary) // no primaries
|
|
ps := State(b.s, Options{})
|
|
if !hasCode(ps, "apex_ns_missing") || !hasCode(ps, "zone_missing_option") {
|
|
t.Errorf("got %v", codes(ps, Error))
|
|
}
|
|
|
|
// Views: zones outside views are not allowed once views exist.
|
|
b = newBuilder()
|
|
b.primary("example.com", nil)
|
|
b.view("internal", "192.168.0.0/16")
|
|
ps = State(b.s, Options{})
|
|
if !hasCode(ps, "zone_outside_view") {
|
|
t.Errorf("zone outside view not reported: %v", codes(ps, Error))
|
|
}
|
|
|
|
// in-view must reference an earlier view.
|
|
b = newBuilder()
|
|
ext := b.view("external")
|
|
in := b.view("internal", "10/8")
|
|
b.primary("example.com", in)
|
|
iv := b.zone("example.com", ext, model.ZoneInView)
|
|
iv.InView = "internal"
|
|
ps = State(b.s, Options{})
|
|
if !hasCode(ps, "zone_inview_invalid") {
|
|
t.Errorf("in-view order not checked: %v", codes(ps, Error))
|
|
}
|
|
if !hasCode(ps, "view_unreachable") {
|
|
t.Errorf("unreachable view not reported: %v", codes(ps, Warning))
|
|
}
|
|
in.Position, ext.Position = 0, 1
|
|
ps = State(b.s, Options{})
|
|
if HasErrors(ps) {
|
|
t.Errorf("valid in-view setup has errors: %v", Errors(ps))
|
|
}
|
|
}
|
|
|
|
func TestACLsAndOptions(t *testing.T) {
|
|
b := newBuilder()
|
|
b.s.ACLs[1] = &model.ACL{ID: 1, Name: "lan", Elements: []string{"192.168.1.0/24", "!192.168.1.5", "missing", "key nokey"}}
|
|
b.s.ACLs[2] = &model.ACL{ID: 2, Name: "a", Elements: []string{"b"}}
|
|
b.s.ACLs[3] = &model.ACL{ID: 3, Name: "b", Elements: []string{"a"}}
|
|
b.s.ACLs[4] = &model.ACL{ID: 4, Name: "any", Elements: []string{"10.0.0.0/8"}}
|
|
b.s.ACLs[5] = &model.ACL{ID: 5, Name: "bad", Elements: []string{"10.0.0.1/8"}}
|
|
b.id = 10
|
|
b.opt(model.ScopeOptions, 0, "recursion", "maybe")
|
|
b.opt(model.ScopeOptions, 0, "frobnicate", "yes")
|
|
b.opt(model.ScopeOptions, 0, "allow-query", "{ lan; ghost; }")
|
|
b.opt(model.ScopeOptions, 0, "allow-transfer", "{ key nope; }")
|
|
b.opt(model.ScopeOptions, 0, "directory", `"/var/cache/bind"`)
|
|
b.opt(model.ScopeOptions, 0, "directory", `"/tmp"`)
|
|
b.opt(model.ScopeOptions, 0, "dnssec-validation", "sometimes")
|
|
b.opt(model.ScopeTopLevel, 0, "acl", "x { any; }")
|
|
ps := State(b.s, Options{})
|
|
for _, want := range []string{"acl_ref_missing", "key_ref_missing", "acl_cycle", "acl_name_invalid", "acl_element_invalid", "option_value_invalid", "option_unknown", "option_duplicate", "option_managed"} {
|
|
if !hasCode(ps, want) {
|
|
t.Errorf("missing %s; got %v", want, codes(ps, Error))
|
|
}
|
|
}
|
|
if !hasCode(ps, "acl_shadowed") {
|
|
t.Errorf("shadowed element not reported: %v", codes(ps, Warning))
|
|
}
|
|
ps = State(b.s, Options{ExternalKeysUnknown: true})
|
|
for _, p := range ps {
|
|
if p.Code == "key_ref_missing" {
|
|
t.Error("key refs reported despite ExternalKeysUnknown")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSecurityLints(t *testing.T) {
|
|
// allow-query any without allow-recursion: open resolver.
|
|
b := newBuilder()
|
|
b.primary("example.com", nil)
|
|
b.opt(model.ScopeOptions, 0, "allow-query", "{ any; }")
|
|
ps := State(b.s, Options{})
|
|
if !hasCode(ps, "security_open_resolver") {
|
|
t.Errorf("open resolver not detected: %v", codes(ps, Warning))
|
|
}
|
|
// Fixed by recursion no.
|
|
b.opt(model.ScopeOptions, 0, "recursion", "no")
|
|
if hasCode(State(b.s, Options{}), "security_open_resolver") {
|
|
t.Error("open resolver reported with recursion no")
|
|
}
|
|
// Through an ACL that contains any.
|
|
b = newBuilder()
|
|
b.s.ACLs[100] = &model.ACL{ID: 100, Name: "everyone", Elements: []string{"any"}}
|
|
b.opt(model.ScopeOptions, 0, "allow-recursion", "{ everyone; }")
|
|
if !hasCode(State(b.s, Options{}), "security_open_resolver") {
|
|
t.Error("open resolver via ACL not detected")
|
|
}
|
|
// Inside a view that only matches internal clients it's fine.
|
|
b = newBuilder()
|
|
v := b.view("internal", "192.168.0.0/16")
|
|
b.opt(model.ScopeView, v.ID, "allow-recursion", "{ any; }")
|
|
if hasCode(State(b.s, Options{}), "security_open_resolver") {
|
|
t.Error("open resolver reported for internal-only view")
|
|
}
|
|
// Dynamic updates are rejected.
|
|
b = newBuilder()
|
|
z := b.primary("example.com", nil)
|
|
b.opt(model.ScopeZone, z.ID, "allow-update", "{ key ddns; }")
|
|
ps = State(b.s, Options{ExternalKeys: []string{"ddns"}})
|
|
if !hasCode(ps, "dynamic_update_unsupported") {
|
|
t.Errorf("allow-update not rejected: %v", codes(ps, Error))
|
|
}
|
|
b.s.Options[b.id].Value = "{ none; }"
|
|
if hasCode(State(b.s, Options{}), "dynamic_update_unsupported") {
|
|
t.Error("allow-update { none; } rejected")
|
|
}
|
|
// Open transfers.
|
|
b.opt(model.ScopeZone, z.ID, "allow-transfer", "{ any; }")
|
|
if !hasCode(State(b.s, Options{}), "security_open_transfer") {
|
|
t.Error("open transfer not detected")
|
|
}
|
|
}
|
|
|
|
func TestIntroduced(t *testing.T) {
|
|
b := newBuilder()
|
|
z := b.primary("example.com", nil)
|
|
before := State(b.s, Options{})
|
|
b.rec(z, "www", "CNAME", "ns1")
|
|
b.rec(z, "www", "TXT", "x")
|
|
after := State(b.s, Options{})
|
|
intro := Introduced(before, after)
|
|
if len(intro) == 0 || intro[0].Code != "cname_conflict" {
|
|
t.Errorf("Introduced = %v", intro)
|
|
}
|
|
if len(Introduced(after, after)) != 0 {
|
|
t.Error("same state introduced problems")
|
|
}
|
|
}
|
|
|
|
func TestRecordLimit(t *testing.T) {
|
|
b := newBuilder()
|
|
z := b.primary("example.com", nil)
|
|
for i := 0; i < 101; i++ {
|
|
b.rec(z, "many", "TXT", strings.Repeat("x", i+1))
|
|
}
|
|
if !hasCode(State(b.s, Options{}), "rrset_too_large") {
|
|
t.Error("RRset over 100 records not reported")
|
|
}
|
|
b.opt(model.ScopeOptions, 0, "max-records-per-type", "0")
|
|
if hasCode(State(b.s, Options{}), "rrset_too_large") {
|
|
t.Error("max-records-per-type 0 (unlimited) not honoured")
|
|
}
|
|
}
|
|
|
|
func TestNegativeCachingTTL(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
defaultTTL, minimum uint32
|
|
warn string // "" for no warning
|
|
}{
|
|
{3600, 300, ""},
|
|
{3600, 86400, ""}, // the SOA's own TTL (1h) limits negative caching
|
|
{86400, 10800, ""}, // 3 hours is fine
|
|
{86400, 86400, "1 day"}, // both long: negative answers live a day
|
|
{43200, 86400, "12 hours"},
|
|
} {
|
|
b := newBuilder()
|
|
z := b.primary("example.com", nil)
|
|
z.DefaultTTL, z.SOA.Minimum = tc.defaultTTL, tc.minimum
|
|
var got *Problem
|
|
for _, p := range State(b.s, Options{}) {
|
|
if p.Code == "soa_warning" && strings.Contains(p.Message, "negative") {
|
|
got = &p
|
|
}
|
|
}
|
|
switch {
|
|
case tc.warn == "" && got != nil:
|
|
t.Errorf("TTL %d, minimum %d: unexpected warning %q", tc.defaultTTL, tc.minimum, got.Message)
|
|
case tc.warn != "" && (got == nil || !strings.Contains(got.Message, tc.warn)):
|
|
t.Errorf("TTL %d, minimum %d: want a warning mentioning %q, got %v", tc.defaultTTL, tc.minimum, tc.warn, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestProblemStringDisplaysZone(t *testing.T) {
|
|
p := Problem{Severity: Warning, Message: "m", Zone: "10.0.10.in-addr.arpa.", View: "internal"}
|
|
if got, want := p.String(), "warning: zone 10.0.10.in-addr.arpa [view internal]: m"; got != want {
|
|
t.Errorf("String() = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
// One CNAME to another is common and says nothing.
|
|
func TestShortCNAMEChain(t *testing.T) {
|
|
b := newBuilder()
|
|
z := b.primary("example.com", nil)
|
|
b.rec(z, "songshare", "CNAME", "navidrome")
|
|
b.rec(z, "navidrome", "CNAME", "ns1")
|
|
for _, p := range State(b.s, Options{}) {
|
|
if p.Code == "cname_chain" {
|
|
t.Errorf("one extra CNAME: %s %s", p.Severity, p.Message)
|
|
}
|
|
}
|
|
}
|