Files
jarl/internal/model/model.go
T
adamandClaude Opus 5.5 384f6b3060 Give every view a TSIG key that selects it
Each view's match-clients starts with "key jarl-view-NAME" (after
"!key" for the views listed after it), so a query signed with a view's
key lands in that view from any address. The secrets are derived from a
random seed in Jarl's database. The delegation check signs its queries
to this server's own addresses with the key of the zone's view: before,
they got whichever view matched the server's address, usually internal.
jarl view key / GET /views/{id}/key show a key for dig -k (tsig:write,
audited). jarl-view- key names are reserved; the importer drops view
keys from a configuration Jarl wrote.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:19:10 +01:00

660 lines
18 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.
// ViewColors are the colours the web UI can show a view in, besides the
// default (the link colour).
var ViewColors = []string{"green", "teal", "purple", "pink", "orange", "yellow", "red", "grey"}
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"`
// DNSSECPolicy is the dnssec-policy named signs the zone with (primary
// zones only): "" for an unsigned zone, "default" (BIND's built-in
// policy), the name of a dnssec-policy statement, or "insecure" while
// signing is being removed safely (docs/dnssec.md).
DNSSECPolicy string `json:"dnssec_policy,omitempty"`
Comment string `json:"comment"`
// Disabled zones stay in Jarl, records and all, but named doesn't
// load them: their zone statement is left out of named.conf.
Disabled bool `json:"disabled,omitempty"`
Meta
}
// DNSSEC policies with a special meaning.
const (
DNSSECDefault = "default" // BIND's built-in policy
DNSSECInsecure = "insecure" // BIND's built-in policy for removing signing
)
// HasRecords reports whether Jarl manages the zone's records.
func (z *Zone) HasRecords() bool { return z.Type == ZonePrimary }
// Signed reports whether named signs the zone (or is still removing its
// signatures, under the insecure policy).
func (z *Zone) Signed() bool { return z.Type == ZonePrimary && z.DNSSECPolicy != "" }
// 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"`
// Disabled records stay in Jarl but are left out of the zone (the
// zone file lists them as comments).
Disabled bool `json:"disabled,omitempty"`
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"`
// BlockingPausedUntil, while set, lets every name through blocking
// (docs/blocking.md); jarld clears it when the time is up.
BlockingPausedUntil *time.Time `json:"blocking_paused_until,omitempty"`
// BlockingPausedIndefinitely pauses blocking until someone resumes it.
BlockingPausedIndefinitely bool `json:"blocking_paused_indefinitely,omitempty"`
Version int `json:"version"`
}
// BlockingPaused reports whether blocking is paused, for a time or until
// resumed.
func (s *Settings) BlockingPaused() bool {
return s.BlockingPausedUntil != nil || s.BlockingPausedIndefinitely
}
// 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
// Blocking (docs/blocking.md).
Blocklists map[int64]*Blocklist
BlockRules map[int64]*BlockRule
}
// 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{},
Blocklists: map[int64]*Blocklist{},
BlockRules: map[int64]*BlockRule{},
}
}
// 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)),
Blocklists: make(map[int64]*Blocklist, len(s.Blocklists)),
BlockRules: make(map[int64]*BlockRule, len(s.BlockRules)),
}
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
}
for id, b := range s.Blocklists {
n := *b
n.ViewIDs = append([]int64(nil), b.ViewIDs...)
c.Blocklists[id] = &n
}
for id, r := range s.BlockRules {
n := *r
n.ViewIDs = append([]int64(nil), r.ViewIDs...)
c.BlockRules[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
}
// ViewKeyPrefix starts the names of the TSIG keys Jarl gives views
// (render/viewkeys.go); other keys can't use it.
const ViewKeyPrefix = "jarl-view-"
// ViewKeyName returns the name of a view's key.
func ViewKeyName(view string) string { return ViewKeyPrefix + view }
// 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
}
// ServedRecords returns the records of a zone that are in the zone file
// (not disabled), in display order.
func (s *State) ServedRecords(zoneID int64) []*Record {
var out []*Record
for _, r := range s.ZoneRecords(zoneID) {
if !r.Disabled {
out = append(out, r)
}
}
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
}
// ServedRecordsByZone is RecordsByZone without disabled records.
func (s *State) ServedRecordsByZone() map[int64][]*Record {
out := map[int64][]*Record{}
for _, r := range s.Records {
if !r.Disabled {
out[r.ZoneID] = append(out[r.ZoneID], r)
}
}
return out
}
// ScopedOptions returns the options of a scope: in a fixed order by name
// for a block (see optionLess), in the order they were added for
// top-level statements, which are written as entered.
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)
}
}
if scope != ScopeTopLevel {
sort.Slice(out, func(i, j int) bool { return optionLess(out[i], out[j]) })
return out
}
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)
}