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") }