initial commit
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user