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:
abrown38andClaude Opus 5.5 committed 2026-10-07 15:03:13 +00:00
commit bd6b8778e4
122 files changed
+29217

No files matched your search

+266
View File
@@ -0,0 +1,266 @@
package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
"reflect"
"jarl/internal/model"
)
const settingsKey = "settings"
// LoadState loads the complete configuration.
func (s *Store) LoadState(ctx context.Context) (*model.State, error) {
return loadState(ctx, s.db)
}
func loadState(ctx context.Context, e execer) (*model.State, error) {
st := model.NewState()
var raw string
err := e.QueryRowContext(ctx, `SELECT value FROM meta WHERE key = ?`, settingsKey).Scan(&raw)
switch {
case errors.Is(err, sql.ErrNoRows):
case err != nil:
return nil, err
default:
if err := json.Unmarshal([]byte(raw), &st.Settings); err != nil {
return nil, err
}
}
rows, err := e.QueryContext(ctx, `SELECT id, name, position, match_clients, match_destinations, match_recursive_only, comment, version, created_at, updated_at, created_by, updated_by FROM views`)
if err != nil {
return nil, err
}
for rows.Next() {
v := &model.View{}
var mc, md, ca, ua string
var mro sql.NullInt64
if err := rows.Scan(&v.ID, &v.Name, &v.Position, &mc, &md, &mro, &v.Comment, &v.Version, &ca, &ua, &v.CreatedBy, &v.UpdatedBy); err != nil {
rows.Close()
return nil, err
}
json.Unmarshal([]byte(mc), &v.MatchClients)
json.Unmarshal([]byte(md), &v.MatchDestinations)
if mro.Valid {
v.MatchRecursiveOnly = model.Ptr(mro.Int64 != 0)
}
v.CreatedAt, v.UpdatedAt = parseTS(ca), parseTS(ua)
st.Views[v.ID] = v
}
if err := closeRows(rows); err != nil {
return nil, err
}
rows, err = e.QueryContext(ctx, `SELECT id, name, elements, comment, version, created_at, updated_at, created_by, updated_by FROM acls`)
if err != nil {
return nil, err
}
for rows.Next() {
a := &model.ACL{}
var el, ca, ua string
if err := rows.Scan(&a.ID, &a.Name, &el, &a.Comment, &a.Version, &ca, &ua, &a.CreatedBy, &a.UpdatedBy); err != nil {
rows.Close()
return nil, err
}
json.Unmarshal([]byte(el), &a.Elements)
a.CreatedAt, a.UpdatedAt = parseTS(ca), parseTS(ua)
st.ACLs[a.ID] = a
}
if err := closeRows(rows); err != nil {
return nil, err
}
rows, err = e.QueryContext(ctx, `SELECT id, name, algorithm, secret, comment, version, created_at, updated_at, created_by, updated_by FROM tsig_keys`)
if err != nil {
return nil, err
}
for rows.Next() {
k := &model.TSIGKey{}
var ca, ua string
if err := rows.Scan(&k.ID, &k.Name, &k.Algorithm, &k.Secret, &k.Comment, &k.Version, &ca, &ua, &k.CreatedBy, &k.UpdatedBy); err != nil {
rows.Close()
return nil, err
}
k.CreatedAt, k.UpdatedAt = parseTS(ca), parseTS(ua)
st.Keys[k.ID] = k
}
if err := closeRows(rows); err != nil {
return nil, err
}
rows, err = e.QueryContext(ctx, `SELECT id, name, view_id, type, in_view, file, soa_mname, soa_rname, soa_serial, soa_refresh, soa_retry, soa_expire, soa_minimum, default_ttl, serial_policy, comment, version, created_at, updated_at, created_by, updated_by FROM zones`)
if err != nil {
return nil, err
}
for rows.Next() {
z := &model.Zone{}
var ca, ua string
if err := rows.Scan(&z.ID, &z.Name, &z.ViewID, &z.Type, &z.InView, &z.File, &z.SOA.MName, &z.SOA.RName, &z.SOA.Serial, &z.SOA.Refresh, &z.SOA.Retry, &z.SOA.Expire, &z.SOA.Minimum, &z.DefaultTTL, &z.SerialPolicy, &z.Comment, &z.Version, &ca, &ua, &z.CreatedBy, &z.UpdatedBy); err != nil {
rows.Close()
return nil, err
}
z.CreatedAt, z.UpdatedAt = parseTS(ca), parseTS(ua)
st.Zones[z.ID] = z
}
if err := closeRows(rows); err != nil {
return nil, err
}
rows, err = e.QueryContext(ctx, `SELECT id, zone_id, name, type, ttl, data, comment, version, created_at, updated_at, created_by, updated_by FROM records`)
if err != nil {
return nil, err
}
for rows.Next() {
r := &model.Record{}
var ttl sql.NullInt64
var ca, ua string
if err := rows.Scan(&r.ID, &r.ZoneID, &r.Name, &r.Type, &ttl, &r.Data, &r.Comment, &r.Version, &ca, &ua, &r.CreatedBy, &r.UpdatedBy); err != nil {
rows.Close()
return nil, err
}
if ttl.Valid {
r.TTL = model.Ptr(uint32(ttl.Int64))
}
r.CreatedAt, r.UpdatedAt = parseTS(ca), parseTS(ua)
st.Records[r.ID] = r
}
if err := closeRows(rows); err != nil {
return nil, err
}
rows, err = e.QueryContext(ctx, `SELECT id, scope, scope_id, name, value, position, comment, version, created_at, updated_at, created_by, updated_by FROM options`)
if err != nil {
return nil, err
}
for rows.Next() {
o := &model.Option{}
var ca, ua string
if err := rows.Scan(&o.ID, &o.Scope, &o.ScopeID, &o.Name, &o.Value, &o.Position, &o.Comment, &o.Version, &ca, &ua, &o.CreatedBy, &o.UpdatedBy); err != nil {
rows.Close()
return nil, err
}
o.CreatedAt, o.UpdatedAt = parseTS(ca), parseTS(ua)
st.Options[o.ID] = o
}
if err := closeRows(rows); err != nil {
return nil, err
}
return st, nil
}
func closeRows(rows *sql.Rows) error {
err := rows.Err()
rows.Close()
return err
}
// SaveState writes the differences between old and new.
func (t *Tx) SaveState(old, new *model.State) error {
return saveState(t.ctx, t.tx, old, new)
}
// ReplaceState replaces the stored configuration with st (used by import
// and tests).
func (t *Tx) ReplaceState(st *model.State) error {
for _, tbl := range []string{"views", "acls", "tsig_keys", "zones", "records", "options"} {
if _, err := t.tx.ExecContext(t.ctx, "DELETE FROM "+tbl); err != nil {
return err
}
}
return saveState(t.ctx, t.tx, model.NewState(), st)
}
// LoadState loads the configuration inside the transaction.
func (t *Tx) LoadState() (*model.State, error) { return loadState(t.ctx, t.tx) }
func saveState(ctx context.Context, e execer, old, new *model.State) error {
if !reflect.DeepEqual(old.Settings, new.Settings) {
if err := setMeta(ctx, e, settingsKey, jsonText(new.Settings)); err != nil {
return err
}
}
if err := diffMap(old.Views, new.Views, func(v *model.View) error {
var mro any
if v.MatchRecursiveOnly != nil {
mro = boolInt(*v.MatchRecursiveOnly)
}
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO views (id, name, position, match_clients, match_destinations, match_recursive_only, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
v.ID, v.Name, v.Position, jsonText(nonNil(v.MatchClients)), jsonText(nonNil(v.MatchDestinations)), mro, v.Comment, v.Version, ts(v.CreatedAt), ts(v.UpdatedAt), v.CreatedBy, v.UpdatedBy)
return err
}, func(id int64) error { return del(ctx, e, "views", id) }); err != nil {
return err
}
if err := diffMap(old.ACLs, new.ACLs, func(a *model.ACL) error {
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO acls (id, name, elements, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?)`,
a.ID, a.Name, jsonText(nonNil(a.Elements)), a.Comment, a.Version, ts(a.CreatedAt), ts(a.UpdatedAt), a.CreatedBy, a.UpdatedBy)
return err
}, func(id int64) error { return del(ctx, e, "acls", id) }); err != nil {
return err
}
if err := diffMap(old.Keys, new.Keys, func(k *model.TSIGKey) error {
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO tsig_keys (id, name, algorithm, secret, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?)`,
k.ID, k.Name, k.Algorithm, k.Secret, k.Comment, k.Version, ts(k.CreatedAt), ts(k.UpdatedAt), k.CreatedBy, k.UpdatedBy)
return err
}, func(id int64) error { return del(ctx, e, "tsig_keys", id) }); err != nil {
return err
}
if err := diffMap(old.Zones, new.Zones, func(z *model.Zone) error {
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO zones (id, name, view_id, type, in_view, file, soa_mname, soa_rname, soa_serial, soa_refresh, soa_retry, soa_expire, soa_minimum, default_ttl, serial_policy, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
z.ID, z.Name, z.ViewID, z.Type, z.InView, z.File, z.SOA.MName, z.SOA.RName, z.SOA.Serial, z.SOA.Refresh, z.SOA.Retry, z.SOA.Expire, z.SOA.Minimum, z.DefaultTTL, z.SerialPolicy, z.Comment, z.Version, ts(z.CreatedAt), ts(z.UpdatedAt), z.CreatedBy, z.UpdatedBy)
return err
}, func(id int64) error { return del(ctx, e, "zones", id) }); err != nil {
return err
}
if err := diffMap(old.Records, new.Records, func(r *model.Record) error {
var ttl any
if r.TTL != nil {
ttl = int64(*r.TTL)
}
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO records (id, zone_id, name, type, ttl, data, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
r.ID, r.ZoneID, r.Name, r.Type, ttl, r.Data, r.Comment, r.Version, ts(r.CreatedAt), ts(r.UpdatedAt), r.CreatedBy, r.UpdatedBy)
return err
}, func(id int64) error { return del(ctx, e, "records", id) }); err != nil {
return err
}
return diffMap(old.Options, new.Options, func(o *model.Option) error {
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO options (id, scope, scope_id, name, value, position, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
o.ID, o.Scope, o.ScopeID, o.Name, o.Value, o.Position, o.Comment, o.Version, ts(o.CreatedAt), ts(o.UpdatedAt), o.CreatedBy, o.UpdatedBy)
return err
}, func(id int64) error { return del(ctx, e, "options", id) })
}
func nonNil(s []string) []string {
if s == nil {
return []string{}
}
return s
}
func del(ctx context.Context, e execer, table string, id int64) error {
_, err := e.ExecContext(ctx, "DELETE FROM "+table+" WHERE id = ?", id)
return err
}
// diffMap calls put for new or changed objects and remove for deleted ones.
func diffMap[T any](old, new map[int64]*T, put func(*T) error, remove func(int64) error) error {
for id, n := range new {
if o, ok := old[id]; ok && reflect.DeepEqual(o, n) {
continue
}
if err := put(n); err != nil {
return err
}
}
for id := range old {
if _, ok := new[id]; !ok {
if err := remove(id); err != nil {
return err
}
}
}
return nil
}