// 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) }