// SPDX-License-Identifier: Apache-2.0 package server import ( "bytes" "context" "crypto/rand" "crypto/sha256" "crypto/tls" "database/sql" "encoding/base64" "encoding/csv" "encoding/hex" "encoding/json" "fmt" "html/template" "image/png" "io" "io/fs" "log" "net" "net/http" "net/smtp" "net/url" "os" "slices" "sort" "strconv" "strings" "sync" "sync/atomic" "time" "github.com/X4Applegate/caddyui/internal/analytics" "github.com/X4Applegate/caddyui/internal/auth" "github.com/X4Applegate/caddyui/internal/caddy" "github.com/X4Applegate/caddyui/internal/caddylogs" appdb "github.com/X4Applegate/caddyui/internal/db" "github.com/X4Applegate/caddyui/internal/dns" "github.com/X4Applegate/caddyui/internal/models" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" otplib "github.com/pquerna/otp" totplib "github.com/pquerna/otp/totp" ) type Server struct { DB *sql.DB Caddy *caddy.Client Templates map[string]*template.Template Static fs.FS CaddyfilePath string Version string // DBPath is the filesystem path to the SQLite DB. Stored so the backup // handler can write its VACUUM INTO temp file next to the real DB (same // volume, guaranteed writable by our UID) — `os.TempDir()` → /tmp doesn't // exist in the scratch final image, and creating it at runtime as a // non-root UID isn't allowed. v2.7.5. DBPath string pendingTOTP sync.Map // token → userID (int64), auto-deleted after 5 min setupMu sync.Mutex // serializes first-run setup (see postSetup) syncMu sync.Mutex // serializes syncCaddy: it swaps the shared s.Caddy client // pendingTOTPAttempts counts wrong second-factor codes per pending token // (token → *int32) so one password check cannot buy unlimited guesses. pendingTOTPAttempts sync.Map // CSRF HMAC key, loaded from (or generated into) the settings table on // first use. Per-Server rather than package-level so two Servers in the // same test binary don't share one another's key. v2.29.0. csrfSecretOnce sync.Once csrfSecretCache []byte // version-check cache (Docker Hub, 1h TTL) versionMu sync.Mutex latestVersion string versionCheckedAt time.Time // health-poller hysteresis: count consecutive failed pings per server so // a single WG/network blip doesn't instantly flap a server to offline. // Only flip to offline after healthFailThreshold consecutive failures. healthMu sync.Mutex healthFailures map[int64]int // app-response health cache — end-to-end HTTPS GET / result per proxy host, // refreshed by StartAppHealthPoller. Keyed by ProxyHost.ID. Independent of // the TCP/port health (which Caddy's admin API reports): this probes the // public domain through Caddy, so it catches cases where the port is open // but the app is wedged (e.g. DB unreachable, slow startup). appHealthMu sync.RWMutex appHealth map[int64]appHealthEntry // v2.38.0: post-apply expectation results, per process (see expectations.go). expectationMu sync.RWMutex expectationResults map[int64][]expectationResult expectationRuns map[int64]expectationRun // Serializes source-to-target fleet copies inside this process. The // deployment mapping table supplies durable idempotency; this mutex closes // the check-then-create race between concurrent UI submissions. fleetDeployMu sync.Mutex // v2.7.0: handle to the analytics ingest listener. Wired by main.go // via SetAnalyticsIngest after Server construction (rather than as a // New() arg) so the wiring order stays readable — the ingest owns a // DB ref that has to exist before we hand it over. analyticsIngest *analytics.Ingest // Runtime Caddy logs share the analytics NDJSON socket but stay in a // bounded in-memory hub. Only the latest certificate lifecycle projection // is persisted. Temporary full-log captures are guarded by expiry timers. caddyLogHub *caddylogs.Hub runtimeLogMu sync.Mutex runtimeLogTimers map[int64]*time.Timer certificateProbeFn func(models.CaddyServer, models.Certificate) managedCertificateServerStatus // v2.39.0: live TLS probes of custom (PEM / file-path) certificates, see // certificate_probe.go. certProbeTargetFn lets tests point the dial at a // local listener instead of :443. certProbeRunMu sync.Mutex certProbeTargetFn func(serverID int64, probeName string) string // v2.42.0: serializes certificate export passes (certificate_export.go). certExportRunMu sync.Mutex // v2.43.0: analytics retention — one prune and one VACUUM at a time. accessPruneMu sync.Mutex vacuumMu sync.Mutex } type apiTokenScopeContextKey struct{} // SetAnalyticsIngest plumbs the analytics ingest listener into the server // so handlers can surface its stats on /analytics and /settings. Called // once at startup from main.go after the ingest listener has bound. // Passing nil is valid and disables the stats card — happens when the // CADDYUI_INGEST_LISTEN env var is blank or the bind failed at startup. func (s *Server) SetAnalyticsIngest(ing *analytics.Ingest) { s.analyticsIngest = ing } func (s *Server) SetCaddyLogHub(hub *caddylogs.Hub) { s.caddyLogHub = hub if hub != nil { // v2.42.0: an issuance/renewal reported by a node triggers the // export of every exporting certificate that covers the name. hub.OnCertificateActive = s.handleCertificateActive } } func New(db *sql.DB, caddyClient *caddy.Client, templates fs.FS, static fs.FS, caddyfilePath string, version string, dbPath string) (*Server, error) { tpl, err := parseTemplates(templates) if err != nil { return nil, err } // Resolve the active timezone once at startup so the very first render // uses the admin's picked zone (DB) rather than whatever time.Local // happens to be. Priority: DB value → TZ env var → UTC. See timezone.go. loc := loadActiveLocation(db) log.Printf("timezone: rendering timestamps in %s", loc) s := &Server{ DB: db, Caddy: caddyClient, Templates: tpl, Static: static, CaddyfilePath: caddyfilePath, Version: version, DBPath: dbPath, healthFailures: map[int64]int{}, appHealth: map[int64]appHealthEntry{}, runtimeLogTimers: map[int64]*time.Timer{}, } go s.runHealthChecker() go s.runAutoSyncLoop() go s.runFleetDeletionReconciler() // v2.59.0 (issue #120) go s.runMaintenanceWindowLoop() go s.runActivityLogCleanup() go s.runAccessDailyAggregator() // v2.43.0: this loop existed since v2.7.0 but was never started, so // access_events grew without bound (analytics_retention.go). go s.pruneAccessLoop() return s, nil } // runAccessDailyAggregator backfills the access_daily rollup table once at // startup (so a freshly-upgraded install picks up historical days) and then // every hour to catch the previous day shortly after UTC midnight rolls over. // All access_events older than today (UTC) are summarised into per-(day, host) // rows that AccessTotalsSince consults for long windows. v2.9.206. func (s *Server) runAccessDailyAggregator() { if n, err := models.AggregateAccessDaily(s.DB); err != nil { log.Printf("access_daily: initial backfill failed: %v", err) } else if n > 0 { log.Printf("access_daily: initial backfill aggregated %d day(s)", n) } t := time.NewTicker(1 * time.Hour) defer t.Stop() for range t.C { if n, err := models.AggregateAccessDaily(s.DB); err != nil { log.Printf("access_daily: hourly backfill failed: %v", err) } else if n > 0 { log.Printf("access_daily: aggregated %d new day(s)", n) } } } // healthFailThreshold is the number of consecutive failed pings required // before the health poller flips a server to "offline". Tunable here so WG // blips don't cause the dashboard to flap. const healthFailThreshold = 3 func parseTemplates(tplFS fs.FS) (map[string]*template.Template, error) { funcs := template.FuncMap{ "t": tFunc, // v2.62.0 (issue #128): {{t $ "key"}} "join": func(sep string, parts []string) string { return strings.Join(parts, sep) }, "upper": strings.ToUpper, // dict builds a map from alternating key/value args so templates can pass // structured context to sub-templates (e.g. layout's navItem definition). "dict": func(kv ...any) (map[string]any, error) { if len(kv)%2 != 0 { return nil, fmt.Errorf("dict requires an even number of arguments") } m := make(map[string]any, len(kv)/2) for i := 0; i < len(kv); i += 2 { k, ok := kv[i].(string) if !ok { return nil, fmt.Errorf("dict key at %d must be string", i) } m[k] = kv[i+1] } return m, nil }, "splitDomains": func(s string) []string { parts := strings.FieldsFunc(s, func(r rune) bool { return r == ',' || r == '\n' || r == ' ' }) out := make([]string, 0, len(parts)) for _, d := range parts { d = strings.TrimSpace(d) if d != "" { out = append(out, d) } } return out }, // rawRouteSourceHosts extracts the match.host[] entries from a raw // route's JSON blob so the /raw-routes table and the dashboard's // "Recent advanced routes" block can render hostname pills the same // way proxy hosts do. v2.7.9. Returns nil for path-only / port-only // routes (no host matcher) — templates fall back to the route label // in that case so the Source column never renders empty. "rawRouteSourceHosts": func(jsonData string) []string { return rawRouteHosts(models.RawRoute{JSONData: jsonData}) }, "prettyJSON": func(s string) string { var v any if err := json.Unmarshal([]byte(s), &v); err != nil { return s } out, err := json.MarshalIndent(v, "", " ") if err != nil { return s } return string(out) }, // httpCodeName returns the standard reason phrase for common redirect // status codes so templates can surface human-readable descriptions // (e.g. "301 Moved Permanently") on hover/tooltip. "httpCodeName": func(code int) string { switch code { case 301: return "Moved Permanently" case 302: return "Found (Temporary)" case 303: return "See Other" case 307: return "Temporary Redirect" case 308: return "Permanent Redirect" } return "" }, // Timezone-aware time formatters. Every visible timestamp pulled from // the DB should flow through one of these so the admin's picked zone // (Settings → Timezone) actually takes effect. activeLocation() // resolves DB → TZ env → UTC; see timezone.go. // // Templates pass a time.Time and get back a formatted string: // {{ fmtDate .CreatedAt }} → "2026-04-22" // {{ fmtDateTime .CreatedAt }} → "2026-04-22 14:30" // {{ fmtTime .CreatedAt }} → "14:30:45" // {{ tzName }} → "America/New_York" // // Zero-value times render as an empty string so we don't surface // "0001-01-01" when a nullable DB column is NULL. "fmtDate": func(t time.Time) string { if t.IsZero() { return "" } return t.In(activeLocation()).Format("2006-01-02") }, "fmtDateTime": func(t time.Time) string { if t.IsZero() { return "" } return t.In(activeLocation()).Format("2006-01-02 15:04") }, "fmtTime": func(t time.Time) string { if t.IsZero() { return "" } return t.In(activeLocation()).Format("15:04:05") }, // fmtIn is the escape-hatch: pass any Go time layout string and it // renders in the active zone. Used by templates that need a specific // visible format (e.g. "Jan 2, 2006 3:04 PM") that fmtDate/fmtDateTime // don't cover. Keeps the existing look of the page while switching // the underlying zone. "fmtIn": func(t time.Time, layout string) string { if t.IsZero() { return "" } return t.In(activeLocation()).Format(layout) }, "tzName": func() string { return activeLocation().String() }, // v2.11.12: hasPrefix / hasSuffix exposed for template-side action // string classification on the dashboard "Recently edited" widget. "hasPrefix": strings.HasPrefix, "hasSuffix": strings.HasSuffix, // truncate returns the first n bytes of s, or s itself if shorter. // Used in sessions.html to display a shortened token prefix. "truncate": func(s string, n int) string { if len(s) <= n { return s } return s[:n] }, // httpCodeDesc returns a plain-English one-liner explaining what a // redirect status code means in practice. Used in tooltips. "httpCodeDesc": func(code int) string { switch code { case 301: return "301 Moved Permanently — cached forever by browsers & search engines. Best for SEO when a URL has permanently changed. May convert POST to GET." case 302: return "302 Found (Temporary) — not cached. Use when the redirect is temporary or might change. May convert POST to GET." case 303: return "303 See Other — always converts the request to GET. Used after form submissions (POST → GET)." case 307: return "307 Temporary Redirect — like 302 but preserves the HTTP method (POST stays POST). Safer for APIs." case 308: return "308 Permanent Redirect — like 301 but preserves the HTTP method. Modern replacement for 301." } return fmt.Sprintf("HTTP %d", code) }, // Integer math helpers for SVG bar-chart rendering in the analytics // page. html/template doesn't support arithmetic on ints, so we ship // these three minimal operations rather than pulling in a full // expression language. Kept deliberately narrow — bar height = views // × maxPx / maxViews, bar y = chartHeight - barHeight, next bucket // = index + 1. Any more and we should wire up sprig. "mulDivInt": func(a, b, c int) int { if c == 0 { return 0 } return (a * b) / c }, "subInt": func(a, b int) int { return a - b }, "addInt": func(a, b int) int { return a + b }, // mulDivInt64 is the int64 analogue of mulDivInt for use with // BandwidthBucket.BytesOut (int64) SVG bar-height calculations. "mulDivInt64": func(a, b, c int64) int64 { if c == 0 { return 0 } return (a * b) / c }, "subInt64": func(a, b int64) int64 { return a - b }, // ssoLabel shortens an issuer URL for a badge. v2.68.0. "ssoLabel": ssoProviderLabel, // addInt64 lets a chart bar compute its own bucket's end time // (bucket start + bucket seconds) for an exact drill-through link. // v2.66.0 (issue #145). "addInt64": func(a, b int64) int64 { return a + b }, // fmtBytes renders a byte count as a human-readable size string. // Used in analytics cards to show bandwidth totals without overwhelming // the reader with raw byte counts (e.g. "1.4 GB" instead of "1503238553"). "fmtBytes": func(b int64) string { switch { case b >= 1<<30: return fmt.Sprintf("%.1f GB", float64(b)/(1<<30)) case b >= 1<<20: return fmt.Sprintf("%.1f MB", float64(b)/(1<<20)) case b >= 1<<10: return fmt.Sprintf("%.1f KB", float64(b)/(1<<10)) default: return fmt.Sprintf("%d B", b) } }, // fmtRel renders a time as a short human-readable interval from now // ("3m", "2h", "4d"). Used in analytics tables where an absolute // timestamp would eat column width and the admin only cares how // stale the data is. Zero time → "never" so callers don't need to // bracket every use with {{if ...}}. Future times (clock skew) // render as "in " so we don't silently swallow bad data. "fmtRel": func(t time.Time) string { if t.IsZero() { return "never" } d := time.Since(t) prefix := "" if d < 0 { prefix = "in " d = -d } else { // Past — add "ago" suffix after formatting. } var body string switch { case d < time.Minute: body = fmt.Sprintf("%ds", int(d.Seconds())) case d < time.Hour: body = fmt.Sprintf("%dm", int(d.Minutes())) case d < 24*time.Hour: body = fmt.Sprintf("%dh", int(d.Hours())) case d < 30*24*time.Hour: body = fmt.Sprintf("%dd", int(d.Hours())/24) default: body = t.In(activeLocation()).Format("2006-01-02") return prefix + body } if prefix != "" { return prefix + body } return body + " ago" }, // colorDotClass returns Tailwind bg-* classes for a host color label. // Returns an empty string when the color is blank so the dot can be // hidden entirely ({{ if .Host.Color }} guard in templates). "colorDotClass": func(c string) string { switch c { case "red": return "bg-red-400" case "orange": return "bg-orange-400" case "yellow": return "bg-yellow-400" case "green": return "bg-green-400" case "teal": return "bg-teal-400" case "blue": return "bg-blue-400" case "purple": return "bg-purple-400" case "pink": return "bg-pink-400" case "gray": return "bg-gray-400" default: return "" } }, } entries, err := fs.ReadDir(tplFS, ".") if err != nil { return nil, err } pages := map[string]*template.Template{} for _, e := range entries { if e.IsDir() || e.Name() == "layout.html" || !strings.HasSuffix(e.Name(), ".html") { continue } t, err := template.New("").Funcs(funcs).ParseFS(tplFS, "layout.html", e.Name()) if err != nil { return nil, err } pages[e.Name()] = t } return pages, nil } // safeLogFormatter is chi's request logger minus the query string. The default // logger writes the full request URI, and several flows carry a bearer secret in // the query: ?token= (password reset, invitations), ?created= (a freshly created // API token), ?t= (a pending second-factor token), the OIDC ?code=&state=. Anyone // who can read the container logs, Portainer or a log shipper would otherwise be // handed a working credential (v2.57.1). The request line is %q-quoted so // control characters in a path cannot forge log lines. type safeLogFormatter struct{} func (safeLogFormatter) NewLogEntry(r *http.Request) middleware.LogEntry { scheme := "http" if r.TLS != nil { scheme = "https" } line := fmt.Sprintf("%s %s://%s%s %s", r.Method, scheme, r.Host, r.URL.Path, r.Proto) return &safeLogEntry{prefix: fmt.Sprintf("%q from %s - ", line, r.RemoteAddr)} } type safeLogEntry struct{ prefix string } func (e *safeLogEntry) Write(status, bytes int, _ http.Header, elapsed time.Duration, _ interface{}) { log.Printf("%s%d %dB in %s", e.prefix, status, bytes, elapsed) } func (e *safeLogEntry) Panic(v interface{}, stack []byte) { middleware.PrintPrettyStack(v) } func (s *Server) Routes() http.Handler { r := chi.NewRouter() r.Use(s.adminIPGate) r.Use(s.securityHeaders) r.Use(middleware.RequestLogger(safeLogFormatter{})) // v2.57.1: path only — see safeLogFormatter r.Use(middleware.Recoverer) staticSub, err := fs.Sub(s.Static, ".") if err == nil { // v2.12.38: wrap the static FileServer with a Cache-Control header. // Without this, browsers refused to cache /static/app.css, the PWA // icons, etc. and re-fetched them on every cold navigation. // // 1 day (not 1 year) because the URLs aren't versioned — if we said // `immutable, max-age=31536000` then a docker pull to a newer // CaddyUI build wouldn't show up until users hard-refreshed. 86400 // is the sweet spot: repeat visits within the same day are instant, // upgrades take effect within a day, and Ctrl-Shift-R always works. // (The PWA service worker does a separate cache-first pass at the // JS layer for assets it precaches — this header is the safety net // for first-visit / SW-not-yet-installed cases.) staticHandler := http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))) r.Handle("/static/*", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { w.Header().Set("Cache-Control", "public, max-age=86400") staticHandler.ServeHTTP(w, req) })) } // PWA root files — must be served from / scope for the service worker to control the whole app. r.Get("/manifest.json", func(w http.ResponseWriter, r *http.Request) { f, err := s.Static.Open("manifest.json") if err != nil { http.NotFound(w, r) return } defer f.Close() w.Header().Set("Content-Type", "application/manifest+json") io.Copy(w, f) }) r.Get("/sw.js", func(w http.ResponseWriter, r *http.Request) { f, err := s.Static.Open("sw.js") if err != nil { http.NotFound(w, r) return } defer f.Close() w.Header().Set("Content-Type", "application/javascript") w.Header().Set("Service-Worker-Allowed", "/") io.Copy(w, f) }) r.Get("/setup", s.getSetup) r.Post("/setup", s.postSetup) r.Get("/login", s.getLogin) r.Post("/login", s.postLogin) r.Post("/logout", s.postLogout) r.Get("/auth/oidc/login", s.getOIDCLogin) // issue #106 (SSO) r.Get("/auth/oidc/callback", s.getOIDCCallback) // issue #106 (SSO) r.Get("/login/totp", s.getTOTPVerify) r.Post("/login/totp", s.postTOTPVerify) r.Get("/forgot-password", s.getForgotPassword) r.Post("/forgot-password", s.postForgotPassword) r.Get("/reset-password", s.getResetPassword) r.Post("/reset-password", s.postResetPassword) r.Get("/accept-invite", s.getAcceptInvite) r.Post("/accept-invite", s.postAcceptInvite) r.Group(func(r chi.Router) { r.Use(s.requireAuth) // v2.29.0: CSRF must sit *inside* requireAuth — it reads the API-token // scope that requireAuth puts on the context to decide whether a // request is bearer-authenticated (and so not CSRF-reachable). r.Use(s.requireCSRF) r.Get("/", s.dashboard) r.Get("/onboarding", s.getOnboarding) // Read routes — open to both admin and viewer roles. r.Get("/proxy-hosts", s.listProxyHosts) r.Get("/redirection-hosts", s.listRedirectionHosts) r.Get("/caddyfile-import", s.getCaddyfileImport) r.Get("/activity", s.listActivityLog) r.Get("/activity/export.csv", s.exportActivityCSV) r.Get("/certificates", s.listCertificates) r.Get("/certificates/{id}/inspect", s.getCertificateInspect) r.Get("/certificates/{id}/managed-status", s.getManagedCertificateStatus) // v2.39.0: live TLS probes of custom certificates. Read-only towards // Caddy, so viewers may trigger them like the Refresh button before. r.Post("/certificates/probe", s.probeCertificatesHandler) r.Post("/certificates/{id}/probe", s.probeCertificateHandler) // v2.42.0: Export now — writes files, so write access is required. r.With(s.requireWrite).Post("/certificates/{id}/export", s.exportCertificateHandler) r.Get("/raw-routes", s.listRawRoutes) r.Get("/docs", s.getDocs) r.Get("/api/docs", s.getAPIDocs) // Server picker — available to every authenticated role. The route // only flips the caddyui_server cookie (a per-user preference) so // viewers and non-admin users can switch between Caddy instances // the admin has registered without needing write access. Admin-only // CRUD on the server list still lives in the admin-gated group below. r.Post("/servers/{id}/select", s.selectServer) // Feature B: upstream health check API (authenticated, no requireWrite). r.Get("/api/upstream-health", s.apiUpstreamHealth) // v2.11.5: ⌘K command palette — returns every visible resource on the // active server in one flat list. Frontend caches per palette open. r.Get("/api/search", s.globalSearch) // v2.11.15: AI assistant — proxies prompts to the configured Ollama // instance. /api/ai/status reports whether AI is enabled so the // frontend can hide the floating button when not configured. r.Get("/api/ai/status", s.apiAIStatus) r.With(s.requireWrite).Post("/api/ai/chat", s.apiAIChat) // v2.57.1: spends the admin's LLM key — not for read-only accounts // v2.12.27: per-user color-theme persistence so the picker in // Settings follows the account across devices instead of being // trapped in per-browser localStorage. r.Post("/api/me/color-theme", s.postMyColorTheme) // Live upstream status — proxies Caddy's /reverse_proxy/upstreams response. r.Get("/api/caddy-upstreams", s.apiCaddyUpstreams) r.With(s.requireWrite).Post("/api/proxy-hosts/test-upstream", s.apiTestUpstream) // v2.57.1: writers only // v2.11.13: live Caddyfile/JSON preview — takes the in-progress // proxy-host edit form and returns the route JSON Caddy would see. r.With(s.requireWrite).Post("/api/proxy-hosts/preview", s.apiPreviewProxyHost) // v2.57.1: writers only // v2.9.228: validate a raw-route's Caddyfile/JSON via /load?validate_only=true // before saving the row. Lets the form catch syntax/schema errors at edit // time instead of waiting for the next sync to surface them. r.With(s.requireWrite).Post("/api/raw-routes/validate", s.apiValidateRawRoute) // v2.57.1: writers only // Feature F: notifier status API (authenticated). // Phase 7: system stats API (authenticated, read-only). r.Get("/api/system-stats", s.apiSystemStats) // v2.15.0: dashboard sparklines — 7-day daily totals for the three // stat cards (views, visitors, bandwidth). Scoped to active server. r.Get("/api/dashboard-sparklines", s.apiDashboardSparklines) // Caddy version from the admin API root endpoint. r.Get("/api/caddy-version", s.apiCaddyVersion) // Update-check: fetches latest tag from Docker Hub (cached 1h). r.Get("/api/version-check", s.apiVersionCheck) // Unified DNS-provider zones endpoint — /api/dns-zones?provider=. // Replaces the v2.2.x per-provider /api/cf-zones and /api/pb-domains // endpoints with a single handler that routes on ?provider=. r.Get("/api/dns-zones", s.apiDNSZones) // v2.4.8: warn when a proxy host would collide with a pre-existing // DNS record. Proxy-host form JS calls this after the user picks // provider + zone + first domain; response drives the "record // already exists — Cancel / Override?" dialog. r.Get("/api/dns-zones/check-record", s.apiDNSCheckRecord) // v2.5.2: post-save deployment status — drives the "deploying" // page's live checklist (DNS propagated, cert issued). Read-only, // so it lives outside the write-guarded group. v2.5.5 extends the // same checklist to advanced (raw) routes that have a host matcher. r.Get("/proxy-hosts/{id}/deploying", s.proxyHostDeploying) r.Get("/api/proxy-hosts/{id}/deploy-status", s.apiProxyHostDeployStatus) r.Get("/raw-routes/{id}/deploying", s.rawRouteDeploying) r.Get("/api/raw-routes/{id}/deploy-status", s.apiRawRouteDeployStatus) // v2.9.2: per-host health history page (read-only, authenticated). r.Get("/proxy-hosts/{id}/health", s.getProxyHostHealth) // v2.9.5: REST JSON API for proxy hosts and redirection hosts (read endpoints). r.Get("/api/v1/proxy-hosts", s.apiV1ListProxyHosts) r.Get("/api/v1/proxy-hosts/{id}", s.apiV1GetProxyHost) r.Get("/api/v1/redirection-hosts", s.apiV1ListRedirectionHosts) r.Get("/api/v1/redirection-hosts/{id}", s.apiV1GetRedirectionHost) r.Get("/api/v1/raw-routes", s.apiV1ListRawRoutes) r.Get("/api/v1/raw-routes/{id}", s.apiV1GetRawRoute) r.Get("/api/v1/certificates", s.apiV1ListCertificates) r.Get("/api/v1/certificates/{id}", s.apiV1GetCertificate) r.Get("/api/v1/servers", s.apiV1ListServers) // Write routes — admin-only in practice. Viewers get 403 via requireWrite. r.Group(func(r chi.Router) { r.Use(s.requireWrite) // v2.12.11: AI tool calling — model proposes a proxy host or // redirection via tool_calls; user confirms; this endpoint // actually creates it. GHSA-5h8j-xxm3-7ggr: this was previously // registered outside the requireWrite group above, so a // read-only viewer could reach it directly (bypassing the UI's // own confirmation step entirely) and create proxy hosts or // redirections despite being blocked from every other write // path. Moved here so the same role gate every other mutating // route already gets is no longer something this one handler // has to remember to check for itself. r.Post("/api/ai/exec-tool", s.apiAIExecTool) r.Get("/proxy-hosts/new", s.newProxyHost) r.Post("/proxy-hosts", s.createProxyHost) r.Get("/proxy-hosts/{id}/export.json", s.exportProxyHost) r.Get("/proxy-hosts/export-all.json", s.exportAllProxyHosts) // v2.12.49: Caddyfile export — inverse of /caddyfile-import paste flow. r.Get("/proxy-hosts/export-all.caddyfile", s.exportServerCaddyfile) // v2.12.50: per-host Caddyfile export. r.Get("/proxy-hosts/{id}/export.caddyfile", s.exportProxyHostCaddyfile) r.Post("/proxy-hosts/import", s.importProxyHost) r.Get("/proxy-hosts/{id}/edit", s.editProxyHost) r.Post("/proxy-hosts/{id}", s.updateProxyHost) r.Post("/proxy-hosts/{id}/delete", s.deleteProxyHost) r.Post("/proxy-hosts/{id}/clone", s.cloneProxyHost) r.Post("/proxy-hosts/{id}/toggle", s.toggleProxyHost) r.Post("/proxy-hosts/{id}/expectations/run", s.runProxyHostExpectationsHandler) // v2.38.0 r.Post("/proxy-hosts/{id}/maintenance", s.toggleMaintenanceMode) r.Post("/proxy-hosts/bulk-toggle", s.bulkToggleProxyHosts) r.Post("/proxy-hosts/bulk-maintenance", s.bulkMaintenanceProxyHosts) r.Post("/proxy-hosts/bulk-certificate", s.bulkCertificateProxyHosts) r.Post("/proxy-hosts/bulk-delete", s.bulkDeleteProxyHosts) // v2.11.11: drag-to-reorder rows — accepts ids[] in desired // display order, writes sort_order = (index * 10) for each. r.Post("/proxy-hosts/reorder", s.reorderProxyHosts) // v2.9.5: REST JSON API write routes (write-scoped, honours requireWrite). r.Post("/api/v1/proxy-hosts", s.apiV1CreateProxyHost) r.Put("/api/v1/proxy-hosts/{id}", s.apiV1UpdateProxyHost) r.Delete("/api/v1/proxy-hosts/{id}", s.apiV1DeleteProxyHost) r.Post("/api/v1/proxy-hosts/{id}/toggle", s.apiV1ToggleProxyHost) r.Post("/api/v1/proxy-hosts/{id}/maintenance", s.apiV1ToggleMaintenanceProxyHost) r.Post("/api/v1/redirection-hosts", s.apiV1CreateRedirectionHost) r.Put("/api/v1/redirection-hosts/{id}", s.apiV1UpdateRedirectionHost) r.Delete("/api/v1/redirection-hosts/{id}", s.apiV1DeleteRedirectionHost) r.Post("/api/v1/redirection-hosts/{id}/toggle", s.apiV1ToggleRedirectionHost) r.Post("/api/v1/raw-routes", s.apiV1CreateRawRoute) r.Put("/api/v1/raw-routes/{id}", s.apiV1UpdateRawRoute) r.Delete("/api/v1/raw-routes/{id}", s.apiV1DeleteRawRoute) r.Post("/api/v1/raw-routes/{id}/toggle", s.apiV1ToggleRawRoute) r.Post("/api/v1/certificates", s.apiV1CreateCertificate) r.Put("/api/v1/certificates/{id}", s.apiV1UpdateCertificate) r.Delete("/api/v1/certificates/{id}", s.apiV1DeleteCertificate) r.Get("/redirection-hosts/new", s.newRedirectionHost) r.Post("/redirection-hosts", s.createRedirectionHost) r.Get("/redirection-hosts/{id}/edit", s.editRedirectionHost) r.Post("/redirection-hosts/{id}", s.updateRedirectionHost) r.Post("/redirection-hosts/{id}/delete", s.deleteRedirectionHost) r.Post("/redirection-hosts/{id}/toggle", s.toggleRedirectionHost) r.Post("/redirection-hosts/{id}/clone", s.cloneRedirectionHost) // v2.11.6: bulk operations on /redirection-hosts (parallel of proxy-host bulk). r.Post("/redirection-hosts/bulk-toggle", s.bulkToggleRedirectionHosts) r.Post("/redirection-hosts/bulk-delete", s.bulkDeleteRedirectionHosts) // v2.11.11: drag-to-reorder rows. r.Post("/redirection-hosts/reorder", s.reorderRedirectionHosts) r.Post("/caddy/reload", s.reloadCaddy) r.Post("/caddyfile-import", s.postCaddyfileImport) // v2.7.2: certificate create + edit are writer-level, not admin-only. // Each cert now carries owner_id (NULL = global/admin, >0 = private // to a user). user-role uploads land with owner_id = cu.ID so they // don't collide with other tenants' TLS material. The per-handler // 403 in editCertificate / updateCertificate enforces ownership // defensively — route layer only filters view-role out. // // Delete stays admin-only (see /certificates/{id}/delete down in // the requireAdmin group) because its blast radius is NULL-ing // certificate_id on proxy_hosts/redirection_hosts/raw_routes across // owners. That check would be racy if we distributed it. r.Get("/certificates/new", s.newCertificate) r.Post("/certificates", s.createCertificate) r.Get("/certificates/{id}/edit", s.editCertificate) r.Post("/certificates/{id}", s.updateCertificate) r.Post("/certificates/{id}/delete", s.deleteCertificate) // v2.11.10: bulk delete on /certificates — same ownership / in-use // guards as the single-row deleteCertificate handler. r.Post("/certificates/bulk-delete", s.bulkDeleteCertificates) r.Get("/raw-routes/new", s.newRawRoute) r.Post("/raw-routes", s.createRawRoute) r.Get("/raw-routes/{id}/edit", s.editRawRoute) r.Post("/raw-routes/{id}", s.updateRawRoute) r.Post("/raw-routes/{id}/delete", s.deleteRawRoute) // v2.11.9: bulk operations on /raw-routes (parallel of /proxy-hosts). r.Post("/raw-routes/bulk-toggle", s.bulkToggleRawRoutes) r.Post("/raw-routes/bulk-delete", s.bulkDeleteRawRoutes) // v2.10.9: bulk re-run the classifier over Advanced routes — // useful for users who imported before v2.10.7 (auto-classify) // shipped, when every block landed in Advanced. r.Post("/raw-routes/reclassify", s.postReclassifyRawRoutes) // Phase 7: database backup download. }) // TOTP setup — available to all authenticated users. r.Get("/totp/setup", s.getTOTPSetup) r.Post("/totp/setup", s.postTOTPSetup) r.Post("/totp/regenerate-backup-codes", s.postRegenerateBackupCodes) // v2.7.0: visitor analytics — read-only for every signed-in // user. userAllowedHosts scopes non-admins to their owned // sites so the page doesn't leak traffic for hosts they // don't have Edit permission on. r.Get("/analytics", s.getAnalytics) r.Get("/analytics/export.csv", s.exportAnalyticsCSV) r.Get("/analytics/requests", s.getAnalyticsRequests) // issue #145: cross-dimension request explorer r.Get("/analytics/{host}", s.getAnalyticsHost) r.Get("/analytics/{host}/visitor", s.getAnalyticsVisitor) // issue #94 r.Get("/analytics/{host}/path", s.getAnalyticsPath) // issue #94 r.Get("/analytics/{host}/status", s.getAnalyticsStatus) // issue #94 follow-up r.Post("/analytics/{host}/block", s.postAnalyticsBlock) // issue #100 r.Get("/live-traffic", s.getLiveTraffic) r.Get("/api/live-traffic/stream", s.liveTrafficStream) // Global search — read-only, available to every authenticated role. r.Get("/search", s.getSearch) // Session manager — every user can view and revoke their own sessions; // admins see all sessions across all users. r.Get("/sessions", s.getSessions) r.Post("/sessions/{token}/revoke", s.revokeSession) // API token manager — every authenticated user can create/revoke their // own tokens; admins see all users' tokens. r.Get("/api-tokens", s.listAPITokens) r.Post("/api-tokens", s.createAPIToken) r.Post("/api-tokens/{id}/revoke", s.revokeAPIToken) // Profile page — every authenticated user can update their own name/password. r.Get("/profile", s.getProfile) r.Post("/profile", s.postProfile) // User management and settings — admin-only (both read and write). r.Group(func(r chi.Router) { r.Use(s.requireAdmin) // Security (v2.57.1): everything that exposes or replaces the live // Caddy config, the database, or the stored credentials is // admin-only. These used to sit in the read-for-any-role and // requireWrite groups, so a viewer could read certificate private // keys and DNS API tokens from /caddy-config and a customer-level // user could download the whole database from /backup. r.Get("/caddy-config", s.getCaddyConfig) // v2.58.0 (issue #122): Layer4 (TCP/UDP) proxies. Admin-only — a // layer4 listener opens a port on the host and forwards to anything // the host can reach. r.Get("/middleware-profiles", s.listMiddlewareProfiles) // v2.60.0 (issue #124) r.Get("/middleware-profiles/new", s.newMiddlewareProfile) r.Post("/middleware-profiles", s.createMiddlewareProfile) r.Get("/middleware-profiles/{id}/edit", s.editMiddlewareProfile) r.Post("/middleware-profiles/{id}", s.updateMiddlewareProfile) r.Post("/middleware-profiles/{id}/delete", s.deleteMiddlewareProfile) r.Get("/layer4-proxies", s.listLayer4Proxies) r.Get("/layer4-proxies/new", s.newLayer4Proxy) r.Post("/layer4-proxies", s.createLayer4Proxy) r.Get("/layer4-proxies/{id}/edit", s.editLayer4Proxy) r.Post("/layer4-proxies/{id}", s.updateLayer4Proxy) r.Post("/layer4-proxies/{id}/toggle", s.toggleLayer4Proxy) r.Post("/layer4-proxies/{id}/delete", s.deleteLayer4Proxy) r.Get("/import", s.getImport) r.Post("/import", s.postImport) r.Get("/snapshots", s.listSnapshots) r.Get("/snapshots/{id}/download", s.downloadSnapshot) r.Get("/snapshots/{id}/diff", s.getSnapshotDiff) r.Post("/snapshots", s.createManualSnapshot) r.Post("/snapshots/upload", s.uploadSnapshot) r.Post("/snapshots/auto", s.setAutoSnapshots) r.Post("/snapshots/{id}/restore", s.restoreSnapshot) r.Post("/snapshots/{id}/delete", s.deleteSnapshot) r.Get("/backup", s.getBackup) // The Porkbun import uses the admin's own Porkbun API keys and // returns SSL bundles including the private key. r.Get("/certificates/import/porkbun", s.importPorkbunCertificatePage) r.Post("/certificates/import/porkbun", s.importPorkbunCertificate) // Webhook / ntfy destinations are bearer secrets. r.Get("/api/notifier-status", s.apiNotifierStatus) r.Get("/users", s.listUsers) r.Get("/users/new", s.newUser) r.Post("/users", s.createUser) r.Post("/users/invite", s.postInviteUser) r.Get("/users/{id}/edit", s.editUser) r.Post("/users/{id}", s.updateUser) r.Post("/users/{id}/delete", s.deleteUser) // v2.7.4: group CRUD. Admin puts user-role accounts into a shared // group; members then see each other's rows (proxy/redirect/raw/ // certs) read-only in List* queries. Edit/delete still goes // through the per-row ownership gate inside each handler, so // "view teammates' work" doesn't imply "mutate teammates' work". r.Get("/groups", s.listGroups) r.Get("/groups/new", s.newGroup) r.Post("/groups", s.createGroup) r.Get("/groups/{id}/edit", s.editGroup) r.Post("/groups/{id}", s.updateGroup) r.Post("/groups/{id}/delete", s.deleteGroup) r.Get("/servers", s.listServersPage) r.Get("/servers/new", s.newServerPage) r.Post("/servers", s.createServer) r.Get("/servers/{id}/edit", s.editServerPage) r.Get("/servers/{id}/config", s.viewServerConfig) r.Post("/servers/{id}", s.updateServer) r.Post("/servers/{id}/sync-from-current", s.syncServerFromCurrent) r.Post("/servers/{id}/sync-reapply", s.reapplySyncHandler) // v2.38.0 r.Post("/servers/{id}/sync-hold/clear", s.clearSyncHoldHandler) // v2.38.0 r.Post("/servers/{id}/sync-error/clear", s.clearSyncErrorHandler) // v2.42.1 r.Post("/servers/{id}/delete", s.deleteServer) r.Post("/servers/{id}/pending-deletions/retry", s.retryFleetPendingDeletions) // v2.59.0 (issue #120) r.Post("/servers/{id}/pending-deletions/discard", s.discardFleetPendingDeletion) // v2.59.0 (issue #120) r.Get("/server-logs", s.getServerLogs) r.Get("/api/server-logs/status", s.serverLogStatus) r.Get("/api/server-logs/stream", s.serverLogStream) r.Post("/api/server-logs/enable", s.enableServerLogs) r.Post("/api/server-logs/disable", s.disableServerLogs) r.Post("/api/server-logs/clear", s.clearServerLogs) // v2.35.5 (issue #60) r.Post("/dashboard/recommendations/unused-certificates/dismiss", s.dismissUnusedCertificateRecommendation) r.Post("/dashboard/recommendations/two-factor-policy/dismiss", s.dismissTOTPPolicyRecommendation) // v2.64.2 (issue #138) // v2.7.2: create/edit moved up to the requireWrite group so // user-role accounts can manage their own certs. The remaining // admin-only cert concern — whole-cert delete with its // cross-owner blast radius — is enforced by the inline role check // inside deleteCertificate, not by route gating, so a user-role // account can still delete a cert they own (see the ownership // branch in that handler). // Feature F: settings page (admin-only). r.Get("/settings", s.getSettings) r.Get("/settings/{section}", s.getSettings) // v2.44.0 r.Post("/settings", s.postSettings) r.Post("/settings/analytics/prune", s.pruneAnalyticsHandler) // v2.43.0 r.Post("/settings/analytics/vacuum", s.vacuumAnalyticsHandler) // v2.43.0 r.Post("/settings/backup/run", s.postBackupRun) // v2.52.0 (issue #104) r.Post("/settings/test-webhook", s.postTestWebhook) r.Post("/settings/test-email", s.postTestEmail) r.Post("/settings/test-crowdsec", s.postTestCrowdSec) r.Post("/settings/dns-provider/{id}/clear", s.postClearDNSProvider) }) }) return r } // securityHeaders sets baseline response headers on every CaddyUI response // regardless of whether the request reached us through a reverse proxy or // directly. Defense-in-depth: even if you bypass Caddy and hit CaddyUI on // its bound port (or behind a different proxy that doesn't add security // headers), these still ship. // // Headers set: // - X-Frame-Options: SAMEORIGIN — page can only be iframed by itself, // mitigates clickjacking against the management UI. // - X-Content-Type-Options: nosniff — browser respects the Content-Type // header instead of MIME-sniffing, mitigates MIME confusion. // - Referrer-Policy: strict-origin-when-cross-origin — hides path/query // from cross-origin navigations. // // HSTS is intentionally NOT set here — CaddyUI may be served on plain HTTP // when accessed via Tailscale / Wireguard / localhost during dev/test, and // HSTS would make those flows uncomfortable. The fronting Caddy adds HSTS // for public HTTPS access via the Security Headers checkbox on the // proxy host fronting CaddyUI. v2.10.3. func (s *Server) securityHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // v2.12.15: honour Settings → "Globally stripped response headers" // for CaddyUI's own responses too (not just proxied upstream replies). // Without this, a user setting `X-Frame-Options` in the global-strip // field still saw SAMEORIGIN on CaddyUI-served pages (login redirect, // admin UI, etc.) because this middleware unconditionally set it. stripped := map[string]bool{} if globalStrip, _ := models.GetSetting(s.DB, settingGlobalStripResponseHeaders); strings.TrimSpace(globalStrip) != "" { for _, hh := range strings.Split(globalStrip, ",") { hh = strings.ToLower(strings.TrimSpace(hh)) if hh != "" { stripped[hh] = true } } } h := w.Header() // Set-only-if-not-already-present so a more restrictive value from // a fronting proxy (e.g. X-Frame-Options: DENY) still wins. Skip // entirely if the header is in the global-strip list — user has // explicitly told us to drop it everywhere. if !stripped["x-frame-options"] && h.Get("X-Frame-Options") == "" { h.Set("X-Frame-Options", "SAMEORIGIN") } if !stripped["x-content-type-options"] && h.Get("X-Content-Type-Options") == "" { h.Set("X-Content-Type-Options", "nosniff") } if !stripped["referrer-policy"] && h.Get("Referrer-Policy") == "" { h.Set("Referrer-Policy", "strict-origin-when-cross-origin") } // v2.30.0: Content-Security-Policy and Permissions-Policy, following // the same strip-aware, set-only-if-absent rules as the three above. // See security_headers.go for the allowlist and why each origin is on // it; CADDYUI_CSP=off disables the policy without a redeploy. if csp := cspPolicy(); csp != "" && !stripped["content-security-policy"] && h.Get("Content-Security-Policy") == "" { h.Set("Content-Security-Policy", csp) } if !stripped["permissions-policy"] && h.Get("Permissions-Policy") == "" { h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), interest-cohort=()") } // v2.12.18: previously wrapped w in a stripHeaderWriter so headers // set by downstream handlers (templates / 404 / Caddy-static) could // also be stripped. Reverted — the wrapper didn't implement // http.Flusher / http.Hijacker so streaming responses degraded into // browser-side "save as" downloads (no detected Content-Type // flushing). The conditional skips above on the three headers this // middleware unconditionally sets are enough; downstream handlers // don't add X-Frame-Options / etc. on their own. next.ServeHTTP(w, r) }) } // adminIPGate returns a middleware that enforces the admin_allowlist setting. // If the setting is empty, all IPs are allowed. Non-matching IPs get 403. // // v2.65.0 (issue #140): the allowlist is matched against the address from // resolvedClientIP, not the raw connection. It used to compare // r.RemoteAddr, which behind any reverse proxy is the proxy — so an // allowlist naming the real visitor locked that visitor out, and the usual // workaround of naming the proxy instead admitted *everyone* who came // through it while still reading as "restricted" in the UI. func (s *Server) adminIPGate(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { raw := s.adminAllowlist() if raw == "" { next.ServeHTTP(w, r) return } // Parse the allowlist. var allowedNets []*net.IPNet var allowedIPs []net.IP for _, line := range strings.FieldsFunc(raw, func(r rune) bool { return r == ',' || r == '\n' }) { cidr := strings.TrimSpace(line) if cidr == "" { continue } if strings.Contains(cidr, "/") { _, ipnet, err := net.ParseCIDR(cidr) if err == nil { allowedNets = append(allowedNets, ipnet) } } else { if ip := net.ParseIP(cidr); ip != nil { allowedIPs = append(allowedIPs, ip) } } } if len(allowedNets) == 0 && len(allowedIPs) == 0 { next.ServeHTTP(w, r) return } // The visitor's address, taken from a forwarded header only when the // connection came from a trusted proxy. clientIP, _ := s.resolvedClientIP(r) ip := net.ParseIP(clientIP) if ip == nil { http.Error(w, "Forbidden", http.StatusForbidden) return } // Check against allowlist. for _, allowed := range allowedIPs { if allowed.Equal(ip) { next.ServeHTTP(w, r) return } } for _, ipnet := range allowedNets { if ipnet.Contains(ip) { next.ServeHTTP(w, r) return } } http.Error(w, "403 Forbidden — your IP is not on the admin allowlist", http.StatusForbidden) }) } func (s *Server) requireAuth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { n, err := models.CountUsers(s.DB) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } if n == 0 { http.Redirect(w, r, "/setup", http.StatusSeeOther) return } cookie, err := r.Cookie(auth.SessionCookie) if err != nil { // No session cookie — try bearer token before redirecting. if u, tokenScopes := s.bearerTokenUser(r); u != nil { // Enforce the token's scope on EVERY route (v2.57.1). if msg := apiTokenScopeDenial(tokenScopes, r); msg != "" { http.Error(w, msg, http.StatusForbidden) return } ctx := context.WithValue(r.Context(), auth.ContextUserKey, u) ctx = context.WithValue(ctx, apiTokenScopeContextKey{}, tokenScopes) next.ServeHTTP(w, r.WithContext(ctx)) return } http.Redirect(w, r, "/login", http.StatusSeeOther) return } u, err := auth.UserFromSession(s.DB, cookie.Value) if err != nil || u == nil { auth.ClearSessionCookie(w, r) // Session invalid — try bearer token before redirecting. if u, tokenScopes := s.bearerTokenUser(r); u != nil { // Enforce the token's scope on EVERY route (v2.57.1). if msg := apiTokenScopeDenial(tokenScopes, r); msg != "" { http.Error(w, msg, http.StatusForbidden) return } ctx := context.WithValue(r.Context(), auth.ContextUserKey, u) ctx = context.WithValue(ctx, apiTokenScopeContextKey{}, tokenScopes) next.ServeHTTP(w, r.WithContext(ctx)) return } http.Redirect(w, r, "/login", http.StatusSeeOther) return } // Enforce the two-factor policy: a user without TOTP goes to the setup // page until they enrol. // // v2.64.1 (issue #138): this used to be two consecutive blocks, one per // setting, which differed only in their query parameter and in what // they let through — so whether an API call survived the redirect // depended on which of two identically-described toggles the admin // happened to use. One check now, with the wider exclusion list, and // either setting still turns it on (see settingRequire2FA). if !u.TOTPEnabled && s.twoFactorRequired() { path := r.URL.Path // /totp/ covers the setup page and its POST target, so enrolment // can actually be completed. /api/ is excluded because a JSON // caller has no use for a redirect to an HTML page. if !strings.HasPrefix(path, "/totp/") && !strings.HasPrefix(path, "/static/") && !strings.HasPrefix(path, "/api/") && path != "/logout" { http.Redirect(w, r, "/totp/setup?required=1", http.StatusSeeOther) return } } ctx := context.WithValue(r.Context(), auth.ContextUserKey, u) next.ServeHTTP(w, r.WithContext(ctx)) }) } // bearerTokenUser checks for an Authorization: Bearer header and resolves // it to a User via the api_tokens table. Returns (nil, "") if no header is // present or the token is invalid/expired. Callers that already resolved a // session user should skip this (session wins over bearer). func (s *Server) bearerTokenUser(r *http.Request) (*models.User, string) { authHeader := r.Header.Get("Authorization") if !strings.HasPrefix(authHeader, "Bearer ") { return nil, "" } raw := strings.TrimPrefix(authHeader, "Bearer ") if raw == "" { return nil, "" } h := sha256.Sum256([]byte(raw)) hash := fmt.Sprintf("%x", h) tok, err := models.GetAPITokenByHash(s.DB, hash) if err != nil || tok == nil { return nil, "" } if tok.Expired() { return nil, "" } u, err := models.GetUserByID(s.DB, tok.UserID) if err != nil { return nil, "" } models.TouchAPIToken(s.DB, tok.ID) return u, tok.Scopes } func (s *Server) currentUser(r *http.Request) *models.User { u, _ := r.Context().Value(auth.ContextUserKey).(*models.User) return u } func currentAPITokenScope(r *http.Request) string { scope, _ := r.Context().Value(apiTokenScopeContextKey{}).(string) return scope } func proxyWriteTokenCanWritePath(path string) bool { return path == "/api/v1/proxy-hosts" || strings.HasPrefix(path, "/api/v1/proxy-hosts/") } // sessionTTL returns the configured session duration, defaulting to 7 days. func (s *Server) sessionTTL() time.Duration { v, _ := models.GetSetting(s.DB, settingSessionDays) if v == "" { return 7 * 24 * time.Hour } days, err := strconv.Atoi(strings.TrimSpace(v)) if err != nil || days <= 0 || days > 365 { return 7 * 24 * time.Hour } return time.Duration(days) * 24 * time.Hour } func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, data map[string]any) { if data == nil { data = map[string]any{} } // Always inject app version. data["AppVersion"] = s.Version // v2.68.3 (issue #150): a save whose copy did not reach every fleet target // says so on the page it lands on (withDeployProblems); the pages that // receive those redirects render it with {{template "deployError" .}}. if _, ok := data["DeployError"]; !ok && r != nil { data["DeployError"] = r.URL.Query().Get("deploy_error") } // v2.62.0 (issue #128): the page's language and the strings the browser-side // scripts may need (window.CADDYUI_I18N in layout.html). if _, ok := data["Lang"]; !ok { u, _ := data["User"].(*models.User) if u == nil && r != nil { u = s.currentUser(r) } data["Lang"] = s.requestLocale(r, u) } if _, ok := data["I18NJS"]; !ok { data["I18NJS"] = translations().Subset(data["Lang"].(string), "js.") } // v2.29.0: CSRF token for every rendered page. CSRFField is the ready-made // hidden input each POST form embeds; CSRFToken is the raw value, surfaced // as a tag so the fetch wrapper in app.js can pick it up for JSON // calls. Both are empty for anonymous pages, which have no session to // protect. Injected here so no page can forget it. if _, ok := data["CSRFField"]; !ok { data["CSRFField"] = s.csrfField(r) } if _, ok := data["CSRFToken"]; !ok { data["CSRFToken"] = s.csrfTokenForRequest(r) } // Inject site title for every page so layout.html can use it. if _, ok := data["SiteTitle"]; !ok { data["SiteTitle"] = mustGetSetting(s.DB, settingSiteTitle) } // Inject custom favicon URL for every page so layout.html can override the default icon. if _, ok := data["FaviconURL"]; !ok { data["FaviconURL"] = mustGetSetting(s.DB, settingFaviconURL) } // Auto-inject server picker data (best-effort; non-fatal if DB unavailable). if _, ok := data["Servers"]; !ok { if servers, err := models.ListCaddyServers(s.DB); err == nil { data["Servers"] = servers } } // v2.38.0: servers held after a failed post-apply check — layout banner. if _, ok := data["SyncHolds"]; !ok { if servers, ok := data["Servers"].([]models.CaddyServer); ok && len(servers) > 0 { data["SyncHolds"] = s.activeSyncHolds(servers) } } // v2.42.1: servers whose last sync failed — layout banner. if _, ok := data["SyncErrors"]; !ok { if servers, ok := data["Servers"].([]models.CaddyServer); ok && len(servers) > 0 { data["SyncErrors"] = s.activeSyncErrors(servers) } } if _, ok := data["CurrentServer"]; !ok { if r != nil { sid := s.currentServerID(r) if srv, err := models.GetCaddyServer(s.DB, sid); err == nil { data["CurrentServer"] = srv } } } tpl, ok := s.Templates[name] if !ok { http.Error(w, "template not found: "+name, http.StatusInternalServerError) return } // v2.29.0: render into a buffer so the CSRF hidden input can be stamped // into every POST form before anything reaches the client. Buffering also // means a mid-template error produces a clean 500 instead of a truncated // page followed by an error string, which is what happened when this wrote // to the ResponseWriter directly. var buf bytes.Buffer if err := tpl.ExecuteTemplate(&buf, "layout", data); err != nil { log.Printf("template %s: %v", name, err) http.Error(w, err.Error(), http.StatusInternalServerError) return } page := buf.Bytes() if field, _ := data["CSRFField"].(template.HTML); field != "" { page = csrfInjectForms(page, []byte(field)) } w.Header().Set("Content-Type", "text/html; charset=utf-8") if _, err := w.Write(page); err != nil { log.Printf("template %s: write: %v", name, err) } } // --- Setup (first-run) --- func (s *Server) getSetup(w http.ResponseWriter, r *http.Request) { n, err := models.CountUsers(s.DB) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } if n > 0 { http.Redirect(w, r, "/login", http.StatusSeeOther) return } s.render(w, r, "setup.html", nil) } func (s *Server) postSetup(w http.ResponseWriter, r *http.Request) { // v2.57.1: the user-count check and the insert must be one step. Two // simultaneous requests could both see zero users and both create an admin. s.setupMu.Lock() defer s.setupMu.Unlock() n, err := models.CountUsers(s.DB) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } if n > 0 { http.Redirect(w, r, "/login", http.StatusSeeOther) return } _ = r.ParseForm() email := strings.TrimSpace(r.FormValue("email")) name := strings.TrimSpace(r.FormValue("name")) pw := r.FormValue("password") pw2 := r.FormValue("password_confirm") if email == "" || pw == "" { s.render(w, r, "setup.html", map[string]any{"Error": "Email and password required"}) return } if pw != pw2 { s.render(w, r, "setup.html", map[string]any{"Error": "Passwords do not match"}) return } // The 8-character minimum was only an HTML attribute; enforce it here too. if len(pw) < 8 { s.render(w, r, "setup.html", map[string]any{"Error": "Password must be at least 8 characters"}) return } hash, err := auth.HashPassword(pw) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } id, err := models.CreateUser(s.DB, email, hash, name, models.RoleAdmin) if err != nil { s.render(w, r, "setup.html", map[string]any{"Error": err.Error()}) return } tok, exp, err := auth.CreateSessionWithTTL(s.DB, id, s.sessionTTL()) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } auth.SetSessionCookie(w, r, tok, exp) http.Redirect(w, r, "/onboarding", http.StatusSeeOther) } // getOnboarding renders the guided first-run journey. It deliberately uses // live product state instead of a dismissible "completed" flag: returning // admins can open the page at any time and immediately see which operational // foundations are configured, without onboarding state drifting away from // the real Caddy/server/DNS/security configuration. func (s *Server) getOnboarding(w http.ResponseWriter, r *http.Request) { cu := s.currentUser(r) if cu == nil { http.Redirect(w, r, "/login", http.StatusSeeOther) return } sid := s.currentServerID(r) currentServer, _ := models.GetCaddyServer(s.DB, sid) serverConnected := currentServer != nil && currentServer.Status == models.CaddyServerStatusOnline dnsConfigured := len(s.loadDNSProfiles()) > 0 var proxyCount int _ = s.DB.QueryRow(`SELECT COUNT(*) FROM proxy_hosts WHERE server_id = ?`, sid).Scan(&proxyCount) completed := requiredReadinessSteps(serverConnected, proxyCount > 0) s.render(w, r, "onboarding.html", map[string]any{ "User": cu, "CurrentServer": currentServer, "ServerConnected": serverConnected, "DNSConfigured": dnsConfigured, "TOTPEnabled": cu.TOTPEnabled, "ProxyCount": proxyCount, "CompletedSteps": completed, "Section": "onboarding", }) } // --- Login --- const ( settingTurnstileSiteKey = "turnstile_site_key" settingTurnstileSecretKey = "turnstile_secret_key" // Shared A-record target — the public IP every DNS provider writes as // its record content. The key name starts with "cf_" for historical // reasons (Cloudflare was the first provider); it's the single source // of truth now regardless of which provider is active. settingServerIP = "cf_server_ip" // Cloudflare-specific. settingCFAPIToken = "cf_api_token" settingCFProxied = "cf_proxied" // v2.49.0 (issue #98): resolver used for external verification lookups on // the deploy/readiness checks. Empty = Cloudflare DoH (default). May be a // DoH URL (https://…/dns-query) or a comma-separated list of plain DNS // servers (e.g. "192.168.1.10:53") for filtered/split-horizon networks. settingDNSVerifyResolver = "dns_verify_resolver" // issue #116: DNS servers Caddy's own ACME DNS-01 solver queries when // checking TXT-record propagation during certificate issuance/renewal — // distinct from settingDNSVerifyResolver, which only affects CaddyUI's // deploy/readiness checks. Empty = Caddy's default (the container/system // resolver), which fails for split-horizon DNS where the internal // resolver can't see the public zone used for ACME validation. A // comma-separated list of plain DNS servers, e.g. "1.1.1.1:53". settingACMEDNSResolvers = "acme_dns_resolvers" // Legacy alias kept so pre-v2.3.0 code paths referencing // settingCFServerIP continue to compile. Points at the shared key. settingCFServerIP = settingServerIP // Porkbun. settingPBAPIKey = "pb_api_key" settingPBSecretKey = "pb_secret_key" // Namecheap (v2.3.0). settingNCAPIUser = "nc_api_user" settingNCAPIKey = "nc_api_key" settingNCClientIP = "nc_client_ip" // GoDaddy (v2.3.0). settingGDAPIKey = "gd_api_key" settingGDAPISecret = "gd_api_secret" // DigitalOcean (v2.3.0). settingDOAPIToken = "do_api_token" // Hetzner DNS (v2.3.0). settingHetznerAPIToken = "hetzner_api_token" // v2.45.0: Gandi LiveDNS personal access token (discussion #72). settingGandiAPIToken = "gandi_api_token" // Amazon Route 53 (v2.23.0). Region defaults to us-east-1 in both the // CaddyUI API adapter and the caddy-dns/route53 module. Session token is // optional and supports temporary STS credentials. settingRoute53AccessKeyID = "route53_access_key_id" settingRoute53SecretAccessKey = "route53_secret_access_key" settingRoute53SessionToken = "route53_session_token" settingRoute53Region = "route53_region" // Multiple DNS credential profiles (v2.16.0). JSON array of // dnsCredentialProfile. Empty/missing means legacy per-provider // settings remain the only credential source. settingDNSProfilesJSON = "dns_profiles_json" ) // dnsProviderCredKeys lists every settings-table key that belongs to a DNS // provider's credential set. Used by postSettings to walk the new unified // form section without hardcoding a branch per provider. // // Key order mirrors dns.Descriptors() so the Settings page renders cards // in the same order credentials are saved. var dnsProviderCredKeys = map[string][]string{ dns.Cloudflare: {settingCFAPIToken}, dns.Porkbun: {settingPBAPIKey, settingPBSecretKey}, dns.Namecheap: {settingNCAPIUser, settingNCAPIKey, settingNCClientIP}, dns.GoDaddy: {settingGDAPIKey, settingGDAPISecret}, dns.DigitalOcean: {settingDOAPIToken}, dns.Hetzner: {settingHetznerAPIToken}, dns.Route53: {settingRoute53AccessKeyID, settingRoute53SecretAccessKey, settingRoute53SessionToken, settingRoute53Region}, dns.Gandi: {settingGandiAPIToken}, // v2.45.0 } type dnsCredentialProfile struct { ID string `json:"id"` Name string `json:"name"` ProviderID string `json:"provider_id"` Credentials map[string]string `json:"credentials"` ZoneAllowlist []string `json:"zone_allowlist,omitempty"` } type dnsProfileView struct { ID string Name string ProviderID string ProviderName string TokenSet bool Proxied bool ZoneAllowlistRaw string } // zoneAllowlistKey returns the settings-table key where the per-provider // zone allow-list is stored. Format on disk: comma-separated lowercase base // domains (e.g. "example.com, other.com"). An empty value means "no // restriction" — every zone the credentials can see is usable, which is // the original v2.4.5-and-earlier behaviour. // // v2.4.7: introduced so users whose API keys have broad account access // (especially GoDaddy, where a single key can touch every domain on the // account) can pin CaddyUI to one or a few zones and guarantee it won't // ever touch the rest. func zoneAllowlistKey(providerID string) string { return strings.ToLower(strings.TrimSpace(providerID)) + "_zone_allowlist" } // parseZoneAllowlist normalises a raw textarea/CSV value into a slice of // lowercase, trimmed base-domain names with duplicates removed. Accepts // commas, whitespace, and newlines as separators so the textarea can be // line-per-domain or CSV with no difference in behaviour. func parseZoneAllowlist(raw string) []string { parts := strings.FieldsFunc(raw, func(r rune) bool { return r == ',' || r == '\n' || r == '\r' || r == ' ' || r == '\t' || r == ';' }) seen := map[string]struct{}{} out := make([]string, 0, len(parts)) for _, p := range parts { p = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(p)), ".") if p == "" { continue } if _, dup := seen[p]; dup { continue } seen[p] = struct{}{} out = append(out, p) } return out } // zoneAllowlist returns the configured allow-list for providerID. An empty // slice means "no restriction" (explicitly or implicitly unset). func (s *Server) zoneAllowlist(providerID string) []string { raw, _ := models.GetSetting(s.DB, zoneAllowlistKey(providerID)) return parseZoneAllowlist(raw) } // zoneAllowed is the single decision point for "is CaddyUI permitted to // touch this zone on this provider". Every CreateRecord / DeleteRecord // call path should guard on it — filtering the dropdown alone isn't // enough, because the dns_zone_name column on a proxy_hosts row could have // been written before the allow-list was tightened (or via direct DB // editing / API). // // Matching is case-insensitive and trailing-dot tolerant. An empty // allow-list allows everything. func (s *Server) zoneAllowed(providerID, zoneName string) bool { allow := s.zoneAllowlist(providerID) if len(allow) == 0 { return true } zoneName = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(zoneName)), ".") if zoneName == "" { // Defensive: an unset zone name can't be verified against the // allow-list, so refuse. A properly configured proxy host always // has dns_zone_name populated when dns_provider is non-empty. return false } for _, z := range allow { if z == zoneName { return true } } return false } // validateZoneMatchesHostname returns "" if the (provider, zoneID, zoneName, // domains) combination is consistent — either no DNS is configured (provider // or zoneID empty, in which case the row is opting out of managed DNS), or // the first hostname in `domains` lives inside `zoneName`. Returns a // user-facing error message ready to surface on the form when the pairing is // inconsistent. // // Why "first hostname" and not all of them: a single proxy host row can serve // several hostnames (the comma-separated Domains field), but managed DNS only // provisions records for the *primary* hostname today. The other entries are // SAN aliases on the same TLS cert. Validating just the primary keeps the // check aligned with what dnsCreateRecord actually does. // // v2.7.8. func validateZoneMatchesHostname(provider, zoneID, zoneName string, domains []string) string { if strings.TrimSpace(provider) == "" || strings.TrimSpace(zoneID) == "" { return "" // no managed DNS configured — nothing to validate } if len(domains) == 0 { return "" // can't validate without a hostname; other validators catch empty-domain saves } first := strings.TrimSpace(domains[0]) if first == "" { return "" } if domainInZone(first, zoneName) { return "" } return fmt.Sprintf("Hostname %q doesn't live in DNS zone %q. Pick a zone whose apex matches the hostname (e.g. zone %q for hostname %q), or change the DNS provider to (none) if you don't want CaddyUI to manage the A record.", first, zoneName, guessApex(first), first) } // guessApex returns a "good enough" suggested zone name for an FQDN — the // rightmost two labels (e.g. "richardapplegate.io" for // "api.richardapplegate.io"). Used only inside the v2.7.8 mismatch error // message; doesn't influence routing decisions. A real public-suffix-aware // implementation would handle .co.uk etc., but the suggestion is just a hint // for the user reading the error — they pick the actual zone from the // dropdown. func guessApex(fqdn string) string { parts := strings.Split(strings.TrimSuffix(strings.ToLower(strings.TrimSpace(fqdn)), "."), ".") if len(parts) < 2 { return fqdn } return strings.Join(parts[len(parts)-2:], ".") } // domainInZone reports whether fqdn belongs to the DNS zone named zoneName. // True iff fqdn is the apex (fqdn == zoneName) or a subdomain of zoneName // (fqdn ends in "." + zoneName). Comparison is case-insensitive and strips a // trailing dot from either side, matching how Caddy and every DNS provider we // integrate with normalises FQDNs. // // Empty inputs return false — callers must decide whether "no zone configured" // is an error (it isn't for the create-record path; it is for the form // validators added in v2.7.8 that reject saving a non-matching pairing). // // v2.7.8: introduced so the proxy-host and raw-route save handlers can reject // "you typed richardapplegate.io but picked the applegatecloud.com zone" // before the row hits the DB. Without this guard the save would succeed, the // front-end's amber mismatch warning would be the only feedback, and the // subsequent dnsCreateRecord call would either fail at the provider API or // silently put the A record in the wrong zone. func domainInZone(fqdn, zoneName string) bool { f := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(fqdn)), ".") z := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(zoneName)), ".") if f == "" || z == "" { return false } return f == z || strings.HasSuffix(f, "."+z) } // dnsCreds returns the current credential map for a provider, reading from // the settings table. Pass it straight to dns.Build. func (s *Server) dnsCreds(providerID string) map[string]string { creds := map[string]string{} for _, k := range dnsProviderCredKeys[providerID] { v, _ := models.GetSetting(s.DB, k) creds[k] = v } // Cloudflare also needs the proxied flag — it lives alongside the // token in the settings table but isn't a credential field proper. if providerID == dns.Cloudflare { creds["cf_proxied"], _ = models.GetSetting(s.DB, settingCFProxied) } return creds } func randomDNSProfileID() string { var b [8]byte if _, err := rand.Read(b[:]); err != nil { return fmt.Sprintf("dnsprof_%d", time.Now().UnixNano()) } return "dnsprof_" + hex.EncodeToString(b[:]) } func normalizeDNSProfile(p dnsCredentialProfile) dnsCredentialProfile { p.ID = strings.TrimSpace(p.ID) p.Name = strings.TrimSpace(p.Name) p.ProviderID = strings.ToLower(strings.TrimSpace(p.ProviderID)) if p.Credentials == nil { p.Credentials = map[string]string{} } p.ZoneAllowlist = parseZoneAllowlist(strings.Join(p.ZoneAllowlist, ",")) return p } func (s *Server) loadDNSProfiles() []dnsCredentialProfile { raw, _ := models.GetSetting(s.DB, settingDNSProfilesJSON) if strings.TrimSpace(raw) == "" { return nil } var stored []dnsCredentialProfile if err := json.Unmarshal([]byte(raw), &stored); err != nil { log.Printf("settings: decode DNS profiles: %v", err) return nil } out := make([]dnsCredentialProfile, 0, len(stored)) seen := map[string]struct{}{} for _, p := range stored { p = normalizeDNSProfile(p) if p.ID == "" || p.Name == "" || p.ProviderID == "" { continue } if _, ok := dns.Lookup(p.ProviderID); !ok { continue } if _, dup := seen[p.ID]; dup { continue } seen[p.ID] = struct{}{} out = append(out, p) } return out } func (s *Server) saveDNSProfiles(profiles []dnsCredentialProfile) error { cleaned := make([]dnsCredentialProfile, 0, len(profiles)) for _, p := range profiles { p = normalizeDNSProfile(p) if p.ID == "" || p.Name == "" || p.ProviderID == "" { continue } if _, ok := dns.Lookup(p.ProviderID); !ok { continue } if !dns.CredsComplete(p.ProviderID, p.Credentials) { continue } cleaned = append(cleaned, p) } body, err := json.Marshal(cleaned) if err != nil { return err } return models.SetSetting(s.DB, settingDNSProfilesJSON, string(body)) } func (s *Server) dnsProfileByID(profileID string) (dnsCredentialProfile, bool) { profileID = strings.TrimSpace(profileID) if profileID == "" { return dnsCredentialProfile{}, false } for _, p := range s.loadDNSProfiles() { if p.ID == profileID { return p, true } } return dnsCredentialProfile{}, false } func (s *Server) dnsCredsFor(providerID, profileID string) map[string]string { providerID = strings.ToLower(strings.TrimSpace(providerID)) profileID = strings.TrimSpace(profileID) if profileID != "" { if p, ok := s.dnsProfileByID(profileID); ok && p.ProviderID == providerID { creds := map[string]string{} for k, v := range p.Credentials { creds[k] = v } return creds } return map[string]string{} } return s.dnsCreds(providerID) } func (s *Server) dnsClientFor(providerID, profileID string) dns.Provider { if strings.TrimSpace(providerID) == "" { return nil } return dns.Build(providerID, s.dnsCredsFor(providerID, profileID)) } func (s *Server) zoneAllowlistFor(providerID, profileID string) []string { providerID = strings.ToLower(strings.TrimSpace(providerID)) profileID = strings.TrimSpace(profileID) if profileID != "" { if p, ok := s.dnsProfileByID(profileID); ok && p.ProviderID == providerID { return p.ZoneAllowlist } return []string{"__missing_dns_profile__"} } return s.zoneAllowlist(providerID) } func (s *Server) zoneAllowedFor(providerID, profileID, zoneName string) bool { allow := s.zoneAllowlistFor(providerID, profileID) if len(allow) == 0 { return true } zoneName = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(zoneName)), ".") if zoneName == "" { return false } for _, z := range allow { if z == zoneName { return true } } return false } func (s *Server) normalizeDNSFormSelection(providerID, profileID string) (string, string) { providerID = strings.ToLower(strings.TrimSpace(providerID)) profileID = strings.TrimSpace(profileID) if strings.HasPrefix(profileID, "legacy:") { return strings.TrimPrefix(profileID, "legacy:"), "" } if p, ok := s.dnsProfileByID(profileID); ok { return p.ProviderID, p.ID } if _, ok := dns.Lookup(providerID); ok { return providerID, "" } return "", "" } func (s *Server) applyDNSFormSelection(p *models.ProxyHost) { provider, profileID := s.normalizeDNSFormSelection(p.DNSProvider, p.DNSProfileID) p.DNSProvider, p.DNSProfileID = provider, profileID if provider == "" { p.DNSZoneID, p.DNSZoneName, p.DNSRecordID = "", "", "" p.DNSSkipRecord = false } } func (s *Server) applyRedirectionDNSFormSelection(rh *models.RedirectionHost) { provider, profileID := s.normalizeDNSFormSelection(rh.DNSProvider, rh.DNSProfileID) rh.DNSProvider, rh.DNSProfileID = provider, profileID if provider == "" { rh.DNSZoneID, rh.DNSZoneName, rh.DNSRecordID = "", "", "" rh.DNSSkipRecord = false } } func (s *Server) applyRawDNSFormSelection(rr *models.RawRoute) { provider, profileID := s.normalizeDNSFormSelection(rr.DNSProvider, rr.DNSProfileID) rr.DNSProvider, rr.DNSProfileID = provider, profileID if provider == "" { rr.DNSZoneID, rr.DNSZoneName, rr.DNSRecordID = "", "", "" rr.DNSSkipRecord = false } } func (s *Server) validateManagedDNSRecordTarget(serverID int64, providerID, zoneID string, skipRecord bool) string { if providerID == "" || zoneID == "" || skipRecord || strings.TrimSpace(s.serverIPFor(serverID)) != "" { return "" } return "Managed DNS cannot create a public A record because this Caddy server has no public IP configured. Set its public IP under Settings → DNS, or turn off “Create public A records” to use DNS-01 only." } func (s *Server) dnsProfileViews() []dnsProfileView { profiles := s.loadDNSProfiles() out := make([]dnsProfileView, 0, len(profiles)+1) for _, p := range profiles { providerName := p.ProviderID if d, ok := dns.Lookup(p.ProviderID); ok { providerName = d.DisplayName } out = append(out, dnsProfileView{ ID: p.ID, Name: p.Name, ProviderID: p.ProviderID, ProviderName: providerName, TokenSet: strings.TrimSpace(p.Credentials[settingCFAPIToken]) != "", Proxied: p.Credentials[settingCFProxied] == "1", ZoneAllowlistRaw: strings.Join(p.ZoneAllowlist, "\n"), }) } out = append(out, dnsProfileView{ProviderID: dns.Cloudflare, ProviderName: "Cloudflare"}) return out } func (s *Server) parseDNSProfilesForm(r *http.Request) []dnsCredentialProfile { existing := map[string]dnsCredentialProfile{} for _, p := range s.loadDNSProfiles() { existing[p.ID] = p } deleted := map[string]struct{}{} for _, id := range r.PostForm["dns_profile_delete"] { if id = strings.TrimSpace(id); id != "" { deleted[id] = struct{}{} } } ids := r.PostForm["dns_profile_id"] names := r.PostForm["dns_profile_name"] tokens := r.PostForm["dns_profile_token"] proxied := r.PostForm["dns_profile_proxied"] allows := r.PostForm["dns_profile_zone_allowlist"] out := make([]dnsCredentialProfile, 0, len(names)) for i := range names { id := "" if i < len(ids) { id = strings.TrimSpace(ids[i]) } if _, drop := deleted[id]; drop && id != "" { continue } name := strings.TrimSpace(names[i]) token := "" if i < len(tokens) { token = strings.TrimSpace(tokens[i]) } if token == "" && id != "" { if prev, ok := existing[id]; ok { token = strings.TrimSpace(prev.Credentials[settingCFAPIToken]) } } if name == "" && token == "" { continue } if name == "" || token == "" { continue } if id == "" { id = randomDNSProfileID() } proxyFlag := "0" if i < len(proxied) && proxied[i] == "1" { proxyFlag = "1" } allowRaw := "" if i < len(allows) { allowRaw = allows[i] } out = append(out, dnsCredentialProfile{ ID: id, Name: name, ProviderID: dns.Cloudflare, Credentials: map[string]string{ settingCFAPIToken: token, settingCFProxied: proxyFlag, }, ZoneAllowlist: parseZoneAllowlist(allowRaw), }) } return out } // dnsClient returns a ready-to-use Provider for the given ID, or nil if // credentials aren't configured. Replaces the per-provider cfClient / // pbClient helpers. func (s *Server) dnsClient(providerID string) dns.Provider { if providerID == "" { return nil } return dns.Build(providerID, s.dnsCreds(providerID)) } // serverIP returns the legacy global IP. Kept for the backwards-compat // fallback path — new code should use serverIPFor(serverID) instead. func (s *Server) serverIP() string { ip, _ := models.GetSetting(s.DB, settingServerIP) return ip } // serverIPFor returns the public IP to use as the A-record target for // proxy hosts that live on serverID. Reads caddy_servers.public_ip first, // then falls back to the legacy global setting so pre-v2.4.0 databases // still resolve to *some* IP while users fill in the per-server column. func (s *Server) serverIPFor(serverID int64) string { if serverID > 0 { if srv, err := models.GetCaddyServer(s.DB, serverID); err == nil && strings.TrimSpace(srv.PublicIP) != "" { return strings.TrimSpace(srv.PublicIP) } } return s.serverIP() } // verifyTurnstile calls the Cloudflare Turnstile siteverify endpoint. // Returns true when the challenge token is valid. Error-codes and the // hostname Cloudflare echoes back are logged on failure so an admin // reading `docker logs caddyui` can tell a wrong-key mistake from a // domain-not-registered one — matches what verifyRecaptcha does for // the Google siteverify path. (v2.6.1 — previously a failure looked // identical to every other "Security check failed" in the UI.) func verifyTurnstile(secretKey, token, remoteIP string) (bool, error) { if token == "" { log.Printf("turnstile: reject — no token in form (widget may not have loaded)") return false, nil } if strings.TrimSpace(secretKey) == "" { log.Printf("turnstile: reject — secret key is blank in DB (check Settings → CAPTCHA → Turnstile)") return false, nil } resp, err := http.PostForm("https://challenges.cloudflare.com/turnstile/v0/siteverify", url.Values{ "secret": {secretKey}, "response": {token}, "remoteip": {remoteIP}, }) if err != nil { log.Printf("turnstile: network error talking to Cloudflare: %v", err) return false, err } defer resp.Body.Close() var result struct { Success bool `json:"success"` Hostname string `json:"hostname"` ErrorCodes []string `json:"error-codes"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { log.Printf("turnstile: decode Cloudflare response: %v", err) return false, err } if !result.Success { log.Printf("turnstile: Cloudflare rejected token — error-codes=%v hostname=%q (check Settings → CAPTCHA if this is a keys mismatch)", result.ErrorCodes, result.Hostname) return false, nil } return true, nil } func (s *Server) getLogin(w http.ResponseWriter, r *http.Request) { n, err := models.CountUsers(s.DB) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } if n == 0 { http.Redirect(w, r, "/setup", http.StatusSeeOther) return } data := captchaTemplateData(loadCaptchaConfig(s.DB)) if r.URL.Query().Get("reset") == "1" { data["Reset"] = true } if r.URL.Query().Get("invited") == "1" { data["Invited"] = true } // issue #106: offer the SSO button when OIDC is configured; surface any // SSO error the callback bounced back with a friendly message. if oc := s.oidcConfig(); oc.ready() { data["OIDCEnabled"] = true data["OIDCButtonLabel"] = oc.ButtonLabel // Translate only the built-in fallback, never an administrator's label. if strings.TrimSpace(mustGetSetting(s.DB, settingOIDCButtonLabel)) == "" { data["OIDCButtonLabel"] = s.tr(r, "login.sso_default") } } if e := r.URL.Query().Get("error"); strings.HasPrefix(e, "sso") { key := "login.error.sso_failed" switch e { case "sso_nouser": key = "login.error.sso_no_user" case "sso_email": key = "login.error.sso_email" case "sso_denied": key = "login.error.sso_cancelled" case "sso_domain": // Deliberately does not name the permitted domains: this page is // reachable by anyone, and the list is configuration. key = "login.error.sso_domain" } data["Error"] = s.tr(r, key) } s.render(w, r, "login.html", data) } // sanitizeForLog escapes CR/LF in user-controlled strings before they land // in a log line so an attacker can't smuggle a forged log entry by typing // `victim@example.com\n[CRITICAL] system compromised` into a form field. // Replaces newlines with their literal escape sequence so the original // content is still readable for diagnosis without breaking line boundaries. // v2.9.225 — addresses CodeQL "Log entries created from user input" finding // on the forgot-password and invite handlers' log.Printf calls. func sanitizeForLog(s string) string { return strings.NewReplacer("\n", "\\n", "\r", "\\r").Replace(s) } // clientIPFromRequest extracts the real client IP, preferring forwarded // headers when present so installs behind a reverse proxy (the typical // CaddyUI deployment — it sits behind the very Caddy it manages) record // the actual visitor IP in activity_log instead of 127.0.0.1. Order: // X-Real-IP → first entry of X-Forwarded-For → r.RemoteAddr (host portion). // Returns the raw string with no normalisation; activity log just stores // it as text for the operator to read. v2.9.210. func clientIPFromRequest(r *http.Request) string { if v := strings.TrimSpace(r.Header.Get("X-Real-Ip")); v != "" { return v } if v := r.Header.Get("X-Forwarded-For"); v != "" { // Left-most entry is the original client; rest are intermediate hops. if i := strings.Index(v, ","); i >= 0 { v = v[:i] } v = strings.TrimSpace(v) if v != "" { return v } } host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { return r.RemoteAddr } return host } // peerHost returns the immediate TCP peer's IP (the host portion of // RemoteAddr, no port). func peerHost(r *http.Request) string { host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { return strings.TrimSpace(r.RemoteAddr) } return host } // peerIsTrustedProxy reports whether the immediate peer may be trusted to have // set X-Forwarded-For / X-Real-IP. When the admin has configured // trusted_proxies, only those CIDRs/IPs are trusted. When it is empty we fall // back to the standard CaddyUI topology — a reverse proxy sharing the host or // LAN — and trust loopback and private/link-local peers only. A directly // connected public client is never trusted, so it cannot forge the header. func (s *Server) peerIsTrustedProxy(peerIP net.IP) bool { if peerIP == nil { return false } raw, _ := models.GetSetting(s.DB, settingTrustedProxies) if strings.TrimSpace(raw) == "" { return peerIP.IsLoopback() || peerIP.IsPrivate() || peerIP.IsLinkLocalUnicast() } for _, line := range strings.FieldsFunc(raw, func(r rune) bool { return r == ',' || r == '\n' }) { entry := strings.TrimSpace(line) if entry == "" { continue } if strings.Contains(entry, "/") { if _, ipnet, err := net.ParseCIDR(entry); err == nil && ipnet.Contains(peerIP) { return true } continue } if ip := net.ParseIP(entry); ip != nil && ip.Equal(peerIP) { return true } } return false } // rateLimitClientIP returns a client IP suitable as a security rate-limit key // (e.g. the login brute-force limiter). Unlike clientIPFromRequest — which // trusts forwarding headers unconditionally for activity-log display — this // only honours X-Real-IP / X-Forwarded-For when the immediate peer is a trusted // proxy, so a directly connected attacker cannot rotate a forged header to // reset the lockout counter. When trusted, X-Real-IP (a single proxy-set value) // wins; otherwise the right-most X-Forwarded-For entry is used, since that is // the hop appended by the closest trusted proxy and is not client-spoofable. func (s *Server) rateLimitClientIP(r *http.Request) string { ip, _ := s.resolvedClientIP(r) return ip } // resolvedClientIP returns the visitor's address as CaddyUI should judge it, // and whether that came from a proxy header rather than the connection. // // A forwarded header is believed only when the machine that opened the // connection is itself a trusted proxy, and the *right-most* entry is taken — // the one the trusted proxy appended — so a visitor cannot prepend a chosen // address to X-Forwarded-For and be believed. // // v2.65.0 (issue #140): split out of rateLimitClientIP so the admin IP // allowlist resolves the address the same way the login rate limiter does, // and so Settings can show the operator which address a request arrived with. func (s *Server) resolvedClientIP(r *http.Request) (ip string, viaTrustedProxy bool) { peer := peerHost(r) if s.peerIsTrustedProxy(net.ParseIP(peer)) { if v := strings.TrimSpace(r.Header.Get("X-Real-Ip")); v != "" { return v, true } if v := r.Header.Get("X-Forwarded-For"); v != "" { parts := strings.Split(v, ",") for i := len(parts) - 1; i >= 0; i-- { if p := strings.TrimSpace(parts[i]); p != "" { return p, true } } } } return peer, false } // loginFailureWindow is how far back failed sign-in attempts are counted. const loginFailureWindow = 15 * time.Minute // maxTOTPAttemptsPerToken is how many wrong second-factor codes one pending // login token allows; maxTOTPFailuresPerAccount is how many wrong codes an // account may rack up inside loginFailureWindow, across tokens and IPs. const ( maxTOTPAttemptsPerToken = 5 maxTOTPFailuresPerAccount = 10 ) // totpLockedOut reports whether an account has failed the second factor too // often recently. Keyed on the account, not the client IP, so it cannot lock out // everyone behind a shared reverse-proxy address; only someone who already // knows the password can trip it. func (s *Server) totpLockedOut(email string) bool { var n int _ = s.DB.QueryRow( `SELECT COUNT(*) FROM activity_log WHERE action = 'login_totp_fail' AND actor = ? AND created_at > ?`, email, time.Now().UTC().Add(-loginFailureWindow), ).Scan(&n) return n >= maxTOTPFailuresPerAccount } func (s *Server) postLogin(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() // v2.5.0: unified captcha (Turnstile OR reCAPTCHA v3, driven by the // captcha_provider setting). verifyCaptcha no-ops when disabled, so // handlers can call it unconditionally. captchaCfg := loadCaptchaConfig(s.DB) tplData := captchaTemplateData(captchaCfg) renderLoginErr := func(msg string) { data := map[string]any{"Error": msg} for k, v := range tplData { data[k] = v } s.render(w, r, "login.html", data) } if ok, err := verifyCaptcha(captchaCfg, r); err != nil || !ok { renderLoginErr(s.tr(r, "login.error.security_check")) return } // v2.9.5: brute-force protection — check recent failed login attempts from this IP. // v2.9.210: use clientIPFromRequest so installs behind a reverse proxy // match by the actual visitor IP, not the proxy's loopback address. // v2.52.4: the rate-limit key uses rateLimitClientIP, which only trusts the // forwarded IP when the peer is a trusted proxy — a directly connected // attacker can no longer forge X-Forwarded-For / X-Real-IP to bypass the // lockout. Activity-log display below keeps clientIPFromRequest. clientIP := s.rateLimitClientIP(r) if maxStr, _ := models.GetSetting(s.DB, settingMaxLoginAttempts); maxStr != "" { if maxAttempts, err := strconv.Atoi(strings.TrimSpace(maxStr)); err == nil && maxAttempts > 0 { var failCount int // Security (v2.57.1): the failure row stores "ip:" in the // target column (see LogActivity below). This query matched the // detail column instead, so it always counted 0 and the lockout // never fired. _ = s.DB.QueryRow( `SELECT COUNT(*) FROM activity_log WHERE action = 'login_fail' AND target = ? AND created_at > ?`, "ip:"+clientIP, time.Now().UTC().Add(-loginFailureWindow), ).Scan(&failCount) if failCount >= maxAttempts { renderLoginErr(s.tr(r, "login.error.login_limit")) return } } } email := strings.TrimSpace(r.FormValue("email")) pw := r.FormValue("password") u, err := models.GetUserByEmail(s.DB, email) if err != nil || !auth.CheckPassword(u.PasswordHash, pw) { _ = models.LogActivity(s.DB, 0, email, "login_fail", "ip:"+clientIP, "invalid credentials", false) renderLoginErr(s.tr(r, "login.error.invalid_credentials")) return } if u.TOTPEnabled && u.TOTPSecret != "" { if s.totpLockedOut(u.Email) { renderLoginErr(s.tr(r, "login.error.totp_limit")) return } // Generate a pre-auth token and redirect to TOTP verification. b := make([]byte, 16) rand.Read(b) tok := hex.EncodeToString(b) s.pendingTOTP.Store(tok, u.ID) // Auto-expire after 5 minutes. go func() { time.Sleep(5 * time.Minute) s.pendingTOTP.Delete(tok) }() http.Redirect(w, r, "/login/totp?t="+tok, http.StatusSeeOther) return } tok, exp, err := auth.CreateSessionWithTTL(s.DB, u.ID, s.sessionTTL()) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } auth.SetSessionCookie(w, r, tok, exp) // v2.9.210: surface successful logins in the activity feed so admins can // see who signed in from where. Detail carries the User-Agent for forensic // context (browser fingerprint mismatch on a teammate's account is useful // signal during an incident). _ = models.LogActivity(s.DB, 0, u.Email, "login_success", "ip:"+clientIP, r.UserAgent(), true) http.Redirect(w, r, "/", http.StatusSeeOther) } func (s *Server) postLogout(w http.ResponseWriter, r *http.Request) { // v2.9.210: capture the actor before deleting the session so the activity // row gets the actual user's email rather than empty. cu := s.currentUser(r) actor := "" if cu != nil { actor = cu.Email } if c, err := r.Cookie(auth.SessionCookie); err == nil { _ = auth.DeleteSession(s.DB, c.Value) } auth.ClearSessionCookie(w, r) if actor != "" { _ = models.LogActivity(s.DB, 0, actor, "logout", "ip:"+clientIPFromRequest(r), "", true) } http.Redirect(w, r, "/login", http.StatusSeeOther) } // --- TOTP / 2FA --- // getTOTPVerify shows the TOTP code entry page. func (s *Server) getTOTPVerify(w http.ResponseWriter, r *http.Request) { tok := r.URL.Query().Get("t") if tok == "" { http.Redirect(w, r, "/login", http.StatusSeeOther) return } data := captchaTemplateData(loadCaptchaConfig(s.DB)) data["Token"] = tok s.render(w, r, "totp_verify.html", data) } // postTOTPVerify validates the TOTP code and creates a session. func (s *Server) postTOTPVerify(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() tok := r.FormValue("token") code := strings.TrimSpace(r.FormValue("code")) // v2.5.0: captcha also gates the TOTP step. Rationale: /login and // /login/totp are separate endpoints, so a bot that cracks a password // without captcha here could still pound TOTP codes (1M combos) if // TOTP had no challenge. Cheap to add, meaningfully raises the floor. captchaCfg := loadCaptchaConfig(s.DB) tplData := captchaTemplateData(captchaCfg) renderTOTPErr := func(msg string) { data := map[string]any{"Token": tok, "Error": msg} for k, v := range tplData { data[k] = v } s.render(w, r, "totp_verify.html", data) } val, ok := s.pendingTOTP.Load(tok) if !ok { renderTOTPErr("Session expired. Please log in again.") return } if ok2, err := verifyCaptcha(captchaCfg, r); err != nil || !ok2 { // Don't delete the pendingTOTP token on captcha fail — let the // user retry with a fresh challenge. Captcha being wrong is an // "I am a bot probably" signal, not an "I burned my TOTP slot" // one. The 5-min auto-expire still caps abuse. renderTOTPErr("Security check failed. Please try again.") return } s.pendingTOTP.Delete(tok) userID := val.(int64) u, err := models.GetUserByID(s.DB, userID) if err != nil { http.Redirect(w, r, "/login", http.StatusSeeOther) return } if s.totpLockedOut(u.Email) { s.pendingTOTPAttempts.Delete(tok) renderTOTPErr("Too many failed verification attempts for this account. Please wait 15 minutes and sign in again.") return } validTOTP := totplib.Validate(code, u.TOTPSecret) if !validTOTP { // Try as a single-use backup code. if u2, _ := models.GetUserByID(s.DB, userID); u2 != nil { if ok, _ := models.ConsumeBackupCode(s.DB, userID, u2.BackupCodes, code); ok { validTOTP = true } } } if !validTOTP { // Security (v2.57.1): the token used to be put back after every wrong // code with no limit, so a known password bought unlimited guesses at // a 6-digit code. A token now allows maxTOTPAttemptsPerToken guesses, // and an account that keeps failing is locked out (totpLockedOut) so a // fresh password login cannot reset the budget. attemptsAny, _ := s.pendingTOTPAttempts.LoadOrStore(tok, new(int32)) attempts := atomic.AddInt32(attemptsAny.(*int32), 1) _ = models.LogActivity(s.DB, 0, u.Email, "login_totp_fail", "ip:"+clientIPFromRequest(r), "invalid TOTP code", false) if attempts >= maxTOTPAttemptsPerToken { s.pendingTOTPAttempts.Delete(tok) // the token stays deleted renderTOTPErr("Too many incorrect codes. Please sign in again.") return } // Put token back so user can retry. s.pendingTOTP.Store(tok, userID) renderTOTPErr("Invalid code. Try again.") return } sessionTok, exp, err := auth.CreateSessionWithTTL(s.DB, userID, s.sessionTTL()) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } auth.SetSessionCookie(w, r, sessionTok, exp) // v2.9.210: log the second-factor success so the audit trail mirrors // the password-only login_success path. _ = models.LogActivity(s.DB, 0, u.Email, "login_totp_success", "ip:"+clientIPFromRequest(r), r.UserAgent(), true) http.Redirect(w, r, "/", http.StatusSeeOther) } // getTOTPSetup shows the TOTP setup page for the current user. // totpQRDataURI renders an otpauth:// URI as a base64 PNG data URI so the 2FA // setup page can show the QR code without loading a script from a CDN. // // v2.27.0: replaces the cdn.jsdelivr.net/npm/qrcode client-side render, which // broke when that CDN path started returning 404 (issue #37) and was blocked // by CORB besides. Rendering server-side also means 2FA setup works on an // air-gapped install. github.com/boombuler/barcode already ships as an // indirect dependency of pquerna/otp, so this adds no new module. // // The return type is template.URL, not string: html/template's contextual // autoescaper rejects the data: scheme in a src attribute and substitutes // "#ZgotmplZ", which renders as a broken image. Marking it safe is correct // here — the value is built entirely server-side from base64-encoded PNG // bytes, with no user-controlled input reaching the URL. // // Returns "" on error; the template falls back to the manual-entry secret, // which is always displayed alongside the QR. func totpQRDataURI(otpauthURI string) template.URL { key, err := otplib.NewKeyFromURL(otpauthURI) if err != nil { return "" } img, err := key.Image(320, 320) if err != nil { return "" } var buf bytes.Buffer if err := png.Encode(&buf, img); err != nil { return "" } return template.URL("data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes())) } func (s *Server) getTOTPSetup(w http.ResponseWriter, r *http.Request) { u := s.currentUser(r) key, err := totplib.Generate(totplib.GenerateOpts{ Issuer: "CaddyUI", AccountName: u.Email, }) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } // Backup codes passed as a one-time query param after enabling/regenerating. backupCodes := strings.Split(r.URL.Query().Get("backup_codes"), ",") if len(backupCodes) == 1 && backupCodes[0] == "" { backupCodes = nil } // Count remaining backup codes stored for this user. var backupCodeCount int if cu := s.currentUser(r); cu != nil && cu.BackupCodes != "" { var hashes []string if json.Unmarshal([]byte(cu.BackupCodes), &hashes) == nil { backupCodeCount = len(hashes) } } pageError := "" if r.URL.Query().Get("error") == "password" { pageError = "Enter your current password to change two-factor settings." } s.render(w, r, "totp_setup.html", map[string]any{ "Error": pageError, "User": u, "Secret": key.Secret(), "OTPAuth": key.URL(), "OTPQRCode": totpQRDataURI(key.URL()), "TOTPEnabled": u.TOTPEnabled, "Required": r.URL.Query().Get("required") == "1", "Enforce": r.URL.Query().Get("enforce") == "1", "BackupCodes": backupCodes, "BackupCodeCount": backupCodeCount, }) } // postTOTPSetup enables or disables TOTP for the current user. // reauthenticated reports whether password is the signed-in user's current // password. Used before changing the second factor, so a stolen session alone // cannot remove it (v2.57.1). func (s *Server) reauthenticated(u *models.User, password string) bool { if u == nil || password == "" { return false } full, err := models.GetUserByID(s.DB, u.ID) if err != nil || full == nil { return false } return auth.CheckPassword(full.PasswordHash, password) } func (s *Server) postTOTPSetup(w http.ResponseWriter, r *http.Request) { u := s.currentUser(r) _ = r.ParseForm() action := r.FormValue("action") if action == "disable" { // v2.57.1: re-authenticate. A stolen session could otherwise strip the // account's second factor with one click. if !s.reauthenticated(u, r.FormValue("current_password")) { http.Redirect(w, r, "/totp/setup?error=password", http.StatusSeeOther) return } if err := models.SetUserTOTP(s.DB, u.ID, "", false); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } http.Redirect(w, r, "/totp/setup?disabled=1", http.StatusSeeOther) return } // action == "enable": verify the submitted code against the submitted secret. secret := r.FormValue("secret") code := strings.TrimSpace(r.FormValue("code")) if !totplib.Validate(code, secret) { otpAuth := "otpauth://totp/CaddyUI:" + u.Email + "?secret=" + secret + "&issuer=CaddyUI" s.render(w, r, "totp_setup.html", map[string]any{ "User": u, "Secret": secret, "OTPAuth": otpAuth, "OTPQRCode": totpQRDataURI(otpAuth), "Error": "Invalid code — try again.", "TOTPEnabled": u.TOTPEnabled, }) return } if err := models.SetUserTOTP(s.DB, u.ID, secret, true); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } // Generate fresh backup codes when TOTP is first enabled. if backupCodes, err := models.GenerateBackupCodes(10); err == nil { if err := models.SaveBackupCodes(s.DB, u.ID, backupCodes); err == nil { // Store codes in session flash or query param for one-time display. // Encode as comma-separated for URL safety. codesParam := strings.Join(backupCodes, ",") http.Redirect(w, r, "/totp/setup?enabled=1&backup_codes="+url.QueryEscape(codesParam), http.StatusSeeOther) return } } http.Redirect(w, r, "/totp/setup?enabled=1", http.StatusSeeOther) } func (s *Server) postRegenerateBackupCodes(w http.ResponseWriter, r *http.Request) { cu := s.currentUser(r) if cu == nil || !cu.TOTPEnabled { http.Redirect(w, r, "/totp/setup", http.StatusSeeOther) return } if !s.reauthenticated(cu, r.FormValue("current_password")) { // v2.57.1 — see postTOTPSetup http.Redirect(w, r, "/totp/setup?error=password", http.StatusSeeOther) return } codes, err := models.GenerateBackupCodes(10) if err != nil { http.Error(w, "failed to generate codes", http.StatusInternalServerError) return } if err := models.SaveBackupCodes(s.DB, cu.ID, codes); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } codesParam := strings.Join(codes, ",") http.Redirect(w, r, "/totp/setup?regen=1&backup_codes="+url.QueryEscape(codesParam), http.StatusSeeOther) } // --- Dashboard --- type dashboardRecommendation struct { Severity string // "critical" | "warning" | "info" Title string Detail string URL string Action string // DismissURL, when set, renders a Dismiss button that POSTs here. // v2.64.2 (issue #138): only for recommendations where declining is a // legitimate decision rather than something left unfinished. DismissURL string } type dashboardRecommendationInput struct { IsAdmin bool ProxyHosts []models.ProxyHost RedirectionHosts []models.RedirectionHost RawRoutes []models.RawRoute Certificates []models.Certificate Snapshots []models.ConfigSnapshot DNSProfileIDs map[string]bool LastSync *time.Time DownCount int MaintenanceCount int GlobalMaintenance bool // v2.64.1 (issue #138): one policy, not two. See twoFactorRequired. RequireTOTP bool AdminAllowlistSet bool AutoSnapshots bool DismissedUnused string // v2.64.2 (issue #138): admin acknowledged that they do not want a // site-wide TOTP requirement. DismissedTOTPPolicy bool Now time.Time // v2.39.0: best-known expiry per custom certificate (stored PEM, readable // file, or the last live TLS probe) — see customCertificateExpiries. // Callers that leave it nil still get stored-PEM expiries. CertificateExpiry map[int64]time.Time } func buildDashboardRecommendations(in dashboardRecommendationInput) []dashboardRecommendation { now := in.Now if now.IsZero() { now = time.Now() } var out []dashboardRecommendation add := func(severity, title, detail, url, action string) { out = append(out, dashboardRecommendation{ Severity: severity, Title: title, Detail: detail, URL: url, Action: action, }) } // addDismissible is add() for a recommendation the admin may legitimately // decline. Same append path, so the cap below still applies to it. addDismissible := func(severity, title, detail, url, action, dismissURL string) { add(severity, title, detail, url, action) out[len(out)-1].DismissURL = dismissURL } if in.GlobalMaintenance { add("critical", "Global maintenance mode is active", "Every proxy host is serving the maintenance response until this is turned off and Caddy is synced.", "/settings/general", "Open settings") } else if in.MaintenanceCount > 0 { add("warning", "Proxy hosts are in maintenance", fmt.Sprintf("%d enabled proxy host(s) are currently serving maintenance responses.", in.MaintenanceCount), "/proxy-hosts?status=maintenance", "Review hosts") } if in.DownCount > 0 { add("critical", "Upstreams are down", fmt.Sprintf("%d enabled proxy host(s) have failing health checks.", in.DownCount), "/proxy-hosts", "View health") } sslOff := 0 incompleteDNS := 0 missingProfiles := 0 referencedCerts := referencedCertificateIDs(in.ProxyHosts, in.RedirectionHosts, in.RawRoutes) for _, h := range in.ProxyHosts { if h.Enabled && !h.SSLEnabled { sslOff++ } if h.DNSProvider != "" && h.DNSZoneID == "" { incompleteDNS++ } if h.DNSProfileID != "" && !in.DNSProfileIDs[h.DNSProfileID] { missingProfiles++ } } for _, h := range in.RedirectionHosts { if h.DNSProvider != "" && h.DNSZoneID == "" { incompleteDNS++ } if h.DNSProfileID != "" && !in.DNSProfileIDs[h.DNSProfileID] { missingProfiles++ } } for _, rr := range in.RawRoutes { if rr.DNSProvider != "" && rr.DNSZoneID == "" { incompleteDNS++ } if rr.DNSProfileID != "" && !in.DNSProfileIDs[rr.DNSProfileID] { missingProfiles++ } } if sslOff > 0 { add("warning", "Enabled hosts have SSL off", fmt.Sprintf("%d enabled proxy host(s) are reachable without Caddy-managed TLS.", sslOff), "/proxy-hosts", "Review TLS") } if incompleteDNS > 0 { add("warning", "Managed DNS is incomplete", fmt.Sprintf("%d resource(s) have a DNS provider selected but no zone saved yet.", incompleteDNS), "/proxy-hosts", "Review DNS") } if missingProfiles > 0 { add("critical", "DNS profile references are missing", fmt.Sprintf("%d resource(s) reference a deleted or unavailable DNS credential profile.", missingProfiles), "/settings/dns", "Fix profiles") } expiringSoon := 0 expired := 0 var unusedCustomCerts []models.Certificate for _, c := range in.Certificates { if isUnusedCustomCertificate(c, referencedCerts) { unusedCustomCerts = append(unusedCustomCerts, c) } if !isCustomCertificate(c) { continue } t, known := in.CertificateExpiry[c.ID] if !known && c.Source == models.CertSourcePEM { if e := parsePEMExpiry(c.CertPEM); e != nil { t, known = *e, true } } if !known { continue } if t.Before(now) { expired++ } else if t.Sub(now) < 30*24*time.Hour { expiringSoon++ } } if expired > 0 { add("critical", "Custom certificates are expired", fmt.Sprintf("%d custom certificate(s) are already expired.", expired), "/certificates", "Review certs") } else if expiringSoon > 0 { add("warning", "Custom certificates expire soon", fmt.Sprintf("%d custom certificate(s) expire within 30 days.", expiringSoon), "/certificates", "Review certs") } unusedFingerprint := unusedCertificateFingerprint(in.Certificates, referencedCerts) if in.IsAdmin && len(unusedCustomCerts) > 0 && unusedFingerprint != in.DismissedUnused { add( "info", "Unused custom certificates", fmt.Sprintf("%d certificate(s) are not assigned to any resource: %s.", len(unusedCustomCerts), summarizeCertificateNames(unusedCustomCerts, 3)), "/certificates?usage=unused", "Show unused", ) } resourceCount := len(in.ProxyHosts) + len(in.RedirectionHosts) + len(in.RawRoutes) if resourceCount > 0 { if in.LastSync == nil { add("warning", "No successful sync recorded", "Resources exist, but the activity log has no successful Caddy sync for this server.", "/", "Sync Caddy") } else if now.Sub(*in.LastSync) > 7*24*time.Hour { add("info", "Last sync is over a week old", fmt.Sprintf("Last successful sync was %s ago.", humanDuration(now.Sub(*in.LastSync))), "/", "Sync Caddy") } } if in.IsAdmin { // v2.64.2 (issue #138): deciding against a site-wide TOTP requirement // is a legitimate choice, so this one can be acknowledged. The // dismissal is cleared the moment the policy is turned on and off // again, so it cannot hide a later change of mind. if !in.RequireTOTP && !in.DismissedTOTPPolicy { addDismissible( "warning", "Two-factor authentication is not required", "Requiring TOTP enrolment for everyone reduces account-takeover risk. Dismiss this if you have decided against it.", "/settings/security", "Open security", "/dashboard/recommendations/two-factor-policy/dismiss", ) } if !in.AdminAllowlistSet { add("info", "Admin IP allowlist is empty", "Restricting CaddyUI to trusted IPs or CIDRs can reduce exposure for homelab installs.", "/settings/security", "Open security") } if len(in.Snapshots) == 0 { add("warning", "No config snapshots yet", "Take a manual snapshot so there is a known-good Caddy config to restore.", "/snapshots", "Take snapshot") } else if !in.AutoSnapshots { add("info", "Auto-snapshots are disabled", "Manual snapshots still work, but pre-sync restore points will not be captured automatically.", "/snapshots", "Review snapshots") } } if len(out) > 6 { return out[:6] } return out } func humanDuration(d time.Duration) string { if d < 0 { d = -d } days := int(d.Hours() / 24) if days >= 1 { if days == 1 { return "1 day" } return fmt.Sprintf("%d days", days) } hours := int(d.Hours()) if hours >= 1 { if hours == 1 { return "1 hour" } return fmt.Sprintf("%d hours", hours) } minutes := int(d.Minutes()) if minutes <= 1 { return "1 minute" } return fmt.Sprintf("%d minutes", minutes) } func (s *Server) dashboard(w http.ResponseWriter, r *http.Request) { sid := s.currentServerID(r) cu := s.currentUser(r) isAdmin := cu != nil && cu.Role == models.RoleAdmin var viewerID int64 if cu != nil { viewerID = cu.ID } peers := s.groupPeerIDs(r) hosts, _ := models.ListProxyHosts(s.DB, sid, viewerID, isAdmin, peers) redirs, _ := models.ListRedirectionHosts(s.DB, sid, viewerID, isAdmin, peers) raws, _ := models.ListRawRoutes(s.DB, sid, viewerID, isAdmin, peers) // v2.7.2: cert card count matches what the /certificates page will show — // admin sees every cert, user-role sees their uploads + globals. // v2.7.4: ...plus any teammate uploads from groups they share. certs, _ := models.ListCertificatesForUser(s.DB, sid, viewerID, isAdmin, peers) // Most-recent sync timestamp from activity log (best-effort). var lastSync *time.Time var ls time.Time if err := s.DB.QueryRow( `SELECT created_at FROM activity_log WHERE server_id = ? AND action = 'sync_applied' ORDER BY id DESC LIMIT 1`, sid, ).Scan(&ls); err == nil { lastSync = &ls } // Feature A: compute enabled/disabled counts and certs expiring within 30 days. var enabledHosts, disabledHosts, expiringSoon int for _, h := range hosts { if h.Enabled { enabledHosts++ } else { disabledHosts++ } } now := time.Now() for _, c := range certs { if t := parsePEMExpiry(c.CertPEM); t != nil { if t.Sub(now) < 30*24*time.Hour && t.After(now) { expiringSoon++ } } } // v2.9.2: health summary — count hosts by latest check result. var healthyCount, downCount, unknownCount int if len(hosts) > 0 { var hostIDs []int64 for _, h := range hosts { hostIDs = append(hostIDs, h.ID) } healthMap, _ := models.LatestProxyHealth(s.DB, hostIDs) for _, h := range hosts { if !h.Enabled { continue // only count enabled hosts } if chk, ok := healthMap[h.ID]; ok { if chk.OK { healthyCount++ } else { downCount++ } } else { unknownCount++ } } } // Today's analytics stats are scoped by both visible hostname and the // selected fleet server. Hostname-only filtering is insufficient when two // nodes serve the same domain: it would merge both nodes back together even // after the operator switched environments. todayStart := time.Now().UTC().Truncate(24 * time.Hour) todayViews, todayVisitors := 0, 0 var todayBandwidth int64 hostsForActive := make([]string, 0, len(hosts)+len(redirs)+len(raws)) for _, h := range hosts { hostsForActive = append(hostsForActive, h.DomainList()...) } for _, rh := range redirs { hostsForActive = append(hostsForActive, rh.DomainList()...) } for _, rr := range raws { hostsForActive = append(hostsForActive, rawRouteHosts(rr)...) } seenTrafficHosts := map[string]struct{}{} for _, host := range hostsForActive { host = strings.TrimSpace(host) if host == "" { continue } hostKey := strings.ToLower(host) if _, seen := seenTrafficHosts[hostKey]; seen { continue } seenTrafficHosts[hostKey] = struct{}{} if t, err := models.AccessTotalsSince(s.DB, todayStart, host, sid); err == nil { todayViews += t.Views todayVisitors += t.Visitors } if bw, err := models.BandwidthSince(s.DB, todayStart, host, sid); err == nil { todayBandwidth += bw } } // Count hosts in maintenance mode for the dashboard banner. maintenanceCount := 0 for _, h := range hosts { if h.MaintenanceMode { maintenanceCount++ } } globalMaintenance, _ := models.GetSetting(s.DB, settingGlobalMaintenance) // v2.11.16: per-server health summary — one card per Caddy server // the user has registered, showing status / host count / last contact. // Aggregated cross-server (not just the active picker) so users with // 4+ servers don't have to flip the picker to spot a down node. type serverHealthRow struct { ID int64 Name string AdminURL string Status string // "online" | "offline" | "unknown" Version string HostCount int LastContactAt time.Time // zero = never; fmtRel handles that IsExternal bool IsActive bool // matches the active server picker } var serverHealth []serverHealthRow if servers, err := models.ListCaddyServers(s.DB); err == nil && len(servers) > 1 { // Only render the widget when there's more than one server — single- // server installs don't benefit from a fleet view. for _, sr := range servers { row := serverHealthRow{ ID: sr.ID, Name: sr.Name, AdminURL: sr.AdminURL, Status: sr.Status, Version: sr.Version, IsExternal: sr.Type == models.CaddyServerTypeExternal, IsActive: sr.ID == sid, } if sr.LastContactAt.Valid { row.LastContactAt = sr.LastContactAt.Time } // Cheap host count — admin sees every host on every server. var n int _ = s.DB.QueryRow(`SELECT COUNT(*) FROM proxy_hosts WHERE server_id=?`, sr.ID).Scan(&n) row.HostCount = n serverHealth = append(serverHealth, row) } } // v2.11.12: "Recently edited" widget — pulls the last few CRUD events // from the activity log (skips login/sync/snapshot noise) so users // landing on the dashboard see at a glance what just changed. var recentEdits []models.Activity if all, err := models.ListActivity(s.DB, sid, 60); err == nil { all = s.activityVisibleTo(s.currentUser(r), all) // v2.57.1 for _, a := range all { if !a.Success { continue } if !isEditAction(a.Action) { continue } recentEdits = append(recentEdits, a) if len(recentEdits) >= 8 { break } } } dnsProfileIDs := map[string]bool{} for _, p := range s.loadDNSProfiles() { dnsProfileIDs[p.ID] = true } currentServer, _ := models.GetCaddyServer(s.DB, sid) serverReady := currentServer != nil && currentServer.Status == models.CaddyServerStatusOnline readinessSteps := requiredReadinessSteps(serverReady, len(hosts) > 0) // v2.64.2 (issue #138): the banner clears once the required foundations // are in place. It used to also hang on the admin's own TOTP, which is a // recommendation, so declining it meant "Continue setup" with nothing // left to continue. showReadiness := isAdmin && readinessIncomplete(serverReady, len(hosts) > 0) snapshots, _ := models.ListSnapshots(s.DB, sid, 1) requireTOTP := s.twoFactorRequired() adminAllowlistSet := strings.TrimSpace(mustGetSetting(s.DB, settingAdminAllowlist)) != "" dismissedUnused := mustGetSetting(s.DB, unusedCertificateDismissalKey(sid)) recommendations := buildDashboardRecommendations(dashboardRecommendationInput{ IsAdmin: isAdmin, ProxyHosts: hosts, RedirectionHosts: redirs, RawRoutes: raws, Certificates: certs, Snapshots: snapshots, DNSProfileIDs: dnsProfileIDs, LastSync: lastSync, DownCount: downCount, MaintenanceCount: maintenanceCount, GlobalMaintenance: globalMaintenance == "1", RequireTOTP: requireTOTP, AdminAllowlistSet: adminAllowlistSet, AutoSnapshots: s.autoSnapshotsEnabled(), DismissedUnused: dismissedUnused, DismissedTOTPPolicy: mustGetSetting(s.DB, settingTOTPPolicyDismissed) == "1", CertificateExpiry: s.customCertificateExpiries(certs), Now: time.Now(), }) s.render(w, r, "dashboard.html", map[string]any{ "User": s.currentUser(r), "ProxyHosts": hosts, "RedirectionHosts": redirs, "RawRoutes": raws, "RawCount": len(raws), "CertCount": len(certs), "LastSync": lastSync, "EnabledHosts": enabledHosts, "DisabledHosts": disabledHosts, "ExpiringSoon": expiringSoon, "HealthyCount": healthyCount, "DownCount": downCount, "UnknownCount": unknownCount, "TodayViews": todayViews, "TodayVisitors": todayVisitors, "TodayBandwidth": todayBandwidth, "MaintenanceCount": maintenanceCount, "GlobalMaintenance": globalMaintenance, "EnabledHostCount": enabledHosts, "MaintenanceHostCount": maintenanceCount, "RecentEdits": recentEdits, "ServerHealth": serverHealth, "Recommendations": recommendations, "ShowReadiness": showReadiness, "ReadinessSteps": readinessSteps, "Section": "dashboard", }) } // isEditAction — v2.11.12: returns true when the activity-log action is a // resource CRUD event the dashboard's "Recently edited" widget should // surface. Skips login/logout, sync_applied, snapshot_*, profile_*, etc. func isEditAction(action string) bool { prefixes := []string{"proxy_", "redirect_", "raw_", "cert_", "group_", "api_token_"} for _, p := range prefixes { if strings.HasPrefix(action, p) { return true } } return action == "caddyfile_import" || action == "import" } // --- Redirection Hosts --- func (s *Server) listRedirectionHosts(w http.ResponseWriter, r *http.Request) { cu := s.currentUser(r) isAdmin := cu != nil && cu.Role == models.RoleAdmin var viewerID int64 if cu != nil { viewerID = cu.ID } hosts, err := models.ListRedirectionHosts(s.DB, s.currentServerID(r), viewerID, isAdmin, s.groupPeerIDs(r)) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } s.render(w, r, "redirection_hosts.html", map[string]any{ "User": s.currentUser(r), "Hosts": hosts, "Section": "redirect", "ViewerID": viewerID, }) } func (s *Server) newRedirectionHost(w http.ResponseWriter, r *http.Request) { certs, _ := s.certListForRequest(r) s.render(w, r, "redirection_host_form.html", s.applyDNSViewData(s.currentServerID(r), map[string]any{ "User": s.currentUser(r), "Host": &models.RedirectionHost{Enabled: true, PreservePath: true, ForwardHTTPCode: 301, ForwardScheme: "auto", SSLEnabled: true, SSLForced: true}, "Certificates": certs, "Users": s.adminUserList(r), "OtherServers": s.otherManagedServers(r), "Section": "redirect", })) } func parseRedirectionHostForm(r *http.Request) (*models.RedirectionHost, error) { _ = r.ParseForm() code, err := strconv.Atoi(r.FormValue("forward_http_code")) if err != nil { code = 301 } certID, _ := strconv.ParseInt(r.FormValue("certificate_id"), 10, 64) // v2.12.2: Managed DNS triple — same shape as proxy hosts and raw routes. provider := strings.ToLower(strings.TrimSpace(r.FormValue("dns_provider"))) profileID := strings.TrimSpace(r.FormValue("dns_profile_id")) zoneID := "" zoneName := "" if provider != "" { if _, ok := dns.Lookup(provider); !ok { provider = "" } else { zoneID = strings.TrimSpace(r.FormValue("dns_zone_id")) zoneName = strings.TrimSpace(r.FormValue("dns_zone_name")) if zoneID == "" { provider = "" zoneName = "" } if zoneName == "" { zoneName = zoneID } } } return &models.RedirectionHost{ Domains: strings.TrimSpace(r.FormValue("domains")), ForwardScheme: r.FormValue("forward_scheme"), ForwardDomain: strings.TrimSpace(r.FormValue("forward_domain")), ForwardHTTPCode: code, PreservePath: r.FormValue("preserve_path") == "on", SSLEnabled: r.FormValue("ssl_enabled") == "on", SSLForced: r.FormValue("ssl_forced") == "on", Enabled: r.FormValue("enabled") == "on", CertificateID: certID, Tags: strings.TrimSpace(r.FormValue("rh_tags")), Notes: r.FormValue("rh_notes"), // v2.9.13: access control + maintenance mode AccessList: strings.TrimSpace(r.FormValue("access_list")), MaintenanceMode: r.FormValue("maintenance_mode") == "on", MaintenanceMsg: strings.TrimSpace(r.FormValue("maintenance_msg")), // v2.9.20: custom response headers CustomRespHeaders: func() string { v := strings.TrimSpace(r.FormValue("custom_resp_headers")) if v == "" { return "{}" } return v }(), // v2.9.21: IP blocklist IPBlocklist: strings.TrimSpace(r.FormValue("ip_blocklist")), // v2.9.24: HSTS HSTSMaxAgeSec: func() int { v, _ := strconv.Atoi(r.FormValue("hsts_max_age_sec")); return v }(), HSTSIncludeSubdomains: r.FormValue("hsts_include_subdomains") == "on", HSTSPreload: r.FormValue("hsts_preload") == "on", // v2.9.26: advanced config (raw JSON handlers) AdvancedConfig: strings.TrimSpace(r.FormValue("rh_advanced_config")), // v2.9.33: color label Color: strings.TrimSpace(r.FormValue("color")), // v2.9.38: maintenance status code MaintenanceStatusCode: func() int { v, _ := strconv.Atoi(r.FormValue("maintenance_status_code")) if v == 0 { return 503 } return v }(), // v2.9.39: sort order SortOrder: func() int { v, _ := strconv.Atoi(r.FormValue("sort_order")); return v }(), // v2.9.229: path-based redirect rules. Form posts a JSON array via // a hidden input populated by the JS in redirection_host_form.html; // the dynamic table UI is the user-facing surface. Empty / invalid // JSON collapses to "" so the row keeps the legacy whole-host // redirect behaviour. RedirectRules: func() string { v := strings.TrimSpace(r.FormValue("redirect_rules")) if v == "" || v == "[]" { return "" } // Probe-parse so we don't store garbage that // RedirectRuleList would silently drop on read. var probe []models.RedirectRule if err := json.Unmarshal([]byte(v), &probe); err != nil { return "" } return v }(), // v2.9.230: redirect_strip_path_prefix RedirectStripPathPrefix: strings.TrimSpace(r.FormValue("redirect_strip_path_prefix")), // v2.9.231: redirect_wildcard_subdomain RedirectWildcardSubdomain: r.FormValue("redirect_wildcard_subdomain") == "on", // v2.9.232: sunset_at — accept ISO date YYYY-MM-DD; empty disables. SunsetAt: strings.TrimSpace(r.FormValue("sunset_at")), // v2.12.2: Managed DNS — wired to the same picker as proxy hosts. DNSProvider: provider, DNSZoneID: zoneID, DNSZoneName: zoneName, DNSProfileID: profileID, DNSSkipRecord: provider != "" && r.FormValue("dns_create_record") != "on", }, nil } func (s *Server) createRedirectionHost(w http.ResponseWriter, r *http.Request) { rh, err := parseRedirectionHostForm(r) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } s.applyRedirectionDNSFormSelection(rh) if errMsg := validateSSLFlags(rh.SSLEnabled, rh.SSLForced, rh.CertificateID); errMsg != "" { s.renderRedirectionHostFormError(w, r, rh, errMsg) return } // v2.7.7: same domain-conflict guard as proxy hosts. A redirect that // shadows an existing proxy (or vice versa) is a common mis-configuration // — Caddy keeps one route per hostname, so the older entry stops working // silently. Reject at save time instead. if conflict, err := models.DomainsConflict(s.DB, s.currentServerID(r), rh.DomainList(), 0, 0); err != nil { s.renderRedirectionHostFormError(w, r, rh, "Could not validate domains: "+err.Error()) return } else if conflict != "" { s.renderRedirectionHostFormError(w, r, rh, fmt.Sprintf("Domain %q is already in use by another proxy or redirect on this server. Each domain can only be claimed once — edit the existing entry or remove it before reusing the name.", conflict)) return } if errMsg := validateZoneMatchesHostname(rh.DNSProvider, rh.DNSZoneID, rh.DNSZoneName, rh.DomainList()); errMsg != "" { s.renderRedirectionHostFormError(w, r, rh, errMsg) return } if errMsg := s.validateManagedDNSRecordTarget(s.currentServerID(r), rh.DNSProvider, rh.DNSZoneID, rh.DNSSkipRecord); errMsg != "" { s.renderRedirectionHostFormError(w, r, rh, errMsg) return } deployTo := s.effectiveDeployTargets(s.currentServerID(r), parseDeployTo(r)) cu := s.currentUser(r) var rhOwnerID int64 if cu != nil && cu.Role != models.RoleAdmin { rhOwnerID = cu.ID } else if cu != nil && cu.Role == models.RoleAdmin { // v2.7.3: admin can assign this redirect to a specific user at create // time via the form's Owner