309 lines
7.9 KiB
Go
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
|
|
}
|