Files
2026-08-16 21:18:45 -05:00

309 lines
7.9 KiB
Go

// Package runtimecfg owns the in-memory view of everything the DNS data path
// needs: settings, the compiled zone index, the compiled policy index and the
// recursion ACL.
//
// The whole view is an immutable Snapshot behind an atomic pointer. Query
// handling reads the pointer once and then works entirely from immutable data,
// so it never blocks on a lock and never touches SQLite. Configuration changes
// build a brand new snapshot and swap it in; readers already in flight finish
// against the old one.
package runtimecfg
import (
"context"
"log/slog"
"sync"
"sync/atomic"
"time"
"github.com/owen/vibedns/internal/authoritative"
"github.com/owen/vibedns/internal/blacklist"
"github.com/owen/vibedns/internal/config"
"github.com/owen/vibedns/internal/database"
"github.com/owen/vibedns/internal/policy"
"github.com/owen/vibedns/internal/resolver"
)
// Snapshot is an immutable view of the runtime configuration.
type Snapshot struct {
Settings config.Settings
Zones *authoritative.Index
Policy *policy.Index
ACL *resolver.ACL
BuiltAt time.Time
BuildMS int64
Problems []authoritative.BuildError
// Counts are computed at build time for the dashboard, so the UI never
// has to walk the indexes.
ZoneCount int
RecordCount int
BlacklistDomains int
AllowlistDomains int
NetworkCount int
}
// Manager builds and publishes snapshots.
type Manager struct {
db *database.DB
log *slog.Logger
cur atomic.Pointer[Snapshot]
buildMu sync.Mutex // serialises snapshot construction
subMu sync.RWMutex
subs []func(*Snapshot)
trigger chan struct{}
wg sync.WaitGroup
once sync.Once
reloads atomic.Int64
failures atomic.Int64
}
// New creates a manager and builds the first snapshot.
func New(ctx context.Context, db *database.DB, log *slog.Logger) (*Manager, error) {
m := &Manager{
db: db,
log: log,
trigger: make(chan struct{}, 1),
}
if err := m.Reload(ctx); err != nil {
return nil, err
}
return m, nil
}
// Current returns the active snapshot. It is never nil after New succeeds.
func (m *Manager) Current() *Snapshot { return m.cur.Load() }
// Settings is a shorthand for the active settings.
func (m *Manager) Settings() config.Settings { return m.cur.Load().Settings }
// OnReload registers a callback invoked after every successful reload. It is
// how the cache, resolver, rate limiter and query logger pick up new settings.
func (m *Manager) OnReload(fn func(*Snapshot)) {
m.subMu.Lock()
m.subs = append(m.subs, fn)
m.subMu.Unlock()
}
// Reload rebuilds the snapshot from the database and publishes it.
func (m *Manager) Reload(ctx context.Context) error {
m.buildMu.Lock()
defer m.buildMu.Unlock()
start := time.Now()
snap, err := m.build(ctx)
if err != nil {
m.failures.Add(1)
return err
}
snap.BuildMS = time.Since(start).Milliseconds()
m.cur.Store(snap)
m.reloads.Add(1)
m.subMu.RLock()
subs := make([]func(*Snapshot), len(m.subs))
copy(subs, m.subs)
m.subMu.RUnlock()
for _, fn := range subs {
fn(snap)
}
m.log.Debug("configuration reloaded",
"zones", snap.ZoneCount, "records", snap.RecordCount,
"blacklist_domains", snap.BlacklistDomains, "networks", snap.NetworkCount,
"duration_ms", snap.BuildMS)
for _, p := range snap.Problems {
m.log.Warn("record skipped while building the zone index",
"zone", p.ZoneName, "name", p.Name, "type", p.Type, "error", p.Err)
}
return nil
}
// build assembles a snapshot. It performs a handful of bulk queries rather
// than per-object lookups, so a reload is cheap even with large lists.
func (m *Manager) build(ctx context.Context) (*Snapshot, error) {
stored, err := m.db.Settings(ctx)
if err != nil {
return nil, err
}
settings := config.LoadSettings(stored)
zones, records, err := m.db.SnapshotZones(ctx)
if err != nil {
return nil, err
}
zoneIdx, problems := authoritative.Build(zones, records)
lists, err := m.buildDomainSets(ctx)
if err != nil {
return nil, err
}
networks, assignments, err := m.db.SnapshotNetworks(ctx)
if err != nil {
return nil, err
}
policies, err := m.db.Policies(ctx)
if err != nil {
return nil, err
}
policyIdx := policy.Build(networks, assignments, policies, lists)
acl, err := resolver.NewACL(settings.Resolver.AllowNetworks, settings.Resolver.DenyNetworks)
if err != nil {
// A bad ACL must not silently become permissive; fall back to an
// empty allow list, which denies recursion to everyone.
m.log.Error("recursion ACL is invalid, denying recursion to all clients", "error", err)
acl, _ = resolver.NewACL(nil, nil)
}
snap := &Snapshot{
Settings: settings,
Zones: zoneIdx,
Policy: policyIdx,
ACL: acl,
BuiltAt: time.Now(),
Problems: problems,
}
snap.ZoneCount = zoneIdx.Len()
for _, z := range zoneIdx.Zones() {
snap.RecordCount += z.RecordCount()
}
pstats := policyIdx.Stats()
snap.BlacklistDomains = int(pstats.BlockedDomains)
snap.AllowlistDomains = int(pstats.AllowedDomains)
snap.NetworkCount = pstats.Networks
return snap, nil
}
// buildDomainSets compiles every enabled domain list into a matcher. Sets are
// shared by pointer across policies, so a list used by several policies costs
// memory exactly once.
func (m *Manager) buildDomainSets(ctx context.Context) (map[int64]*blacklist.Set, error) {
meta, err := m.db.DomainLists(ctx, "", "")
if err != nil {
return nil, err
}
builders := make(map[int64]*blacklist.Builder, len(meta))
for _, l := range meta {
if !l.Enabled {
continue
}
builders[l.ID] = blacklist.NewBuilder(l.ID, l.Name, l.Kind, l.DomainCount)
}
err = m.db.SnapshotDomains(ctx, func(e database.SnapshotDomainEntry) {
if b, ok := builders[e.ListID]; ok {
b.Add(e.Domain, e.MatchSubdomains)
}
})
if err != nil {
return nil, err
}
out := make(map[int64]*blacklist.Set, len(builders))
for id, b := range builders {
out[id] = b.Build()
}
return out, nil
}
// RequestReload schedules a reload without blocking the caller.
//
// Requests arriving while one is pending are coalesced, so importing a hundred
// thousand domains one API call at a time still results in a bounded number of
// index rebuilds.
func (m *Manager) RequestReload() {
select {
case m.trigger <- struct{}{}:
default:
}
}
// Start launches the debounced reload worker.
func (m *Manager) Start(ctx context.Context) {
m.once.Do(func() {
m.wg.Add(1)
go m.reloadLoop(ctx)
})
}
// Stop waits for the reload worker to exit.
func (m *Manager) Stop() { m.wg.Wait() }
const reloadDebounce = 250 * time.Millisecond
func (m *Manager) reloadLoop(ctx context.Context) {
defer m.wg.Done()
for {
select {
case <-ctx.Done():
return
case <-m.trigger:
// Coalesce a burst of changes into one rebuild.
timer := time.NewTimer(reloadDebounce)
drain:
for {
select {
case <-m.trigger:
if !timer.Stop() {
<-timer.C
}
timer.Reset(reloadDebounce)
case <-timer.C:
break drain
case <-ctx.Done():
timer.Stop()
return
}
}
if err := m.Reload(ctx); err != nil {
m.log.Error("could not reload configuration", "error", err)
}
}
}
}
// Stats reports reload activity for the dashboard.
type Stats struct {
Reloads int64 `json:"reloads"`
Failures int64 `json:"failures"`
LastBuiltAt time.Time `json:"last_built_at"`
LastBuildMS int64 `json:"last_build_ms"`
Problems int `json:"problems"`
}
// Stats returns reload counters.
func (m *Manager) Stats() Stats {
snap := m.Current()
s := Stats{Reloads: m.reloads.Load(), Failures: m.failures.Load()}
if snap != nil {
s.LastBuiltAt = snap.BuiltAt
s.LastBuildMS = snap.BuildMS
s.Problems = len(snap.Problems)
}
return s
}
// ZoneProblems returns the records that could not be compiled, for display on
// the zone pages.
func (m *Manager) ZoneProblems(zoneID int64) []authoritative.BuildError {
snap := m.Current()
if snap == nil {
return nil
}
var out []authoritative.BuildError
for _, p := range snap.Problems {
if zoneID == 0 || p.ZoneID == zoneID {
out = append(out, p)
}
}
return out
}