// Package web serves the Bootstrap management interface, the observability // endpoints and the static assets. The REST API is mounted underneath it. package web import ( "context" "errors" "fmt" "log/slog" "net/http" "runtime/debug" "strconv" "strings" "sync" "time" "github.com/owen/vibedns/internal/app" "github.com/owen/vibedns/internal/auditlog" "github.com/owen/vibedns/internal/auth" "github.com/owen/vibedns/internal/netutil" webui "github.com/owen/vibedns/web" ) // Server is the HTTP management interface. type Server struct { app *app.App log *slog.Logger tmpl *templates http *http.Server apiMount http.Handler limiter *httpLimiter mu sync.Mutex addr string } // Options configures the HTTP server. type Options struct { App *app.App Log *slog.Logger // API is mounted at /api/v1 when non-nil. API http.Handler } // New creates the HTTP server and parses the embedded templates. func New(opts Options) (*Server, error) { tmpl, err := loadTemplates(templateFuncs()) if err != nil { return nil, err } s := &Server{ app: opts.App, log: opts.Log, tmpl: tmpl, apiMount: opts.API, limiter: newHTTPLimiter(), } return s, nil } // Handler builds the fully wrapped HTTP handler. func (s *Server) Handler() http.Handler { mux := http.NewServeMux() s.routes(mux) var h http.Handler = mux h = s.withBodyLimit(h) h = s.withRateLimit(h) h = s.withRequestLog(h) h = s.withSecurityHeaders(h) h = s.withRecover(h) return h } // Start binds the management listener. func (s *Server) Start(addr string) error { h := s.Handler() s.mu.Lock() s.addr = addr s.http = &http.Server{ Addr: addr, Handler: h, ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 2 * time.Minute, // large blocklist uploads WriteTimeout: 5 * time.Minute, // large exports IdleTimeout: 120 * time.Second, ErrorLog: slog.NewLogLogger(s.log.Handler(), slog.LevelDebug), } srv := s.http s.mu.Unlock() ln, err := listen(addr) if err != nil { return err } go func() { if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) { s.log.Error("management HTTP server stopped", "error", err) } }() s.log.Info("management interface started", "address", addr) return nil } // Shutdown stops the management listener. func (s *Server) Shutdown(ctx context.Context) error { s.mu.Lock() srv := s.http s.mu.Unlock() if srv == nil { return nil } return srv.Shutdown(ctx) } // routes registers every endpoint. func (s *Server) routes(mux *http.ServeMux) { // Public endpoints. Static assets are unauthenticated because they are // Bootstrap and our own CSS: requiring credentials for them would force an // Argon2 verification on every asset request for no benefit. static := http.FileServer(http.FS(webui.Static())) mux.Handle("GET /static/", http.StripPrefix("/static/", cacheStatic(static))) mux.HandleFunc("GET /favicon.ico", s.handleFavicon) mux.HandleFunc("GET /healthz", s.handleHealthz) mux.HandleFunc("GET /readyz", s.handleReadyz) mux.HandleFunc("GET /metrics", s.handleMetrics) if s.apiMount != nil { mux.Handle("/api/v1/", s.apiMount) } // Everything below requires the administrator. page := s.protect mux.Handle("GET /{$}", page(s.handleDashboard)) mux.Handle("GET /dashboard", page(s.handleDashboard)) // Zones. mux.Handle("GET /zones", page(s.handleZones)) mux.Handle("GET /zones/reverse", page(s.handleZonesReverse)) mux.Handle("GET /zones/new", page(s.handleZoneNew)) mux.Handle("POST /zones/new", page(s.handleZoneCreate)) mux.Handle("GET /zones/{id}", page(s.handleZoneRecords)) mux.Handle("GET /zones/{id}/edit", page(s.handleZoneEdit)) mux.Handle("POST /zones/{id}/edit", page(s.handleZoneUpdate)) mux.Handle("POST /zones/{id}/delete", page(s.handleZoneDelete)) mux.Handle("POST /zones/{id}/toggle", page(s.handleZoneToggle)) mux.Handle("POST /zones/{id}/clone", page(s.handleZoneClone)) mux.Handle("GET /zones/{id}/export", page(s.handleZoneExport)) mux.Handle("POST /zones/{id}/import", page(s.handleZoneImport)) // Records. mux.Handle("GET /records", page(s.handleRecordsAll)) mux.Handle("POST /zones/{id}/records", page(s.handleRecordCreate)) mux.Handle("POST /zones/{id}/records/bulk", page(s.handleRecordBulk)) mux.Handle("POST /records/{id}/edit", page(s.handleRecordUpdate)) mux.Handle("POST /records/{id}/delete", page(s.handleRecordDelete)) mux.Handle("POST /records/{id}/toggle", page(s.handleRecordToggle)) // Resolver and cache. mux.Handle("GET /resolver", page(s.handleResolver)) mux.Handle("POST /resolver/test", page(s.handleResolverTest)) mux.Handle("GET /cache", page(s.handleCache)) mux.Handle("POST /cache/flush", page(s.handleCacheFlush)) mux.Handle("POST /cache/delete", page(s.handleCacheDelete)) // Policies. mux.Handle("GET /policies", page(s.handlePolicies)) mux.Handle("GET /policies/networks", page(s.handleNetworks)) mux.Handle("GET /policies/networks/new", page(s.handleNetworkNew)) mux.Handle("POST /policies/networks/new", page(s.handleNetworkCreate)) mux.Handle("GET /policies/networks/{id}", page(s.handleNetworkEdit)) mux.Handle("POST /policies/networks/{id}", page(s.handleNetworkUpdate)) mux.Handle("POST /policies/networks/{id}/delete", page(s.handleNetworkDelete)) mux.Handle("POST /policies/networks/{id}/toggle", page(s.handleNetworkToggle)) mux.Handle("GET /policies/rules/new", page(s.handlePolicyNew)) mux.Handle("POST /policies/rules/new", page(s.handlePolicyCreate)) mux.Handle("GET /policies/rules/{id}", page(s.handlePolicyEdit)) mux.Handle("POST /policies/rules/{id}", page(s.handlePolicyUpdate)) mux.Handle("POST /policies/rules/{id}/delete", page(s.handlePolicyDelete)) mux.Handle("POST /policies/rules/{id}/toggle", page(s.handlePolicyToggle)) mux.Handle("GET /policies/blacklists", page(s.handleBlacklists)) mux.Handle("GET /policies/allowlists", page(s.handleAllowlists)) mux.Handle("POST /policies/lists/new", page(s.handleListCreate)) mux.Handle("GET /policies/lists/{id}", page(s.handleListDetail)) mux.Handle("POST /policies/lists/{id}", page(s.handleListUpdate)) mux.Handle("POST /policies/lists/{id}/delete", page(s.handleListDelete)) mux.Handle("POST /policies/lists/{id}/toggle", page(s.handleListToggle)) mux.Handle("POST /policies/lists/{id}/clear", page(s.handleListClear)) mux.Handle("POST /policies/lists/{id}/import", page(s.handleListImport)) mux.Handle("GET /policies/lists/{id}/export", page(s.handleListExport)) mux.Handle("POST /policies/lists/{id}/domains", page(s.handleDomainAdd)) mux.Handle("POST /policies/domains/{id}/delete", page(s.handleDomainDelete)) // Logs. mux.Handle("GET /querylog", page(s.handleQueryLog)) mux.Handle("POST /querylog/clear", page(s.handleQueryLogClear)) mux.Handle("GET /audit", page(s.handleAuditLog)) // Tools. mux.Handle("GET /tools", page(s.handleTools)) mux.Handle("POST /tools/lookup", page(s.handleToolsLookup)) // Settings. mux.Handle("GET /settings", page(s.redirectTo("/settings/dns"))) mux.Handle("GET /settings/dns", page(s.handleSettingsDNS)) mux.Handle("POST /settings/dns", page(s.handleSettingsDNSSave)) mux.Handle("GET /settings/resolver", page(s.handleSettingsResolver)) mux.Handle("POST /settings/resolver", page(s.handleSettingsResolverSave)) mux.Handle("GET /settings/cache", page(s.handleSettingsCache)) mux.Handle("POST /settings/cache", page(s.handleSettingsCacheSave)) mux.Handle("GET /settings/logging", page(s.handleSettingsLogging)) mux.Handle("POST /settings/logging", page(s.handleSettingsLoggingSave)) mux.Handle("GET /settings/http", page(s.handleSettingsHTTP)) mux.Handle("POST /settings/http", page(s.handleSettingsHTTPSave)) mux.Handle("GET /settings/database", page(s.handleSettingsDatabase)) mux.Handle("POST /settings/database", page(s.handleSettingsDatabaseSave)) mux.Handle("POST /settings/database/backup", page(s.handleBackupNow)) mux.Handle("GET /settings/database/backup/{name}", page(s.handleBackupDownload)) mux.Handle("POST /settings/database/backup/{name}/delete", page(s.handleBackupDelete)) mux.Handle("POST /settings/database/backup/{name}/restore", page(s.handleBackupRestore)) mux.Handle("POST /settings/database/restore/cancel", page(s.handleRestoreCancel)) mux.Handle("GET /settings/blockpage", page(s.handleSettingsBlockPage)) mux.Handle("POST /settings/blockpage", page(s.handleSettingsBlockPageSave)) mux.Handle("POST /settings/blockpage/preview", page(s.handleSettingsBlockPagePreview)) mux.Handle("GET /settings/api", page(s.handleSettingsAPI)) mux.Handle("POST /settings/api/tokens", page(s.handleTokenCreate)) mux.Handle("POST /settings/api/tokens/{id}/delete", page(s.handleTokenDelete)) mux.Handle("POST /settings/api/tokens/{id}/toggle", page(s.handleTokenToggle)) mux.Handle("GET /settings/config/export", page(s.handleConfigExport)) mux.Handle("POST /settings/config/import", page(s.handleConfigImport)) // Account. mux.Handle("GET /account", page(s.handleAccount)) mux.Handle("POST /account", page(s.handleAccountSave)) // Anything unmatched. Registered without a method so it does not conflict // with the "/api/v1/" prefix pattern: ServeMux rejects a method-specific // catch-all that is more general in path than an existing prefix route. mux.Handle("/", page(s.handleNotFound)) } // handlerFunc is a page handler that may return an error for central handling. type handlerFunc func(http.ResponseWriter, *http.Request) error // protect wraps a page handler with authentication and CSRF enforcement. func (s *Server) protect(fn handlerFunc) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { p, err := s.app.Auth.Authenticate(r, false) if err != nil { s.challenge(w, r, err) return } ctx := auth.WithPrincipal(r.Context(), p) r = r.WithContext(ctx) // The CSRF cookie is refreshed on every page view so it never expires // out from under an open tab. token := s.app.Auth.IssueCSRFToken(p.Name) if r.Method == http.MethodGet { auth.SetCSRFCookie(w, r, token) } else if err := s.app.Auth.CheckCSRF(r, p); err != nil { s.log.Warn("rejected a request that failed the CSRF check", "path", r.URL.Path, "client", p.ClientIP) s.renderError(w, r, http.StatusForbidden, err.Error()) return } if err := fn(w, r); err != nil { s.handleError(w, r, err) } }) } // challenge sends the Basic authentication challenge, or a lockout message. func (s *Server) challenge(w http.ResponseWriter, r *http.Request, err error) { switch { case errors.Is(err, auth.ErrLockedOut): w.Header().Set("Retry-After", "300") s.renderError(w, r, http.StatusTooManyRequests, "Too many failed sign-in attempts from this address. Try again in a few minutes.") case errors.Is(err, auth.ErrNoAdmin): s.renderError(w, r, http.StatusServiceUnavailable, "No administrator account exists yet. Restart the server to create one.") default: w.Header().Set("WWW-Authenticate", fmt.Sprintf("Basic realm=%q, charset=\"UTF-8\"", auth.Realm)) s.renderError(w, r, http.StatusUnauthorized, "Sign in to continue.") } } // handleError turns a service error into a flash or an error page. func (s *Server) handleError(w http.ResponseWriter, r *http.Request, err error) { if app.IsInternal(err) { s.log.Error("request failed", "path", r.URL.Path, "method", r.Method, "error", err) } status := app.StatusOf(err) message := app.MessageOf(err) // A failed form submission returns the operator to the page they were on // with the reason shown, rather than dumping them on an error page. if r.Method == http.MethodPost && status < 500 { setFlash(w, r, "danger", message) s.redirectBack(w, r) return } s.renderError(w, r, status, message) } // redirectBack returns to the referring page, or a supplied fallback. func (s *Server) redirectBack(w http.ResponseWriter, r *http.Request) { target := r.FormValue("return_to") if target == "" { target = safeReferer(r) } if target == "" { target = "/" } http.Redirect(w, r, target, http.StatusSeeOther) } // safeReferer only accepts a same-origin path, so a crafted Referer cannot // turn an error into an open redirect. func safeReferer(r *http.Request) string { ref := r.Header.Get("Referer") if ref == "" { return "" } if strings.HasPrefix(ref, "/") && !strings.HasPrefix(ref, "//") { return ref } if u, err := parseURL(ref); err == nil && u.Host == r.Host { return u.RequestURI() } return "" } func (s *Server) redirectTo(target string) handlerFunc { return func(w http.ResponseWriter, r *http.Request) error { http.Redirect(w, r, target, http.StatusSeeOther) return nil } } // redirect issues a see-other redirect after a successful mutation. func (s *Server) redirect(w http.ResponseWriter, r *http.Request, target string) error { http.Redirect(w, r, target, http.StatusSeeOther) return nil } // actor builds the audit actor for the current request. func (s *Server) actor(r *http.Request) auditlog.Actor { p, _ := auth.PrincipalFrom(r.Context()) source := auditlog.SourceWeb if p.Kind == auth.KindToken { source = auditlog.SourceAPI } return auditlog.Actor{Name: p.Name, Source: source, ClientIP: p.ClientIP} } // base builds the common page envelope. func (s *Server) base(r *http.Request, title, nav string) PageData { p, _ := auth.PrincipalFrom(r.Context()) return PageData{ Title: title, Nav: nav, User: p, CSRF: s.app.Auth.IssueCSRFToken(p.Name), Query: r.URL.Query(), } } // pathID reads an {id} path parameter. func pathID(r *http.Request, name string) (int64, error) { raw := r.PathValue(name) id, err := strconv.ParseInt(raw, 10, 64) if err != nil || id <= 0 { return 0, app.Invalid("%q is not a valid identifier.", raw) } return id, nil } // --- middleware --------------------------------------------------------- func (s *Server) withRecover(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer func() { if rec := recover(); rec != nil { s.log.Error("panic while handling request", "path", r.URL.Path, "panic", rec, "stack", string(debug.Stack())) // The stack trace goes to the log, never to the browser. s.renderError(w, r, http.StatusInternalServerError, "An unexpected error occurred. Check the server log for details.") } }() next.ServeHTTP(w, r) }) } func (s *Server) withSecurityHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := w.Header() h.Set("X-Content-Type-Options", "nosniff") h.Set("X-Frame-Options", "DENY") h.Set("Referrer-Policy", "same-origin") h.Set("Cross-Origin-Opener-Policy", "same-origin") h.Set("Permissions-Policy", "geolocation=(), microphone=(), camera=(), interest-cohort=()") // Everything is served from this origin: no CDN, no inline event // handlers, no eval. 'unsafe-inline' is allowed for style attributes // only, which Bootstrap components set programmatically. h.Set("Content-Security-Policy", "default-src 'self'; "+ "script-src 'self'; "+ "style-src 'self' 'unsafe-inline'; "+ "img-src 'self' data:; "+ "font-src 'self'; "+ "connect-src 'self'; "+ "form-action 'self'; "+ "frame-src 'self'; "+ "frame-ancestors 'none'; "+ "base-uri 'none'; "+ "object-src 'none'") if r.TLS != nil { h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") } next.ServeHTTP(w, r) }) } // statusRecorder captures the response status for logging. type statusRecorder struct { http.ResponseWriter status int bytes int } func (w *statusRecorder) WriteHeader(code int) { w.status = code w.ResponseWriter.WriteHeader(code) } func (w *statusRecorder) Write(b []byte) (int, error) { if w.status == 0 { w.status = http.StatusOK } n, err := w.ResponseWriter.Write(b) w.bytes += n return n, err } func (s *Server) withRequestLog(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() rec := &statusRecorder{ResponseWriter: w} next.ServeHTTP(rec, r) if strings.HasPrefix(r.URL.Path, "/static/") { return // asset noise } level := slog.LevelDebug if rec.status >= 500 { level = slog.LevelError } else if rec.status >= 400 { level = slog.LevelWarn } s.log.Log(r.Context(), level, "http request", "method", r.Method, "path", r.URL.Path, "status", rec.status, "bytes", rec.bytes, "duration_ms", time.Since(start).Milliseconds(), "client", s.app.Auth.ClientIP(r)) }) } // withBodyLimit caps request bodies so an oversized upload cannot exhaust // memory. The limit follows the configured upload size. func (s *Server) withBodyLimit(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Body != nil && r.Method != http.MethodGet && r.Method != http.MethodHead { limit := int64(s.app.Settings().HTTP.MaxUploadMB) * 1024 * 1024 if limit <= 0 { limit = 64 * 1024 * 1024 } r.Body = http.MaxBytesReader(w, r.Body, limit) } next.ServeHTTP(w, r) }) } // withRateLimit applies a coarse per-address request limit to the management // interface. It is a brute-force and runaway-script guard, not a DoS defence. func (s *Server) withRateLimit(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.HasPrefix(r.URL.Path, "/static/") || r.URL.Path == "/healthz" { next.ServeHTTP(w, r) return } perMin := s.app.Settings().HTTP.RateLimitPerMin addr, ok := netutil.AddrFromHostPort(s.app.Auth.ClientIP(r)) if ok && !s.limiter.allow(addr.String(), perMin) { w.Header().Set("Retry-After", "60") http.Error(w, "Too many requests. Slow down and try again shortly.", http.StatusTooManyRequests) return } next.ServeHTTP(w, r) }) } // cacheStatic marks embedded assets as immutable. They only change when the // binary changes, and the binary is what serves them. func cacheStatic(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "public, max-age=86400") next.ServeHTTP(w, r) }) }