Files
jarl/internal/model/model.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

569 lines
14 KiB
Go

// Package model defines Jarl's configuration objects and the in-memory
// State they live in.
//
// The State is the complete desired configuration of the name server. It is
// loaded from the database, modified by applying changeset operations to a
// copy, validated and rendered to BIND configuration files, and only then
// written back.
package model
import (
"encoding/json"
"sort"
"strings"
"time"
"jarl/internal/dnsname"
)
// Meta holds bookkeeping fields shared by all objects.
type Meta struct {
// Version is incremented on every change and used for optimistic
// concurrency control.
Version int `json:"version"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CreatedBy string `json:"created_by,omitempty"`
UpdatedBy string `json:"updated_by,omitempty"`
}
// Touch updates the meta data for a modification by user at time now.
func (m *Meta) Touch(user string, now time.Time) {
if m.Version == 0 {
m.CreatedAt, m.CreatedBy = now, user
}
m.Version++
m.UpdatedAt, m.UpdatedBy = now, user
}
// View is a BIND view.
type View struct {
ID int64 `json:"id"`
Name string `json:"name"`
Position int `json:"position"`
MatchClients []string `json:"match_clients"`
MatchDestinations []string `json:"match_destinations"`
MatchRecursiveOnly *bool `json:"match_recursive_only,omitempty"`
Comment string `json:"comment"`
Meta
}
// ACL is a named address match list.
type ACL struct {
ID int64 `json:"id"`
Name string `json:"name"`
Elements []string `json:"elements"`
Comment string `json:"comment"`
Meta
}
// TSIGKey is a shared secret key for TSIG.
type TSIGKey struct {
ID int64 `json:"id"`
Name string `json:"name"`
Algorithm string `json:"algorithm"`
Secret string `json:"secret,omitempty"`
Comment string `json:"comment"`
Meta
}
// Zone types.
const (
ZonePrimary = "primary"
ZoneSecondary = "secondary"
ZoneMirror = "mirror"
ZoneStub = "stub"
ZoneStaticStub = "static-stub"
ZoneForward = "forward"
ZoneHint = "hint"
ZoneRedirect = "redirect"
ZoneInView = "in-view"
)
// ZoneTypes lists the supported zone types.
var ZoneTypes = []string{ZonePrimary, ZoneSecondary, ZoneMirror, ZoneStub, ZoneStaticStub, ZoneForward, ZoneHint, ZoneRedirect, ZoneInView}
// Serial policies.
const (
SerialDate = "date"
SerialIncrement = "increment"
SerialUnixtime = "unixtime"
)
// SOA holds the SOA fields of a primary zone. The serial is maintained by
// Jarl and bumped automatically when the zone's data changes.
type SOA struct {
MName string `json:"mname"`
RName string `json:"rname"`
Serial uint32 `json:"serial"`
Refresh uint32 `json:"refresh"`
Retry uint32 `json:"retry"`
Expire uint32 `json:"expire"`
Minimum uint32 `json:"minimum"`
}
// Zone is a BIND zone.
type Zone struct {
ID int64 `json:"id"`
Name string `json:"name"`
ViewID int64 `json:"view_id,omitempty"` // 0 when views are not used
Type string `json:"type"`
// InView names the view whose zone of the same name this zone shares
// (type in-view only).
InView string `json:"in_view,omitempty"`
// File is the zone file. It is generated for primary zones and for
// zones named loads from transfers (secondary, mirror, stub), unless
// set explicitly; hint and redirect zones must set it.
File string `json:"file,omitempty"`
SOA SOA `json:"soa"`
DefaultTTL uint32 `json:"default_ttl"`
SerialPolicy string `json:"serial_policy,omitempty"`
Comment string `json:"comment"`
Meta
}
// HasRecords reports whether Jarl manages the zone's records.
func (z *Zone) HasRecords() bool { return z.Type == ZonePrimary }
// Record is a resource record in a primary zone.
type Record struct {
ID int64 `json:"id"`
ZoneID int64 `json:"zone_id"`
Name string `json:"name"` // canonical FQDN
Type string `json:"type"`
// TTL is nil when the record uses the zone's default TTL.
TTL *uint32 `json:"ttl,omitempty"`
Data string `json:"data"` // canonical presentation format
Comment string `json:"comment"`
Meta
}
// EffectiveTTL returns the record's TTL, falling back to the zone default.
func (r *Record) EffectiveTTL(z *Zone) uint32 {
if r.TTL != nil {
return *r.TTL
}
return z.DefaultTTL
}
// Option scopes.
const (
ScopeOptions = "options" // options { } block
ScopeView = "view" // a view
ScopeZone = "zone" // a zone
ScopeTopLevel = "toplevel" // a free-form top-level statement
)
// Option is a configuration statement: a server option, a view or zone
// option, or a free-form top-level statement such as logging or controls.
// Value holds everything after the name, in named.conf syntax.
type Option struct {
ID int64 `json:"id"`
Scope string `json:"scope"`
ScopeID int64 `json:"scope_id,omitempty"`
Name string `json:"name"`
Value string `json:"value"`
Position int `json:"position"`
Comment string `json:"comment"`
Meta
}
// Text returns the option as a single named.conf statement.
func (o *Option) Text() string {
if o.Value == "" {
return o.Name + ";"
}
return o.Name + " " + o.Value + ";"
}
// Settings are server-wide Jarl settings, mostly defaults for new zones.
type Settings struct {
DefaultTTL uint32 `json:"default_ttl"`
Nameservers []string `json:"nameservers"`
RName string `json:"rname"`
Refresh uint32 `json:"refresh"`
Retry uint32 `json:"retry"`
Expire uint32 `json:"expire"`
NegativeTTL uint32 `json:"negative_ttl"`
SerialPolicy string `json:"serial_policy"`
Version int `json:"version"`
}
// DefaultSettings returns the settings of a fresh installation.
func DefaultSettings() Settings {
return Settings{
DefaultTTL: 3600,
Refresh: 3600,
Retry: 900,
Expire: 1209600,
NegativeTTL: 300,
SerialPolicy: SerialDate,
}
}
// State is the complete desired configuration.
type State struct {
Settings Settings
Views map[int64]*View
ACLs map[int64]*ACL
Keys map[int64]*TSIGKey
Zones map[int64]*Zone
Records map[int64]*Record
Options map[int64]*Option
}
// NewState returns an empty state with default settings.
func NewState() *State {
return &State{
Settings: DefaultSettings(),
Views: map[int64]*View{},
ACLs: map[int64]*ACL{},
Keys: map[int64]*TSIGKey{},
Zones: map[int64]*Zone{},
Records: map[int64]*Record{},
Options: map[int64]*Option{},
}
}
// Clone returns a deep copy of the state.
func (s *State) Clone() *State {
c := &State{
Settings: s.Settings,
Views: make(map[int64]*View, len(s.Views)),
ACLs: make(map[int64]*ACL, len(s.ACLs)),
Keys: make(map[int64]*TSIGKey, len(s.Keys)),
Zones: make(map[int64]*Zone, len(s.Zones)),
Records: make(map[int64]*Record, len(s.Records)),
Options: make(map[int64]*Option, len(s.Options)),
}
c.Settings.Nameservers = append([]string(nil), s.Settings.Nameservers...)
for id, v := range s.Views {
n := *v
n.MatchClients = append([]string(nil), v.MatchClients...)
n.MatchDestinations = append([]string(nil), v.MatchDestinations...)
if v.MatchRecursiveOnly != nil {
b := *v.MatchRecursiveOnly
n.MatchRecursiveOnly = &b
}
c.Views[id] = &n
}
for id, a := range s.ACLs {
n := *a
n.Elements = append([]string(nil), a.Elements...)
c.ACLs[id] = &n
}
for id, k := range s.Keys {
n := *k
c.Keys[id] = &n
}
for id, z := range s.Zones {
n := *z
c.Zones[id] = &n
}
for id, r := range s.Records {
n := *r
if r.TTL != nil {
t := *r.TTL
n.TTL = &t
}
c.Records[id] = &n
}
for id, o := range s.Options {
n := *o
c.Options[id] = &n
}
return c
}
// OrderedViews returns the views in match order.
func (s *State) OrderedViews() []*View {
out := make([]*View, 0, len(s.Views))
for _, v := range s.Views {
out = append(out, v)
}
sort.Slice(out, func(i, j int) bool {
if out[i].Position != out[j].Position {
return out[i].Position < out[j].Position
}
return out[i].ID < out[j].ID
})
return out
}
// ViewByName finds a view by name (case-insensitive).
func (s *State) ViewByName(name string) *View {
for _, v := range s.Views {
if strings.EqualFold(v.Name, name) {
return v
}
}
return nil
}
// ViewName returns the name of the zone's view, or "" without views.
func (s *State) ViewName(z *Zone) string {
if v := s.Views[z.ViewID]; v != nil {
return v.Name
}
return ""
}
// ACLByName finds an ACL by name (case-insensitive, as BIND does).
func (s *State) ACLByName(name string) *ACL {
for _, a := range s.ACLs {
if strings.EqualFold(a.Name, name) {
return a
}
}
return nil
}
// KeyByName finds a TSIG key by name.
func (s *State) KeyByName(name string) *TSIGKey {
n := NormalizeKeyName(name)
for _, k := range s.Keys {
if k.Name == n {
return k
}
}
return nil
}
// NormalizeKeyName returns the canonical form of a key name: lowercase,
// without a trailing dot.
func NormalizeKeyName(name string) string {
return strings.TrimSuffix(strings.ToLower(strings.TrimSpace(name)), ".")
}
// SortedACLs returns ACLs ordered by name.
func (s *State) SortedACLs() []*ACL {
out := make([]*ACL, 0, len(s.ACLs))
for _, a := range s.ACLs {
out = append(out, a)
}
sort.Slice(out, func(i, j int) bool { return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name) })
return out
}
// SortedKeys returns TSIG keys ordered by name.
func (s *State) SortedKeys() []*TSIGKey {
out := make([]*TSIGKey, 0, len(s.Keys))
for _, k := range s.Keys {
out = append(out, k)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// ZonesInView returns the zones of a view (viewID 0 for "no view"), sorted
// by name.
func (s *State) ZonesInView(viewID int64) []*Zone {
var out []*Zone
for _, z := range s.Zones {
if z.ViewID == viewID {
out = append(out, z)
}
}
SortZones(out)
return out
}
// SortedZones returns all zones ordered by view position and name.
func (s *State) SortedZones() []*Zone {
out := make([]*Zone, 0, len(s.Zones))
for _, z := range s.Zones {
out = append(out, z)
}
pos := map[int64]int{}
for i, v := range s.OrderedViews() {
pos[v.ID] = i + 1
}
sort.Slice(out, func(i, j int) bool {
if pi, pj := pos[out[i].ViewID], pos[out[j].ViewID]; pi != pj {
return pi < pj
}
return CanonicalLess(out[i].Name, out[j].Name)
})
return out
}
// SortZones sorts zones by name in DNS canonical order.
func SortZones(zs []*Zone) {
sort.Slice(zs, func(i, j int) bool { return CanonicalLess(zs[i].Name, zs[j].Name) })
}
// FindZone finds a zone by name within a view (viewID 0 for no view).
func (s *State) FindZone(name string, viewID int64) *Zone {
name = dnsname.Fqdn(name)
for _, z := range s.Zones {
if z.Name == name && z.ViewID == viewID {
return z
}
}
return nil
}
// ZonesNamed returns all zones with the given name across views.
func (s *State) ZonesNamed(name string) []*Zone {
name = dnsname.Fqdn(name)
var out []*Zone
for _, z := range s.Zones {
if z.Name == name {
out = append(out, z)
}
}
return out
}
// ZoneRecords returns the records of a zone in display order.
func (s *State) ZoneRecords(zoneID int64) []*Record {
var out []*Record
for _, r := range s.Records {
if r.ZoneID == zoneID {
out = append(out, r)
}
}
SortRecords(out, s.Zones[zoneID])
return out
}
// RecordsByZone groups all records by zone ID (unsorted).
func (s *State) RecordsByZone() map[int64][]*Record {
out := map[int64][]*Record{}
for _, r := range s.Records {
out[r.ZoneID] = append(out[r.ZoneID], r)
}
return out
}
// ScopedOptions returns the options of a scope, in position order.
func (s *State) ScopedOptions(scope string, scopeID int64) []*Option {
var out []*Option
for _, o := range s.Options {
if o.Scope == scope && o.ScopeID == scopeID {
out = append(out, o)
}
}
sort.Slice(out, func(i, j int) bool {
if out[i].Position != out[j].Position {
return out[i].Position < out[j].Position
}
return out[i].ID < out[j].ID
})
return out
}
// FindOption returns the first option with the given name in a scope.
func (s *State) FindOption(scope string, scopeID int64, name string) *Option {
for _, o := range s.ScopedOptions(scope, scopeID) {
if o.Name == name {
return o
}
}
return nil
}
// typeOrder puts SOA and NS first when sorting records.
func typeOrder(t string) string {
switch t {
case "SOA":
return "0"
case "NS":
return "1"
}
return "2" + t
}
// SortRecords sorts records in zone-file order: apex first, then names in
// DNS canonical order (so subdomains group together), then by type and
// data.
func SortRecords(rs []*Record, z *Zone) {
apex := ""
if z != nil {
apex = z.Name
}
sort.SliceStable(rs, func(i, j int) bool {
a, b := rs[i], rs[j]
if a.Name != b.Name {
if a.Name == apex {
return true
}
if b.Name == apex {
return false
}
return CanonicalLess(a.Name, b.Name)
}
if a.Type != b.Type {
return typeOrder(a.Type) < typeOrder(b.Type)
}
if a.Data != b.Data {
return a.Data < b.Data
}
return a.ID < b.ID
})
}
// CanonicalLess compares names in DNS canonical order (RFC 4034 section
// 6.1): by label from the right.
func CanonicalLess(a, b string) bool {
la := strings.Split(strings.TrimSuffix(a, "."), ".")
lb := strings.Split(strings.TrimSuffix(b, "."), ".")
for i, j := len(la)-1, len(lb)-1; i >= 0 && j >= 0; i, j = i-1, j-1 {
if la[i] != lb[j] {
return la[i] < lb[j]
}
}
return len(la) < len(lb)
}
// RRSetKey identifies an RRset.
type RRSetKey struct {
Name string
Type string
}
// RRSets groups records into RRsets.
func RRSets(rs []*Record) map[RRSetKey][]*Record {
out := map[RRSetKey][]*Record{}
for _, r := range rs {
k := RRSetKey{r.Name, r.Type}
out[k] = append(out[k], r)
}
return out
}
// Ptr returns a pointer to v.
func Ptr[T any](v T) *T { return &v }
// Opt is an optional value for partial updates that distinguishes "not
// set" from "set to null".
type Opt[T any] struct {
Set bool
Null bool
Value T
}
// Some returns an Opt holding v.
func Some[T any](v T) Opt[T] { return Opt[T]{Set: true, Value: v} }
// IsZero reports whether the option was not set (used by omitzero).
func (o Opt[T]) IsZero() bool { return !o.Set }
// UnmarshalJSON implements json.Unmarshaler.
func (o *Opt[T]) UnmarshalJSON(b []byte) error {
o.Set = true
if string(b) == "null" {
o.Null = true
return nil
}
return json.Unmarshal(b, &o.Value)
}
// MarshalJSON implements json.Marshaler.
func (o Opt[T]) MarshalJSON() ([]byte, error) {
if !o.Set || o.Null {
return []byte("null"), nil
}
return json.Marshal(o.Value)
}