Files
vibedns/internal/web/handlers_settings.go
T
2026-08-16 21:18:45 -05:00

399 lines
14 KiB
Go

package web
import (
"fmt"
"net/http"
"strings"
"github.com/owen/vibedns/internal/app"
"github.com/owen/vibedns/internal/config"
)
// settingsPage renders one settings tab with the current configuration.
func (s *Server) settingsPage(w http.ResponseWriter, r *http.Request, page, subnav, title string, extra map[string]any) {
data := s.base(r, title, "settings")
data.Subnav = subnav
payload := map[string]any{"S": s.app.Settings()}
for k, v := range extra {
payload[k] = v
}
data.Data = payload
s.render(w, r, page, data)
}
// --- DNS ----------------------------------------------------------------
func (s *Server) handleSettingsDNS(w http.ResponseWriter, r *http.Request) error {
udp, tcp := s.app.DNS.ListenAddrs()
s.settingsPage(w, r, "settings_dns", "dns", "DNS Settings", map[string]any{
"BoundUDP": udp,
"BoundTCP": tcp,
"Running": s.app.DNS.Running(),
})
return nil
}
func (s *Server) handleSettingsDNSSave(w http.ResponseWriter, r *http.Request) error {
if err := parseForm(r); err != nil {
return err
}
next := s.app.Settings()
next.DNS.UDPListen = formString(r, "udp_listen")
next.DNS.TCPListen = formString(r, "tcp_listen")
next.DNS.Recursion = formBool(r, "recursion")
next.DNS.EDNSEnabled = formBool(r, "edns_enabled")
next.DNS.EDNSUDPSize = formInt(r, "edns_udp_size", next.DNS.EDNSUDPSize)
next.DNS.MaxUDPResponse = formInt(r, "max_udp_response", next.DNS.MaxUDPResponse)
next.DNS.DefaultTTL = formUint32(r, "default_ttl", next.DNS.DefaultTTL)
next.DNS.TCPIdleSeconds = formInt(r, "tcp_idle", next.DNS.TCPIdleSeconds)
next.DNS.ExposeVersion = formBool(r, "expose_version")
if err := s.app.SaveSettings(r.Context(), s.actor(r), app.GroupDNS, next); err != nil {
return err
}
setFlash(w, r, "success", "DNS settings saved.")
return s.redirect(w, r, "/settings/dns")
}
// --- Resolver -----------------------------------------------------------
func (s *Server) handleSettingsResolver(w http.ResponseWriter, r *http.Request) error {
s.settingsPage(w, r, "settings_resolver", "resolver", "Resolver Settings", map[string]any{
"Upstreams": s.app.Resolver.Statuses(),
"Strategies": []string{
config.StrategyFastest, config.StrategySequential,
config.StrategyRoundRobin, config.StrategyRandom,
},
})
return nil
}
func (s *Server) handleSettingsResolverSave(w http.ResponseWriter, r *http.Request) error {
if err := parseForm(r); err != nil {
return err
}
next := s.app.Settings()
next.Resolver.Upstreams = config.SplitLines(r.FormValue("upstreams"))
next.Resolver.TimeoutMS = formInt(r, "timeout_ms", next.Resolver.TimeoutMS)
next.Resolver.Retries = formInt(r, "retries", next.Resolver.Retries)
next.Resolver.Strategy = formString(r, "strategy")
next.Resolver.AllowNetworks = config.SplitLines(r.FormValue("allow_networks"))
next.Resolver.DenyNetworks = config.SplitLines(r.FormValue("deny_networks"))
next.Resolver.PreferIPv6 = formBool(r, "prefer_ipv6")
next.Resolver.DNSSEC = formBool(r, "dnssec")
next.Resolver.MaxConcurrent = formInt(r, "max_concurrent", next.Resolver.MaxConcurrent)
if err := s.app.SaveSettings(r.Context(), s.actor(r), app.GroupResolver, next); err != nil {
return err
}
setFlash(w, r, "success", "Resolver settings saved and applied.")
return s.redirect(w, r, "/settings/resolver")
}
// --- Cache --------------------------------------------------------------
func (s *Server) handleSettingsCache(w http.ResponseWriter, r *http.Request) error {
s.settingsPage(w, r, "settings_cache", "cache", "Cache Settings", map[string]any{
"Stats": s.app.CacheStats(),
})
return nil
}
func (s *Server) handleSettingsCacheSave(w http.ResponseWriter, r *http.Request) error {
if err := parseForm(r); err != nil {
return err
}
next := s.app.Settings()
next.Cache.Enabled = formBool(r, "enabled")
next.Cache.MaxEntries = formInt(r, "max_entries", next.Cache.MaxEntries)
next.Cache.MinTTL = formInt(r, "min_ttl", next.Cache.MinTTL)
next.Cache.MaxTTL = formInt(r, "max_ttl", next.Cache.MaxTTL)
next.Cache.NegativeTTL = formInt(r, "negative_ttl", next.Cache.NegativeTTL)
next.Cache.ServeStale = formBool(r, "serve_stale")
next.Cache.StaleTTL = formInt(r, "stale_ttl", next.Cache.StaleTTL)
next.Cache.Prefetch = formBool(r, "prefetch")
next.Cache.PrefetchPercent = formInt(r, "prefetch_pct", next.Cache.PrefetchPercent)
next.Cache.CleanupSeconds = formInt(r, "cleanup_seconds", next.Cache.CleanupSeconds)
if err := s.app.SaveSettings(r.Context(), s.actor(r), app.GroupCache, next); err != nil {
return err
}
setFlash(w, r, "success", "Cache settings saved and applied.")
return s.redirect(w, r, "/settings/cache")
}
// --- Logging ------------------------------------------------------------
func (s *Server) handleSettingsLogging(w http.ResponseWriter, r *http.Request) error {
rows, _ := s.app.DB.QueryLogCount(r.Context())
s.settingsPage(w, r, "settings_logging", "logging", "Logging Settings", map[string]any{
"Stats": s.app.QueryLog.Stats(),
"QueryLogRows": rows,
})
return nil
}
func (s *Server) handleSettingsLoggingSave(w http.ResponseWriter, r *http.Request) error {
if err := parseForm(r); err != nil {
return err
}
next := s.app.Settings()
next.QueryLog.Enabled = formBool(r, "querylog_enabled")
next.QueryLog.RetentionDays = formInt(r, "retention_days", next.QueryLog.RetentionDays)
next.QueryLog.MaxRows = formInt(r, "max_rows", next.QueryLog.MaxRows)
next.QueryLog.CleanupMinutes = formInt(r, "cleanup_minutes", next.QueryLog.CleanupMinutes)
next.QueryLog.IgnoreNetworks = config.SplitLines(r.FormValue("ignore_networks"))
next.QueryLog.IgnoreDomains = config.SplitLines(r.FormValue("ignore_domains"))
next.Logging.Level = formString(r, "log_level")
next.Logging.Format = formString(r, "log_format")
next.Logging.AuditMaxRows = formInt(r, "audit_max_rows", next.Logging.AuditMaxRows)
if err := s.app.SaveSettings(r.Context(), s.actor(r), app.GroupLogging, next); err != nil {
return err
}
setFlash(w, r, "success", "Logging settings saved and applied.")
return s.redirect(w, r, "/settings/logging")
}
// --- HTTP and rate limiting ---------------------------------------------
func (s *Server) handleSettingsHTTP(w http.ResponseWriter, r *http.Request) error {
s.settingsPage(w, r, "settings_http", "http", "Web Server Settings", map[string]any{
"RateLimit": s.app.Limiter.Stats(),
"Bound": s.app.Boot.HTTPAddr,
})
return nil
}
func (s *Server) handleSettingsHTTPSave(w http.ResponseWriter, r *http.Request) error {
if err := parseForm(r); err != nil {
return err
}
next := s.app.Settings()
next.HTTP.Listen = formString(r, "listen")
next.HTTP.BaseURL = formString(r, "base_url")
next.HTTP.TrustedProxies = config.SplitLines(r.FormValue("trusted_proxies"))
next.HTTP.MetricsEnabled = formBool(r, "metrics_enabled")
next.HTTP.MetricsPublic = formBool(r, "metrics_public")
next.HTTP.MaxUploadMB = formInt(r, "max_upload_mb", next.HTTP.MaxUploadMB)
next.HTTP.RateLimitPerMin = formInt(r, "http_rate_limit", next.HTTP.RateLimitPerMin)
next.RateLimit.Enabled = formBool(r, "dns_ratelimit_enabled")
next.RateLimit.QPS = formInt(r, "dns_qps", next.RateLimit.QPS)
next.RateLimit.Burst = formInt(r, "dns_burst", next.RateLimit.Burst)
next.RateLimit.ExemptNetworks = config.SplitLines(r.FormValue("exempt_networks"))
if err := s.app.SaveSettings(r.Context(), s.actor(r), app.GroupHTTP, next); err != nil {
return err
}
setFlash(w, r, "success", "Web server settings saved.")
return s.redirect(w, r, "/settings/http")
}
// --- Database and backups -----------------------------------------------
func (s *Server) handleSettingsDatabase(w http.ResponseWriter, r *http.Request) error {
stats, err := s.app.DatabaseStats(r.Context())
if err != nil {
return err
}
backups, err := s.app.BackupList()
if err != nil {
// A missing or unreadable directory should not hide the whole page.
s.log.Warn("could not list backups", "error", err)
}
migrations, _ := s.app.DB.MigrationStatuses(r.Context())
s.settingsPage(w, r, "settings_database", "database", "Database Settings", map[string]any{
"DBStats": stats,
"Backups": backups,
"BackupStatus": s.app.BackupStatus(),
"Migrations": migrations,
"PendingRestore": s.app.PendingRestore(),
})
return nil
}
func (s *Server) handleSettingsDatabaseSave(w http.ResponseWriter, r *http.Request) error {
if err := parseForm(r); err != nil {
return err
}
next := s.app.Settings()
next.Backup.Enabled = formBool(r, "backup_enabled")
next.Backup.Directory = formString(r, "backup_dir")
next.Backup.IntervalHours = formInt(r, "interval_hours", next.Backup.IntervalHours)
next.Backup.Retention = formInt(r, "retention", next.Backup.Retention)
if err := s.app.SaveSettings(r.Context(), s.actor(r), app.GroupBackup, next); err != nil {
return err
}
setFlash(w, r, "success", "Backup settings saved.")
return s.redirect(w, r, "/settings/database")
}
func (s *Server) handleBackupNow(w http.ResponseWriter, r *http.Request) error {
if err := parseForm(r); err != nil {
return err
}
info, err := s.app.RunBackup(r.Context(), s.actor(r))
if err != nil {
return err
}
setFlash(w, r, "success", fmt.Sprintf("Backup %s created (%s).", info.Name, humanBytes(info.SizeBytes)))
return s.redirect(w, r, "/settings/database")
}
func (s *Server) handleBackupDownload(w http.ResponseWriter, r *http.Request) error {
name := r.PathValue("name")
f, info, err := s.app.OpenBackup(name)
if err != nil {
return err
}
defer f.Close()
w.Header().Set("Content-Type", "application/vnd.sqlite3")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", info.Name))
w.Header().Set("Content-Length", fmt.Sprint(info.SizeBytes))
if _, err := copyLimited(w, f, info.SizeBytes); err != nil {
s.log.Warn("backup download interrupted", "backup", info.Name, "error", err)
}
return nil
}
func (s *Server) handleBackupDelete(w http.ResponseWriter, r *http.Request) error {
if err := parseForm(r); err != nil {
return err
}
name := r.PathValue("name")
if err := s.app.DeleteBackup(r.Context(), s.actor(r), name); err != nil {
return err
}
setFlash(w, r, "success", fmt.Sprintf("Backup %s deleted.", name))
return s.redirect(w, r, "/settings/database")
}
func (s *Server) handleBackupRestore(w http.ResponseWriter, r *http.Request) error {
if err := parseForm(r); err != nil {
return err
}
name := r.PathValue("name")
// Restoring replaces every zone, record and policy. Requiring the file
// name to be retyped makes it very hard to do by accident.
if formString(r, "confirm") != name {
return app.Invalid("Type the backup file name exactly (%s) to confirm the restore.", name)
}
if err := s.app.StageRestore(r.Context(), s.actor(r), name); err != nil {
return err
}
setFlash(w, r, "warning", fmt.Sprintf(
"Backup %s is staged. It replaces the live database the next time this server starts. "+
"Restart now to apply it, or cancel below.", name))
return s.redirect(w, r, "/settings/database")
}
func (s *Server) handleRestoreCancel(w http.ResponseWriter, r *http.Request) error {
if err := parseForm(r); err != nil {
return err
}
if err := s.app.CancelRestore(r.Context(), s.actor(r)); err != nil {
return err
}
setFlash(w, r, "success", "The staged restore was cancelled.")
return s.redirect(w, r, "/settings/database")
}
// --- API tokens and configuration transfer ------------------------------
func (s *Server) handleSettingsAPI(w http.ResponseWriter, r *http.Request) error {
tokens, err := s.app.APITokens(r.Context())
if err != nil {
return err
}
// A token secret is shown exactly once, immediately after creation, via a
// single-use flash carried across the redirect.
s.settingsPage(w, r, "settings_api", "api", "API Settings", map[string]any{
"Tokens": tokens,
"BaseURL": s.app.Settings().HTTP.BaseURL,
})
return nil
}
func (s *Server) handleTokenCreate(w http.ResponseWriter, r *http.Request) error {
if err := parseForm(r); err != nil {
return err
}
tok, err := s.app.CreateAPIToken(r.Context(), s.actor(r), formString(r, "name"), formString(r, "description"))
if err != nil {
return err
}
setFlash(w, r, "warning", fmt.Sprintf(
"Token %q created. Copy it now, it is not shown again: %s", tok.Name, tok.Secret))
return s.redirect(w, r, "/settings/api")
}
func (s *Server) handleTokenToggle(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if err := parseForm(r); err != nil {
return err
}
enabled := formBool(r, "enabled")
if err := s.app.SetAPITokenEnabled(r.Context(), s.actor(r), id, enabled); err != nil {
return err
}
setFlash(w, r, "success", "Token "+enabledWord(enabled)+".")
return s.redirect(w, r, "/settings/api")
}
func (s *Server) handleTokenDelete(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if err := parseForm(r); err != nil {
return err
}
if err := s.app.DeleteAPIToken(r.Context(), s.actor(r), id); err != nil {
return err
}
setFlash(w, r, "success", "Token revoked.")
return s.redirect(w, r, "/settings/api")
}
func (s *Server) handleConfigExport(w http.ResponseWriter, r *http.Request) error {
includeDomains := formBool(r, "include_domains")
filename := fmt.Sprintf("vibedns-config-%s.json", s.app.StartedAt().UTC().Format("20060102"))
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
return s.app.WriteConfigExport(r.Context(), w, includeDomains)
}
func (s *Server) handleConfigImport(w http.ResponseWriter, r *http.Request) error {
if err := parseMultipart(r, 16); err != nil {
return err
}
file, _, ferr := r.FormFile("file")
if ferr != nil {
return app.Invalid("Choose a configuration export file to import.")
}
defer file.Close()
report, err := s.app.ImportConfig(r.Context(), s.actor(r), file, formBool(r, "apply_settings"))
if err != nil {
return err
}
msg := fmt.Sprintf("Imported %d zones, %d records, %d networks, %d policies, %d lists and %s domains.",
report.Zones, report.Records, report.Networks, report.Policies, report.Lists, humanNumber(report.Domains))
if len(report.Conflicts) > 0 {
msg += " Existing objects were left unchanged: " + strings.Join(report.Conflicts, "; ") + "."
}
setFlash(w, r, "success", msg)
return s.redirect(w, r, "/settings/database")
}