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,452 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/render"
|
||||
"jarl/internal/store"
|
||||
)
|
||||
|
||||
type testEnv struct {
|
||||
t *testing.T
|
||||
ctx context.Context
|
||||
e *Engine
|
||||
st *store.Store
|
||||
admin *auth.Principal
|
||||
dir string
|
||||
}
|
||||
|
||||
func newEnv(t *testing.T) *testEnv {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
st, err := store.Open(filepath.Join(dir, "jarl.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
cfg := Config{
|
||||
Paths: render.Paths{ConfigDir: filepath.Join(dir, "etc"), ZoneDir: filepath.Join(dir, "zones"), CacheDir: filepath.Join(dir, "cache")},
|
||||
StagingDir: filepath.Join(dir, "staging"),
|
||||
NoReload: true,
|
||||
SkipCheck: true,
|
||||
}
|
||||
e := New(st, nil, cfg, nil)
|
||||
e.Now = func() time.Time { return time.Date(2026, 10, 7, 12, 0, 0, 0, time.UTC) }
|
||||
admin := &model.User{Username: "admin", Admin: true}
|
||||
if err := st.CreateUser(context.Background(), admin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env := &testEnv{t: t, ctx: context.Background(), e: e, st: st, admin: &auth.Principal{User: admin, Method: "local"}, dir: dir}
|
||||
// Defaults for new zones.
|
||||
env.submit(env.admin, NewRequest(OpSettingsUpdate, 0, SettingsInput{Nameservers: &[]string{"ns1.example.com."}, RName: model.Ptr("hostmaster@example.com")}))
|
||||
return env
|
||||
}
|
||||
|
||||
func (env *testEnv) submit(p *auth.Principal, reqs ...Request) *Result {
|
||||
env.t.Helper()
|
||||
res, err := env.e.Submit(env.ctx, reqs, p, "test", "", ApplyOptions{})
|
||||
if err != nil {
|
||||
env.t.Fatalf("submit %v: %v (%+v)", reqs[0].Op, err, res)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func (env *testEnv) submitErr(p *auth.Principal, reqs ...Request) *Error {
|
||||
env.t.Helper()
|
||||
_, err := env.e.Submit(env.ctx, reqs, p, "test", "", ApplyOptions{})
|
||||
if err == nil {
|
||||
env.t.Fatalf("submit %v succeeded, want error", reqs[0].Op)
|
||||
}
|
||||
ee, ok := AsError(err)
|
||||
if !ok {
|
||||
env.t.Fatalf("unexpected error type %T: %v", err, err)
|
||||
}
|
||||
return ee
|
||||
}
|
||||
|
||||
func (env *testEnv) state() *model.State {
|
||||
s, err := env.st.LoadState(env.ctx)
|
||||
if err != nil {
|
||||
env.t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func rec(zone, name, typ, data string) Request {
|
||||
return NewRequest(OpRecordCreate, 0, RecordInput{Zone: zone, Name: &name, Type: &typ, Data: &data})
|
||||
}
|
||||
|
||||
func (env *testEnv) records(zone string) []string {
|
||||
s := env.state()
|
||||
zs := s.ZonesNamed(zone)
|
||||
var out []string
|
||||
for _, r := range s.ZoneRecords(zs[0].ID) {
|
||||
out = append(out, r.Name+" "+r.Type+" "+r.Data)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestZoneLifecycle(t *testing.T) {
|
||||
env := newEnv(t)
|
||||
res := env.submit(env.admin,
|
||||
NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com")}),
|
||||
rec("example.com", "ns1", "A", "192.0.2.1"),
|
||||
rec("example.com", "www", "A", "192.0.2.10"),
|
||||
)
|
||||
if !res.Applied || len(res.Files) != 2 || !strings.HasSuffix(res.Files[0], "etc/named.conf") || !strings.HasSuffix(res.Files[1], "example.com.zone") {
|
||||
t.Fatalf("result = %+v", res)
|
||||
}
|
||||
s := env.state()
|
||||
z := s.ZonesNamed("example.com")[0]
|
||||
if z.SOA.Serial != 2026100700 || z.SOA.MName != "ns1.example.com." || z.SOA.RName != "hostmaster.example.com." || z.DefaultTTL != 3600 {
|
||||
t.Errorf("zone defaults = %+v", z.SOA)
|
||||
}
|
||||
got := strings.Join(env.records("example.com"), "|")
|
||||
if got != "example.com. NS ns1.example.com.|ns1.example.com. A 192.0.2.1|www.example.com. A 192.0.2.10" {
|
||||
t.Errorf("records = %s", got)
|
||||
}
|
||||
zf, err := os.ReadFile(filepath.Join(env.dir, "zones/_default/example.com.zone"))
|
||||
if err != nil || !strings.Contains(string(zf), "www\t\tIN\tA\t192.0.2.10") {
|
||||
t.Errorf("zone file: %v\n%s", err, zf)
|
||||
}
|
||||
// A change bumps the serial once.
|
||||
env.submit(env.admin, rec("example.com", "mail", "A", "192.0.2.25"), rec("example.com", "@", "MX", "10 mail"))
|
||||
if s := env.state().ZonesNamed("example.com")[0].SOA.Serial; s != 2026100701 {
|
||||
t.Errorf("serial after change = %d", s)
|
||||
}
|
||||
// Comment-only changes don't.
|
||||
r := findRec(env.state(), "www.example.com.", "A")
|
||||
env.submit(env.admin, NewRequest(OpRecordUpdate, r.ID, RecordInput{Comment: model.Ptr("web server")}))
|
||||
if s := env.state().ZonesNamed("example.com")[0].SOA.Serial; s != 2026100701 {
|
||||
t.Errorf("serial after comment change = %d", s)
|
||||
}
|
||||
}
|
||||
|
||||
func findRec(s *model.State, name, typ string) *model.Record {
|
||||
for _, r := range s.Records {
|
||||
if r.Name == name && r.Type == typ {
|
||||
return r
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func setupZone(env *testEnv) {
|
||||
env.submit(env.admin,
|
||||
NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com")}),
|
||||
rec("example.com", "ns1", "A", "192.0.2.1"),
|
||||
)
|
||||
}
|
||||
|
||||
func TestConflictsRejected(t *testing.T) {
|
||||
env := newEnv(t)
|
||||
setupZone(env)
|
||||
env.submit(env.admin, rec("example.com", "www", "CNAME", "ns1"))
|
||||
// The headline rule: no A record next to a CNAME.
|
||||
e := env.submitErr(env.admin, rec("example.com", "www", "A", "192.0.2.10"))
|
||||
if e.Kind != KindValidation || !strings.Contains(e.Message, "CNAME") {
|
||||
t.Errorf("error = %+v", e)
|
||||
}
|
||||
// Bad data is rejected before validation.
|
||||
e = env.submitErr(env.admin, rec("example.com", "bad", "A", "300.1.1.1"))
|
||||
if e.Kind != KindBadRequest {
|
||||
t.Errorf("bad data error = %+v", e)
|
||||
}
|
||||
// Swapping CNAME for A works when both happen in one changeset.
|
||||
r := findRec(env.state(), "www.example.com.", "CNAME")
|
||||
env.submit(env.admin, NewRequest(OpRecordDelete, r.ID, nil), rec("example.com", "www", "A", "192.0.2.10"))
|
||||
if findRec(env.state(), "www.example.com.", "A") == nil {
|
||||
t.Error("swap failed")
|
||||
}
|
||||
// Nothing is stored when a submit fails.
|
||||
cs, _ := env.st.ListChangesets(env.ctx, store.ChangesetFilter{})
|
||||
for _, c := range cs {
|
||||
if c.Status != model.ChangesetApplied {
|
||||
t.Errorf("unexpected changeset %+v", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStagedChangeset(t *testing.T) {
|
||||
env := newEnv(t)
|
||||
setupZone(env)
|
||||
cs, err := env.e.CreateChangeset(env.ctx, env.admin, "web", "add web")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := env.e.Stage(env.ctx, cs.ID, []Request{rec("example.com", "www", "CNAME", "ns1")}, env.admin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Staging a conflicting record is rejected right away.
|
||||
if _, err := env.e.Stage(env.ctx, cs.ID, []Request{rec("example.com", "www", "TXT", "hello")}, env.admin); err == nil {
|
||||
t.Error("conflicting record staged")
|
||||
}
|
||||
prev, err := env.e.Preview(env.ctx, cs.ID, env.admin)
|
||||
if err != nil || !prev.Valid || !strings.Contains(prev.Diff, "+www\t\tIN\tCNAME\tns1.example.com.") {
|
||||
t.Fatalf("preview = %+v %v", prev, err)
|
||||
}
|
||||
if findRec(env.state(), "www.example.com.", "CNAME") != nil {
|
||||
t.Error("preview changed the state")
|
||||
}
|
||||
// Someone else changes the configuration meanwhile.
|
||||
env.submit(env.admin, rec("example.com", "www", "A", "192.0.2.10"))
|
||||
if _, err := env.e.Apply(env.ctx, cs.ID, env.admin, ApplyOptions{}); err == nil {
|
||||
t.Fatal("stale changeset applied")
|
||||
}
|
||||
// Staging the delete of the conflicting A record fixes the
|
||||
// changeset (it removes an error rather than adding one).
|
||||
a := findRec(env.state(), "www.example.com.", "A")
|
||||
if _, err := env.e.Stage(env.ctx, cs.ID, []Request{NewRequest(OpRecordDelete, a.ID, nil)}, env.admin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err := env.e.Apply(env.ctx, cs.ID, env.admin, ApplyOptions{})
|
||||
if err != nil || !res.Applied {
|
||||
t.Fatalf("apply: %v %+v", err, res)
|
||||
}
|
||||
got, _ := env.st.GetChangeset(env.ctx, cs.ID)
|
||||
if got.Status != model.ChangesetApplied || got.Diff == "" {
|
||||
t.Errorf("changeset = %+v", got)
|
||||
}
|
||||
if _, err := env.e.Stage(env.ctx, cs.ID, []Request{rec("example.com", "x", "A", "192.0.2.3")}, env.admin); err == nil {
|
||||
t.Error("staged into applied changeset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionConflict(t *testing.T) {
|
||||
env := newEnv(t)
|
||||
setupZone(env)
|
||||
env.submit(env.admin, rec("example.com", "www", "A", "192.0.2.10"))
|
||||
r := findRec(env.state(), "www.example.com.", "A")
|
||||
cs, _ := env.e.CreateChangeset(env.ctx, env.admin, "web", "")
|
||||
if _, err := env.e.Stage(env.ctx, cs.ID, []Request{NewRequest(OpRecordUpdate, r.ID, RecordInput{Data: model.Ptr("192.0.2.11")})}, env.admin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env.submit(env.admin, NewRequest(OpRecordUpdate, r.ID, RecordInput{Data: model.Ptr("192.0.2.12")}))
|
||||
_, err := env.e.Apply(env.ctx, cs.ID, env.admin, ApplyOptions{})
|
||||
if ee, ok := AsError(err); !ok || ee.Kind != KindConflict {
|
||||
t.Errorf("expected version conflict, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissions(t *testing.T) {
|
||||
env := newEnv(t)
|
||||
setupZone(env)
|
||||
g, _ := auth.ParseGrant("records:write zones=example.com names=_acme-challenge.* types=TXT")
|
||||
u := &model.User{Username: "acme", Grants: []model.Grant{g}}
|
||||
env.st.CreateUser(env.ctx, u)
|
||||
p := &auth.Principal{User: u, Method: "token"}
|
||||
env.submit(p, rec("example.com", "_acme-challenge.www", "TXT", "token123"))
|
||||
if e := env.submitErr(p, rec("example.com", "www", "A", "192.0.2.66")); e.Kind != KindForbidden {
|
||||
t.Errorf("A record allowed: %+v", e)
|
||||
}
|
||||
if e := env.submitErr(p, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("evil.com")})); e.Kind != KindForbidden {
|
||||
t.Errorf("zone create allowed: %+v", e)
|
||||
}
|
||||
// ACME cleanup: delete by match, idempotent.
|
||||
del := NewRequest(OpRecordDelete, 0, RecordInput{Zone: "example.com", Name: model.Ptr("_acme-challenge.www"), Type: model.Ptr("TXT"), Data: model.Ptr("token123")})
|
||||
env.submit(p, del)
|
||||
if findRec(env.state(), "_acme-challenge.www.example.com.", "TXT") != nil {
|
||||
t.Error("challenge not deleted")
|
||||
}
|
||||
e := env.submitErr(p, del)
|
||||
if e.Kind != KindNotFound {
|
||||
t.Errorf("second delete = %+v", e)
|
||||
}
|
||||
env.submit(p, NewRequest(OpRecordDelete, 0, RecordInput{Zone: "example.com", Name: model.Ptr("_acme-challenge.www"), Type: model.Ptr("TXT"), Data: model.Ptr("token123"), MissingOK: true}))
|
||||
}
|
||||
|
||||
func TestPTRAndRRSet(t *testing.T) {
|
||||
env := newEnv(t)
|
||||
setupZone(env)
|
||||
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("192.0.2.0/24")}))
|
||||
in := RecordInput{Zone: "example.com", Name: model.Ptr("nas"), Type: model.Ptr("A"), Data: model.Ptr("192.0.2.20"), PTR: true}
|
||||
res := env.submit(env.admin, NewRequest(OpRecordCreate, 0, in))
|
||||
if len(res.Ops) != 2 || res.Ops[1].Summary != "add 20.2.0.192.in-addr.arpa. IN PTR nas.example.com." {
|
||||
t.Fatalf("ops = %+v", res.Ops)
|
||||
}
|
||||
ptr := findRec(env.state(), "20.2.0.192.in-addr.arpa.", "PTR")
|
||||
if ptr == nil || ptr.Data != "nas.example.com." {
|
||||
t.Fatalf("PTR = %+v", ptr)
|
||||
}
|
||||
// Changing the address moves the PTR.
|
||||
a := findRec(env.state(), "nas.example.com.", "A")
|
||||
env.submit(env.admin, NewRequest(OpRecordUpdate, a.ID, RecordInput{Data: model.Ptr("192.0.2.21"), PTR: true}))
|
||||
if findRec(env.state(), "20.2.0.192.in-addr.arpa.", "PTR") != nil || findRec(env.state(), "21.2.0.192.in-addr.arpa.", "PTR") == nil {
|
||||
t.Error("PTR not moved")
|
||||
}
|
||||
// RRset replace keeps matching records and sets the TTL on all.
|
||||
env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", Name: "api", Type: "A", TTL: model.Some(uint32(60)), Data: []string{"192.0.2.30", "192.0.2.31"}}))
|
||||
keep := findRecData(env.state(), "api.example.com.", "192.0.2.30")
|
||||
env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", Name: "api", Type: "A", Data: []string{"192.0.2.30", "192.0.2.32"}}))
|
||||
s := env.state()
|
||||
if findRecData(s, "api.example.com.", "192.0.2.31") != nil || findRecData(s, "api.example.com.", "192.0.2.32") == nil {
|
||||
t.Error("rrset replace wrong")
|
||||
}
|
||||
if k := findRecData(s, "api.example.com.", "192.0.2.30"); k == nil || k.ID != keep.ID || *k.TTL != 60 || *findRecData(s, "api.example.com.", "192.0.2.32").TTL != 60 {
|
||||
t.Error("rrset replace did not keep record or TTL")
|
||||
}
|
||||
// A new record joining an RRset inherits its TTL.
|
||||
env.submit(env.admin, rec("example.com", "api", "A", "192.0.2.33"))
|
||||
if r := findRecData(env.state(), "api.example.com.", "192.0.2.33"); r.TTL == nil || *r.TTL != 60 {
|
||||
t.Error("TTL not inherited")
|
||||
}
|
||||
// A conflicting TTL is rejected.
|
||||
ttl := RecordInput{Zone: "example.com", Name: model.Ptr("api"), Type: model.Ptr("A"), Data: model.Ptr("192.0.2.34"), TTL: model.Some(uint32(300))}
|
||||
if e := env.submitErr(env.admin, NewRequest(OpRecordCreate, 0, ttl)); !strings.Contains(e.Message, "same TTL") {
|
||||
t.Errorf("TTL mismatch error = %+v", e)
|
||||
}
|
||||
}
|
||||
|
||||
func findRecData(s *model.State, name, data string) *model.Record {
|
||||
for _, r := range s.Records {
|
||||
if r.Name == name && r.Data == data {
|
||||
return r
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestRevert(t *testing.T) {
|
||||
env := newEnv(t)
|
||||
setupZone(env)
|
||||
env.submit(env.admin, rec("example.com", "www", "A", "192.0.2.10"))
|
||||
before := env.records("example.com")
|
||||
r := findRec(env.state(), "www.example.com.", "A")
|
||||
res := env.submit(env.admin, NewRequest(OpRecordDelete, r.ID, nil), rec("example.com", "web", "A", "192.0.2.11"))
|
||||
rev, err := env.e.Revert(env.ctx, res.ChangesetID, env.admin)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := env.e.Apply(env.ctx, rev.ID, env.admin, ApplyOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := strings.Join(env.records("example.com"), "|"); got != strings.Join(before, "|") {
|
||||
t.Errorf("after revert %s, want %s", got, strings.Join(before, "|"))
|
||||
}
|
||||
// Reverting a zone deletion restores records and options.
|
||||
z := env.state().ZonesNamed("example.com")[0]
|
||||
env.submit(env.admin, NewRequest(OpOptionCreate, 0, OptionInput{Scope: model.Ptr(model.ScopeZone), ScopeID: &z.ID, Name: model.Ptr("notify"), Value: model.Ptr("no")}))
|
||||
del := env.submit(env.admin, NewRequest(OpZoneDelete, z.ID, nil))
|
||||
if len(env.state().Zones) != 0 {
|
||||
t.Fatal("zone not deleted")
|
||||
}
|
||||
rev, _ = env.e.Revert(env.ctx, del.ChangesetID, env.admin)
|
||||
if _, err := env.e.Apply(env.ctx, rev.ID, env.admin, ApplyOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := env.state()
|
||||
if len(s.Zones) != 1 || len(s.Records) != 3 || s.FindOption(model.ScopeZone, z.ID, "notify") == nil {
|
||||
t.Errorf("zone not restored: %d zones, %d records", len(s.Zones), len(s.Records))
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewsAndACLs(t *testing.T) {
|
||||
env := newEnv(t)
|
||||
setupZone(env)
|
||||
env.submit(env.admin, NewRequest(OpACLCreate, 0, ACLInput{Name: model.Ptr("lan"), Elements: &[]string{"192.168.0.0/16", "10/8"}}))
|
||||
a := env.state().ACLByName("lan")
|
||||
if strings.Join(a.Elements, ",") != "192.168.0.0/16,10.0.0.0/8" {
|
||||
t.Errorf("elements = %v", a.Elements)
|
||||
}
|
||||
// Creating a view moves existing zones into it.
|
||||
env.submit(env.admin, NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("internal"), MatchClients: &[]string{"lan", "localhost"}, AdoptZones: true}))
|
||||
s := env.state()
|
||||
v := s.ViewByName("internal")
|
||||
if s.ZonesNamed("example.com")[0].ViewID != v.ID {
|
||||
t.Error("zone not adopted into view")
|
||||
}
|
||||
// A view without adopt_zones and zones outside views is rejected.
|
||||
env.submit(env.admin, NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("external"), MatchClients: &[]string{"any"}}))
|
||||
// Clone the zone into the external view.
|
||||
z := env.state().ZonesNamed("example.com")[0]
|
||||
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external"), CloneFrom: z.ID}))
|
||||
if zs := env.state().ZonesNamed("example.com"); len(zs) != 2 {
|
||||
t.Fatalf("clone failed: %d zones", len(zs))
|
||||
}
|
||||
// Records now need a view to say which zone.
|
||||
if e := env.submitErr(env.admin, rec("example.com", "x", "A", "192.0.2.5")); !strings.Contains(e.Message, "several views") {
|
||||
t.Errorf("ambiguous zone error = %+v", e)
|
||||
}
|
||||
// Renaming an ACL rewrites references.
|
||||
env.submit(env.admin, NewRequest(OpOptionCreate, 0, OptionInput{Scope: model.Ptr(model.ScopeView), ScopeID: &v.ID, Name: model.Ptr("allow-recursion"), Value: model.Ptr("{ lan; }")}))
|
||||
env.submit(env.admin, NewRequest(OpACLUpdate, a.ID, ACLInput{Name: model.Ptr("home")}))
|
||||
s = env.state()
|
||||
if strings.Join(s.ViewByName("internal").MatchClients, ",") != "home,localhost" || s.FindOption(model.ScopeView, v.ID, "allow-recursion").Value != "{ home; }" {
|
||||
t.Errorf("references not renamed: %v %q", s.ViewByName("internal").MatchClients, s.FindOption(model.ScopeView, v.ID, "allow-recursion").Value)
|
||||
}
|
||||
// Deleting an ACL in use is refused with the users listed.
|
||||
e := env.submitErr(env.admin, NewRequest(OpACLDelete, a.ID, nil))
|
||||
if !strings.Contains(e.Message, "view internal") || !strings.Contains(e.Message, "allow-recursion") {
|
||||
t.Errorf("delete in-use ACL error = %+v", e)
|
||||
}
|
||||
// Invalid options are rejected with the catalog's help.
|
||||
e = env.submitErr(env.admin, NewRequest(OpOptionCreate, 0, OptionInput{Scope: model.Ptr(model.ScopeOptions), Name: model.Ptr("recursion"), Value: model.Ptr("maybe")}))
|
||||
if !strings.Contains(e.Message, "boolean") {
|
||||
t.Errorf("bad option error = %+v", e)
|
||||
}
|
||||
e = env.submitErr(env.admin, NewRequest(OpOptionCreate, 0, OptionInput{Scope: model.Ptr(model.ScopeOptions), Name: model.Ptr("match-clients"), Value: model.Ptr("{ any; }")}))
|
||||
if e.Kind != KindBadRequest {
|
||||
t.Errorf("option in wrong scope = %+v", e)
|
||||
}
|
||||
// String options get quoted automatically.
|
||||
env.submit(env.admin, NewRequest(OpOptionCreate, 0, OptionInput{Scope: model.Ptr(model.ScopeOptions), Name: model.Ptr("directory"), Value: model.Ptr("/var/cache/bind")}))
|
||||
if o := env.state().FindOption(model.ScopeOptions, 0, "directory"); o.Value != `"/var/cache/bind"` {
|
||||
t.Errorf("directory = %q", o.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTSIGAndDrift(t *testing.T) {
|
||||
env := newEnv(t)
|
||||
setupZone(env)
|
||||
res := env.submit(env.admin, NewRequest(OpTSIGCreate, 0, TSIGInput{Name: model.Ptr("xfer")}))
|
||||
if strings.Contains(string(res.Ops[0].Data), `"secret":"`) && !strings.Contains(string(res.Ops[0].Data), "(hidden)") {
|
||||
t.Errorf("secret not redacted: %s", res.Ops[0].Data)
|
||||
}
|
||||
k := env.state().KeyByName("xfer")
|
||||
if k == nil || len(k.Secret) != 44 {
|
||||
t.Fatalf("key = %+v", k)
|
||||
}
|
||||
if !strings.Contains(res.Diff, "contents hidden") || strings.Contains(res.Diff, k.Secret) {
|
||||
t.Errorf("secret in diff:\n%s", res.Diff)
|
||||
}
|
||||
// Edit a managed file behind Jarl's back.
|
||||
zf := filepath.Join(env.dir, "zones/_default/example.com.zone")
|
||||
os.WriteFile(zf, []byte("hand edited\n"), 0o640)
|
||||
e := env.submitErr(env.admin, rec("example.com", "www", "A", "192.0.2.10"))
|
||||
if e.Kind != KindDrift {
|
||||
t.Errorf("drift not detected: %+v", e)
|
||||
}
|
||||
if _, err := env.e.Submit(env.ctx, []Request{rec("example.com", "www", "A", "192.0.2.10")}, env.admin, "test", "", ApplyOptions{Force: true}); err != nil {
|
||||
t.Errorf("forced apply: %v", err)
|
||||
}
|
||||
if b, _ := os.ReadFile(zf); strings.Contains(string(b), "hand edited") {
|
||||
t.Error("drifted file not overwritten")
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneCreateWithOptions(t *testing.T) {
|
||||
env := newEnv(t)
|
||||
// A secondary zone is only valid together with its primaries option.
|
||||
e := env.submitErr(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("partner.example"), Type: model.Ptr("slave")}))
|
||||
if e.Kind != KindValidation || !strings.Contains(e.Message, "primaries") {
|
||||
t.Errorf("secondary without primaries = %+v", e)
|
||||
}
|
||||
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("partner.example"), Type: model.Ptr("slave"),
|
||||
Options: []ZoneOption{{Name: "masters", Value: "{ 192.0.2.53; }"}}}))
|
||||
s := env.state()
|
||||
z := s.ZonesNamed("partner.example")[0]
|
||||
if z.Type != model.ZoneSecondary {
|
||||
t.Errorf("type = %s", z.Type)
|
||||
}
|
||||
o := s.FindOption(model.ScopeZone, z.ID, "primaries")
|
||||
if o == nil || o.Value != "{ 192.0.2.53; }" {
|
||||
t.Errorf("primaries option = %+v (the masters synonym should be normalized)", o)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user