399 lines
14 KiB
Go
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")
|
|
}
|