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