// SPDX-License-Identifier: Apache-2.0 package server import ( "fmt" "strings" ) // errorPageStatuses lists the HTTP status codes CaddyUI injects a branded // error page for. 502/503/504 fire when a reverse_proxy upstream is down, // overloaded, or timing out — the most useful ones to humanise since // self-hosters hit them during deploys and the stock Caddy response // ("502 Bad Gateway") gives operators nothing to grep for. 404 catches // handler-raised not-found (e.g. file_server misses, proxied app 404s). // // Keep the list small and stable: every status here produces a ~2KB HTML // blob in Caddy's config, so adding 5xx wildcards would bloat the JSON. var errorPageStatuses = []int{404, 502, 503, 504} // errorPageTitles maps each status to the big headline on the page. Kept // separate from the body so we can render consistent typography without // embedding status-specific strings in Caddy placeholders. var errorPageTitles = map[int]string{ 404: "Not found", 502: "Bad gateway", 503: "Service unavailable", 504: "Gateway timeout", } // errorPageMessages maps each status to a short human explanation of what // probably went wrong and what to do next. Written for the person who hit // the URL, not the ops team — ops gets the error ID + timestamp in the // footer to correlate with logs. var errorPageMessages = map[int]string{ 404: "The page you were looking for does not exist on this server. Check the URL for typos, or go back to the previous page.", 502: "The server is running, but the application behind it is not responding correctly. This usually means the app crashed or is still starting up.", 503: "The server is temporarily unable to handle your request. The application may be overloaded, restarting, or undergoing maintenance. Please try again in a minute.", 504: "The server is running but the application behind it did not respond in time. This usually means the app is very slow or stuck processing something else.", } // buildErrorPageRoutes returns the Caddy JSON routes block that goes under // apps.http.servers.srv0.errors.routes. One route per status code; each // matches on {http.error.status_code} via the expression matcher and // responds with a static HTML page that includes: // // - the status code and title // - a short human explanation // - Caddy's generated {http.error.id} (9-char correlation ID, auto-set // by the handler subsystem — useful for grepping access logs) // - the current timestamp in HTTP-Date form (from {time.now.http}) // // The {err.*} shortcuts only work through the Caddyfile adapter; raw JSON // requires the full {http.error.*} placeholder path. Learned this the hard // way when the 502 page rendered with a blank body — match never fired. // // No customisation UI for v2.4.12 — scope is "hardcoded default only" per // the feature scoping call. Later versions may promote this to a template // admins can edit in /settings. func buildErrorPageRoutes() []any { routes := make([]any, 0, len(errorPageStatuses)) for _, code := range errorPageStatuses { html := renderErrorPageHTML(code, errorPageTitles[code], errorPageMessages[code]) routes = append(routes, map[string]any{ "match": []any{ map[string]any{ // Caddy's expression matcher uses CEL. Compare as int // (no quotes around the number). "expression": fmt.Sprintf("{http.error.status_code} == %d", code), }, }, "handle": []any{ map[string]any{ "handler": "static_response", "status_code": fmt.Sprintf("%d", code), "headers": map[string]any{ // text/html so browsers render our layout instead // of treating it as plaintext. Cache-Control: no-store // so intermediaries don't pin an old error page past // the outage. "Content-Type": []any{"text/html; charset=utf-8"}, "Cache-Control": []any{"no-store"}, }, "body": html, }, }, }) } return routes } // renderErrorPageHTML builds the HTML body for a single status code. Kept // as a plain string concat (no template) because the content is small, // static, and needs to be embeddable in Caddy JSON — the simpler the // escaping story, the better. // // Placeholders Caddy substitutes at serve time: // // {http.error.id} — 9-char random correlation ID Caddy logs per request // {time.now.http} — current UTC time in HTTP-Date format // (e.g. "Thu, 23 Apr 2026 05:07:23 GMT") func renderErrorPageHTML(code int, title, message string) string { // Inline CSS keeps the page self-contained — error pages must render // even if /static is unreachable (which it usually is during a 502). // Style roughly matches CaddyUI's Tailwind palette (ink-700, brand-600) // so the branded page feels consistent with the admin UI. var b strings.Builder b.WriteString(`
`) b.WriteString(message) b.WriteString(`