Files
vibedns/internal/web/server.go
T
owenandClaude Sonnet 5 e6deb5fe84 Fix stale block page preview by cache-busting static assets
The preview iframe stayed blank after a redeploy because /static/js/app.js
is served with a 24h Cache-Control and no versioning, so browsers kept
using the pre-existing cached copy that predated the preview code. Static
CSS/JS references now carry a ?v=<build commit> query string so a new
build is never masked by a stale cache. Also makes the CSP's frame-src
explicit for the preview iframe rather than relying on the default-src
fallback.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TTKpGMQzpfDsvedu1hvSUf
2026-08-17 00:35:26 -05:00

517 lines
18 KiB
Go

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