Files
jarl/internal/model/model.go
T
adamandClaude Opus 5.5 fef916b7ab Encrypted DNS: several host names, views per listen address, what named uses a certificate for
The setup takes several host names (one certificate for all; the health check says when the served certificate misses one), stored in the settings. Each listen address gets its own views for the host names' A/AAAA records (--address ADDR=VIEW,..., a view checkbox per address in the form), always local to the view, with a warning when a public view would get a private address; a view no longer given loses the address, other records of the names stay, and the form starts from where the records are now. Certificates (page and jarl cert show) say what named uses each for: DNS over TLS or HTTPS, on which addresses and ports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 17:49:49 +01:00

740 lines
21 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"
"slices"
"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"`
// Exposure overrides whether the view counts as reachable from the
// internet (docs/exposure.md): "" works it out, ExposurePublic or
// ExposurePrivate say so.
Exposure string `json:"exposure,omitempty"`
// ReverseSuggestionsOff stops suggesting reverse zones for the view's
// addresses (docs/reverse.md), e.g. for a view only a few servers use;
// DismissedReverse are the networks whose suggestion was dismissed.
ReverseSuggestionsOff bool `json:"reverse_suggestions_off,omitempty"`
DismissedReverse []string `json:"dismissed_reverse,omitempty"`
// Acknowledged are exposure findings accepted on purpose; they are
// shown as info instead of warnings.
Acknowledged []Acknowledgement `json:"acknowledged,omitempty"`
Meta
}
// Exposure overrides.
const (
ExposureAuto = ""
ExposurePublic = "public"
ExposurePrivate = "private"
)
// Acknowledgement accepts one exposure finding (by its code) for a view,
// or for the server: the finding stays, as info, with who accepted it,
// when and why.
type Acknowledgement struct {
Code string `json:"code"`
Reason string `json:"reason"`
By string `json:"by"`
At time.Time `json:"at"`
}
// FindAck returns the acknowledgement of code in acks, if any.
func FindAck(acks []Acknowledgement, code string) *Acknowledgement {
for i := range acks {
if acks[i].Code == code {
return &acks[i]
}
}
return nil
}
// 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"`
// LinkGroup links zones of the same name in different views: Jarl
// keeps their shared records in step (docs/views.md#linked-zones). 0
// for a zone that isn't linked; linked zones share the ID.
LinkGroup int64 `json:"link_group,omitempty"`
// AutoPTR keeps the PTR records of the zone's A and AAAA records in
// step with them, in the reverse zones of the zone's views, as if every
// change asked for it (docs/reverse.md).
AutoPTR bool `json:"auto_ptr,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"`
// SharedID ties the copies of a shared record in linked zones
// together (the same in every copy). 0 for a record of a zone that
// isn't linked, and for a record local to its view in one that is.
SharedID int64 `json:"shared_id,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"`
// Exposure and Acknowledged are the server's own (without views, and
// for server-wide findings such as a visible version): see View.
Exposure string `json:"exposure,omitempty"`
Acknowledged []Acknowledgement `json:"acknowledged,omitempty"`
// DismissedReverse are dismissed reverse zone suggestions (without
// views; see View).
DismissedReverse []string `json:"dismissed_reverse,omitempty"`
// EncryptedDNSHosts are the host names clients use for DNS over TLS
// and HTTPS (docs/encrypted-dns.md); the first is checked first.
EncryptedDNSHosts []string `json:"encrypted_dns_hosts,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
BlockSteps map[int64]*BlockStep
ClientGroups map[int64]*ClientGroup
}
// 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{},
BlockSteps: map[int64]*BlockStep{},
ClientGroups: map[int64]*ClientGroup{},
}
}
// 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)),
BlockSteps: make(map[int64]*BlockStep, len(s.BlockSteps)),
ClientGroups: make(map[int64]*ClientGroup, len(s.ClientGroups)),
}
c.Settings.Nameservers = slices.Clone(s.Settings.Nameservers)
c.Settings.Acknowledged = slices.Clone(s.Settings.Acknowledged)
for id, v := range s.Views {
n := *v
n.MatchClients = slices.Clone(v.MatchClients)
n.MatchDestinations = slices.Clone(v.MatchDestinations)
n.Acknowledged = slices.Clone(v.Acknowledged)
if v.MatchRecursiveOnly != nil {
b := *v.MatchRecursiveOnly
n.MatchRecursiveOnly = &b
}
c.Views[id] = &n
}
for id, a := range s.ACLs {
n := *a
n.Elements = slices.Clone(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 = slices.Clone(b.ViewIDs)
c.Blocklists[id] = &n
}
for id, r := range s.BlockRules {
n := *r
n.ViewIDs = slices.Clone(r.ViewIDs)
c.BlockRules[id] = &n
}
for id, st := range s.BlockSteps {
n := *st
c.BlockSteps[id] = &n
}
for id, g := range s.ClientGroups {
n := *g
n.Members = slices.Clone(g.Members)
n.ViewIDs = slices.Clone(g.ViewIDs)
c.ClientGroups[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)
}