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