Files
jarl/internal/validate/validate_test.go
T
abrown38andClaude Opus 5.5 3c73afe0e1 Fix the negative-caching warning and show warnings in the CLI
The soa_warning for negative caching fired on the SOA minimum alone and
blamed BIND's resolver cap (max-ncache-ttl), which doesn't apply to an
authoritative zone. Resolvers cache negative answers for the smaller of
the SOA minimum and the SOA record's TTL (RFC 2308 section 5), and the
SOA record uses the zone's default TTL. The warning now uses that value,
explains the effect, and suggests the fix command (with --view when the
zone is in a view).

Also:
- Problem.String shows zone names without the trailing dot (jarld check).
- jarl prints the warnings a change introduces when it is applied or
  staged, not only on dry runs.
- jarl check/resync print only named-checkconf errors, not its
  "loaded serial" lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:29:05 +00:00

383 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", "ns1")
}, "cname_chain", Warning},
{"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)
}
}