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>
This commit is contained in:
commit
bd6b8778e4
122 files changed
+29217
No files matched your search
@@ -0,0 +1,346 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user