Files
jarl/internal/validate/validate_test.go
T
abrown38andClaude Opus 5.5 bd6b8778e4 Jarl 0.1.0: manage BIND through a web UI, REST API and CLI
Jarl keeps a BIND 9.20 server's whole configuration (views, ACLs, TSIG
keys, zones of every type, records, named.conf options) in SQLite and
renders named.conf, keys.conf and zone files from it.

Every change goes through one changeset pipeline: whole-configuration
validation, named-checkconf -z and named-checkzone in a staging
directory, atomic install, rndc reconfig/reload, serial verification
with rndc zonestatus, and rollback if named does not load the change.
Changes apply one at a time by default or are bundled and applied
together. Applied changesets can be reverted.

- jarld: the daemon with the REST API and an embedded web UI, plus import,
  adopt, check, resync, backup and local user recovery commands
- jarl: the CLI, including ACME DNS-01 hooks for certbot and acme.sh
- users with argon2id passwords and CSRF-protected sessions; hashed API
  tokens with granular grants and roles; an audit log
- systemd unit, sysusers.d/tmpfiles.d files, Makefile
- unit tests, integration tests against a real named, scripts/e2e.sh
- docs/ guides, README and CHANGELOG

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

347 lines
12 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")
}
}