package app import ( "context" "encoding/base64" "errors" "fmt" "log/slog" "os" "time" "github.com/owen/vibedns/internal/auditlog" "github.com/owen/vibedns/internal/auth" "github.com/owen/vibedns/internal/backup" "github.com/owen/vibedns/internal/blockpage" "github.com/owen/vibedns/internal/cache" "github.com/owen/vibedns/internal/config" "github.com/owen/vibedns/internal/database" "github.com/owen/vibedns/internal/dnsengine" "github.com/owen/vibedns/internal/metrics" "github.com/owen/vibedns/internal/querylog" "github.com/owen/vibedns/internal/ratelimit" "github.com/owen/vibedns/internal/resolver" "github.com/owen/vibedns/internal/runtimecfg" "github.com/owen/vibedns/internal/version" ) // keyCSRFSecret stores the CSRF signing key so tokens survive a restart. const keyCSRFSecret = "security.csrf_key" // App wires every component together and exposes the operations the // management interface performs. type App struct { Boot config.Bootstrap DB *database.DB Runtime *runtimecfg.Manager Cache *cache.Cache Resolver *resolver.Resolver Limiter *ratelimit.Limiter Metrics *metrics.Metrics QueryLog *querylog.Logger Audit *auditlog.Logger Auth *auth.Authenticator Backups *backup.Manager DNS *dnsengine.Server BlockPage *blockpage.Server Log *slog.Logger cancel context.CancelFunc started time.Time } // New builds the application. The database must already be migrated. func New(ctx context.Context, boot config.Bootstrap, db *database.DB, log *slog.Logger) (*App, error) { rt, err := runtimecfg.New(ctx, db, log) if err != nil { return nil, fmt.Errorf("build the initial configuration snapshot: %w", err) } settings := rt.Settings() csrfKey, err := loadOrCreateCSRFKey(ctx, db) if err != nil { return nil, err } a := &App{ Boot: boot, DB: db, Runtime: rt, Log: log, started: time.Now(), } a.Metrics = metrics.New(version.Version) a.Cache = cache.New(cacheConfig(settings)) a.Resolver = resolver.New(resolverConfig(settings)) a.Limiter = ratelimit.New(rateLimitConfig(settings)) a.QueryLog = querylog.New(db, log, queryLogConfig(settings)) a.Audit = auditlog.New(db, log) a.Auth = auth.New(db, log, csrfKey) a.Auth.SetTrustedProxies(settings.HTTP.TrustedProxies) a.Backups = backup.New(db, log, backupConfig(settings)) a.DNS = dnsengine.New(dnsengine.Options{ Runtime: rt, Cache: a.Cache, Resolver: a.Resolver, Limiter: a.Limiter, Metrics: a.Metrics, QueryLog: a.QueryLog, Log: log, }) a.BlockPage = blockpage.New(rt, log) // Every subsystem picks up new settings from the same reload event, so a // change in the UI takes effect without a restart. rt.OnReload(func(s *runtimecfg.Snapshot) { a.Cache.SetConfig(cacheConfig(s.Settings)) a.Resolver.SetConfig(resolverConfig(s.Settings)) a.Limiter.SetConfig(rateLimitConfig(s.Settings)) a.QueryLog.SetConfig(queryLogConfig(s.Settings)) a.Backups.SetConfig(backupConfig(s.Settings)) a.Auth.SetTrustedProxies(s.Settings.HTTP.TrustedProxies) }) a.Metrics.SetGaugeSource(a.gauges) return a, nil } func cacheConfig(s config.Settings) cache.Config { return cache.Config{ Enabled: s.Cache.Enabled, MaxEntries: s.Cache.MaxEntries, MinTTL: uint32(s.Cache.MinTTL), MaxTTL: uint32(s.Cache.MaxTTL), NegativeTTL: uint32(s.Cache.NegativeTTL), ServeStale: s.Cache.ServeStale, StaleTTL: uint32(s.Cache.StaleTTL), Prefetch: s.Cache.Prefetch, PrefetchPercent: s.Cache.PrefetchPercent, } } func resolverConfig(s config.Settings) resolver.Config { return resolver.Config{ Upstreams: s.Resolver.Upstreams, Timeout: time.Duration(s.Resolver.TimeoutMS) * time.Millisecond, Retries: s.Resolver.Retries, Strategy: s.Resolver.Strategy, DNSSEC: s.Resolver.DNSSEC, EDNSUDPSize: uint16(s.DNS.EDNSUDPSize), MaxConcurrent: s.Resolver.MaxConcurrent, } } func rateLimitConfig(s config.Settings) ratelimit.Config { return ratelimit.Config{ Enabled: s.RateLimit.Enabled, QPS: s.RateLimit.QPS, Burst: s.RateLimit.Burst, Exempt: s.RateLimit.ExemptNetworks, } } func queryLogConfig(s config.Settings) querylog.Config { return querylog.Config{ Enabled: s.QueryLog.Enabled, RetentionDays: s.QueryLog.RetentionDays, MaxRows: s.QueryLog.MaxRows, CleanupMinutes: s.QueryLog.CleanupMinutes, IgnoreNetworks: s.QueryLog.IgnoreNetworks, IgnoreDomains: s.QueryLog.IgnoreDomains, } } func backupConfig(s config.Settings) backup.Config { return backup.Config{ Enabled: s.Backup.Enabled, Directory: s.Backup.Directory, IntervalHours: s.Backup.IntervalHours, Retention: s.Backup.Retention, } } // loadOrCreateCSRFKey fetches the persisted CSRF signing key, creating one on // first run. Persisting it means tokens in open browser tabs survive a restart. func loadOrCreateCSRFKey(ctx context.Context, db *database.DB) ([]byte, error) { if v, ok, err := db.Setting(ctx, keyCSRFSecret); err != nil { return nil, fmt.Errorf("read the CSRF signing key: %w", err) } else if ok && v != "" { key, err := base64.RawStdEncoding.DecodeString(v) if err == nil && len(key) >= 32 { return key, nil } } encoded, err := auth.RandomKey(32) if err != nil { return nil, err } if err := db.SetSetting(ctx, keyCSRFSecret, encoded); err != nil { return nil, fmt.Errorf("store the CSRF signing key: %w", err) } key, err := base64.RawStdEncoding.DecodeString(encoded) if err != nil { return nil, fmt.Errorf("decode the CSRF signing key: %w", err) } return key, nil } // Start launches every background worker and binds the DNS listeners. func (a *App) Start(ctx context.Context) error { ctx, a.cancel = context.WithCancel(ctx) a.Runtime.Start(ctx) a.QueryLog.Start(ctx) a.Backups.Start(ctx) done := ctx.Done() go a.Cache.Run(done, func() time.Duration { return time.Duration(a.Runtime.Settings().Cache.CleanupSeconds) * time.Second }) go a.Limiter.Run(done, time.Minute, 10*time.Minute) go a.pruneAuditLoop(ctx) if err := a.DNS.Start(ctx); err != nil { a.cancel() return err } if settings := a.Runtime.Settings(); settings.BlockPage.Enabled { if err := a.BlockPage.Start(settings.BlockPage.HTTPListen, settings.BlockPage.HTTPSListen); err != nil { a.cancel() _ = a.DNS.Shutdown(context.Background()) return err } } return nil } // Shutdown stops the DNS listeners and drains the background workers. func (a *App) Shutdown(ctx context.Context) error { err := a.DNS.Shutdown(ctx) if bpErr := a.BlockPage.Shutdown(ctx); bpErr != nil && err == nil { err = bpErr } if a.cancel != nil { a.cancel() } a.QueryLog.Stop() a.Runtime.Stop() a.Backups.Stop() return err } // pruneAuditLoop keeps the audit log bounded. func (a *App) pruneAuditLoop(ctx context.Context) { t := time.NewTicker(6 * time.Hour) defer t.Stop() for { select { case <-ctx.Done(): return case <-t.C: max := a.Runtime.Settings().Logging.AuditMaxRows if n, err := a.DB.PruneAuditLogs(ctx, max); err != nil { a.Log.Warn("could not prune the audit log", "error", err) } else if n > 0 { a.Log.Debug("pruned audit log", "rows", n) } } } } // Settings returns the active runtime settings. func (a *App) Settings() config.Settings { return a.Runtime.Settings() } // Snapshot returns the active configuration snapshot. func (a *App) Snapshot() *runtimecfg.Snapshot { return a.Runtime.Current() } // StartedAt returns when the application started. func (a *App) StartedAt() time.Time { return a.started } // Uptime returns how long the application has been running. func (a *App) Uptime() time.Duration { return time.Since(a.started) } // Reload rebuilds the configuration snapshot immediately. func (a *App) Reload(ctx context.Context) error { if err := a.Runtime.Reload(ctx); err != nil { return Internal(err, "The configuration could not be reloaded.") } return nil } // gauges samples live values for the metrics endpoint. func (a *App) gauges() metrics.Gauges { snap := a.Runtime.Current() cs := a.Cache.Stats() rs := a.Resolver.Stats() g := metrics.Gauges{ CacheEntries: int64(cs.Entries), CacheBytes: cs.Bytes, Zones: int64(snap.ZoneCount), Records: int64(snap.RecordCount), BlacklistDomains: int64(snap.BlacklistDomains), AllowlistDomains: int64(snap.AllowlistDomains), Networks: int64(snap.NetworkCount), UpstreamsTotal: int64(rs.Upstreams), UpstreamsHealthy: int64(rs.Healthy), } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() if n, err := a.DB.QueryLogCount(ctx); err == nil { g.QueryLogRows = n } return g } // Ready reports whether the server is able to answer queries. It backs /readyz. func (a *App) Ready(ctx context.Context) error { if !a.DNS.Running() { return errors.New("DNS listeners are not running") } if err := a.DB.PingContext(ctx); err != nil { return fmt.Errorf("database is unreachable: %w", err) } if a.Runtime.Current() == nil { return errors.New("configuration has not been loaded") } return nil } // translate converts a storage error into a user-facing one. func translate(err error, notFound, conflict string) error { switch { case err == nil: return nil case errors.Is(err, database.ErrNotFound): return NotFound("%s", notFound) case errors.Is(err, database.ErrConflict): return Conflict("%s", conflict) default: return Internal(err, "The change could not be saved.") } } // Hostname returns the machine name, shown on the dashboard. func Hostname() string { h, err := os.Hostname() if err != nil || h == "" { return "unknown" } return h }