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,568 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in new issue
Block a user