internal/server/raw_routes.go
Rama main · réplica de lectura del repositorio original.
// SPDX-License-Identifier: Apache-2.0
package server
import (
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"net/url"
"os"
"slices"
"strconv"
"strings"
"time"
"github.com/X4Applegate/caddyui/internal/caddy"
"github.com/X4Applegate/caddyui/internal/dns"
"github.com/X4Applegate/caddyui/internal/models"
"github.com/go-chi/chi/v5"
)
// --- Raw (passthrough) routes ---
func (s *Server) listRawRoutes(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
}
rows, err := models.ListRawRoutes(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, "raw_routes.html", map[string]any{
"User": s.currentUser(r),
"Rows": rows,
"Section": "raw",
"ViewerID": viewerID,
// v2.10.9: surface the post-reclassify flash banner.
"Flash": r.URL.Query().Get("flash"),
})
}
func (s *Server) newRawRoute(w http.ResponseWriter, r *http.Request) {
certs, _ := s.certListForRequest(r)
s.render(w, r, "raw_route_form.html", s.applyDNSViewData(s.currentServerID(r), map[string]any{
"User": s.currentUser(r),
"Row": &models.RawRoute{Enabled: true},
"Certificates": certs,
"Users": s.adminUserList(r),
"OtherServers": s.otherManagedServers(r),
"Section": "raw",
}))
}
func (s *Server) parseRawRouteForm(r *http.Request) (*models.RawRoute, string) {
_ = r.ParseForm()
label := strings.TrimSpace(r.FormValue("label"))
body := strings.TrimSpace(r.FormValue("json_data"))
cfSrc := strings.TrimSpace(r.FormValue("caddyfile_src"))
// v2.36.1 (issue #64): optional own listener(s) for this route, e.g. ":7070".
// Typed in the form for JSON-only routes; when a Caddyfile block declares a
// site address with a custom port, the adapted value below wins.
listen := models.NormalizeRawRouteListen(models.ParseRawRouteListenInput(r.FormValue("listen")))
if label == "" {
return nil, "Label is required"
}
// If the Caddyfile source field is non-empty, it's authoritative — re-adapt
// through Caddy and let the resulting JSON replace json_data. This is what
// makes the Caddyfile block editable after import.
if cfSrc != "" {
jsonData, adaptedListen, err := s.adaptRawRouteCaddyfile(s.caddyForRequest(r), cfSrc)
if err != nil {
return nil, "Caddyfile rejected by Caddy: " + err.Error()
}
body = jsonData
if adaptedListen != "" {
listen = adaptedListen
}
}
if body == "" {
return nil, "JSON is required"
}
var probe any
if err := json.Unmarshal([]byte(body), &probe); err != nil {
return nil, "Invalid JSON: " + err.Error()
}
certID, _ := strconv.ParseInt(r.FormValue("certificate_id"), 10, 64)
// v2.5.6: Managed DNS picker — mirrors parseProxyHostForm. Unknown
// providers collapse to "none" so a stale dropdown value never writes
// a bogus row.
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
}
}
}
forceSSL := r.FormValue("ssl_forced") == "on"
// Issue #121: an Advanced route written with explicit http:// site
// addresses is a deliberate plain-HTTP route. A certificate has nothing to
// bind to and a forced HTTPS redirect would contradict the address, so
// neither is kept — the form disables both controls for it.
if cfSrc != "" && caddy.CaddyfileHTTPOnly(cfSrc) {
certID = 0
forceSSL = false
}
return &models.RawRoute{
Label: label,
JSONData: body,
CaddyfileSrc: cfSrc,
Enabled: r.FormValue("enabled") == "on",
CertificateID: certID,
ForceSSL: forceSSL,
BlockCommonExploits: r.FormValue("block_common_exploits") == "on",
NodeLocal: r.FormValue("node_local") == "on", // v2.33.0
Listen: listen, // v2.36.1 (issue #64)
DNSProvider: provider,
DNSZoneID: zoneID,
DNSZoneName: zoneName,
DNSProfileID: profileID,
DNSSkipRecord: provider != "" && r.FormValue("dns_create_record") != "on",
}, ""
}
// rawRouteHTTPOnly reports whether rr was written with explicit http:// site
// addresses (issue #121). Such a route is served as plain HTTP: it needs no
// certificate, so there is nothing for the "deploying" page to wait on.
func rawRouteHTTPOnly(rr *models.RawRoute) bool {
return rr != nil && rr.CaddyfileSrc != "" && caddy.CaddyfileHTTPOnly(rr.CaddyfileSrc)
}
// httpsRawRoutes returns the Advanced routes that belong on the HTTPS listener
// (srv0, :443) — every enabled route except those written with explicit
// http:// site addresses (v2.59.3, issue #121). The #121 fix only dropped the
// certificate and Force SSL for such a route on save; the route itself was
// still generated on :443, where Caddy served it over HTTPS and issued an
// internal certificate for its host. Routes with their own listener are
// unaffected (they never share srv0), and the list for the plain :80 server is
// built from the full set, so an http:// route still reaches :80.
func httpsRawRoutes(raws []models.RawRoute) []models.RawRoute {
out := make([]models.RawRoute, 0, len(raws))
for i := range raws {
if raws[i].Listen == "" && rawRouteHTTPOnly(&raws[i]) {
continue
}
out = append(out, raws[i])
}
return out
}
// withAutoLoadedSnippets prepends the snippet definitions — "(name) { ... }" —
// found in the mounted Caddyfile (CADDYFILE_PATH) to src, so `import <name>`
// resolves without the user pasting the definition. Only the snippet blocks
// themselves are added — never site blocks or the global options block, which
// would duplicate existing routes or clash with Caddy's single global-options
// restriction. A snippet src already defines is skipped, to avoid Caddy's
// duplicate-definition error. If CADDYFILE_PATH is unset or unreadable, or
// holds no snippets, src is returned unchanged.
//
// Every path that hands user-written Caddyfile text to /adapt must go through
// this: Advanced routes, the Caddyfile paste importer, and a proxy host's
// Advanced config (issue #119 — that last one was missed and rejected any
// `import <snippet>` with "File to import not found").
func (s *Server) withAutoLoadedSnippets(src string) string {
if s.CaddyfilePath == "" {
return src
}
b, err := os.ReadFile(s.CaddyfilePath)
if err != nil {
return src
}
already := map[string]bool{}
for _, snip := range caddy.ExtractSnippets(src) {
already[caddy.HeadOfBlock(snip)] = true
}
var loaded []string
for _, snip := range caddy.ExtractSnippets(string(b)) {
if !already[caddy.HeadOfBlock(snip)] {
loaded = append(loaded, snip)
}
}
if len(loaded) == 0 {
return src
}
return strings.Join(loaded, "\n\n") + "\n\n" + src
}
// errClientAuthInRawRoute marks the one rejection adaptRawRouteCaddyfile makes
// on its own account rather than relaying from Caddy, so callers can avoid
// blaming Caddy for a block it adapted perfectly well. v2.64.0 (discussion
// #137).
var errClientAuthInRawRoute = errors.New("this block configures client certificate authentication (mTLS), which an Advanced route cannot carry")
// adaptRawRouteCaddyfile sends a Caddyfile block (the `caddyfile_src` field of a
// raw_route) through Caddy's /adapt, prepending auto-loaded snippets from the
// mounted Caddyfile so `import <name>` references resolve. Returns the JSON to
// store in raw_routes.json_data — a single route object if the block produced
// exactly one route, or a JSON array otherwise (buildMergedRoutes handles both) —
// and, v2.36.1 (issue #64), the normalised listen set of the block's server(s)
// ("" when it uses the standard ports). One Advanced route is one listen set:
// if a paste holds several site blocks on different custom ports, their routes
// all serve on the union of those ports — split them into separate Advanced
// routes to keep them apart.
func (s *Server) adaptRawRouteCaddyfile(caddyCl *caddy.Client, src string) (string, string, error) {
adapted, err := caddyCl.Adapt(s.withAutoLoadedSnippets(src))
if err != nil {
return "", "", err
}
adaptedRoutes := extractAdaptedServerRoutes(adapted.Result)
if len(adaptedRoutes) == 0 {
return "", "", fmt.Errorf("the Caddyfile adapted successfully but produced no HTTP routes — include at least one site block")
}
// v2.64.0 (discussion #137): an Advanced route stores routes only, so a
// `tls { client_auth ... }` block here would be dropped on the floor and
// the site would serve happily without ever asking for a certificate.
// Refuse the save and name the setting that works instead.
if snis := adaptedClientAuthSNIs(adapted.Result); len(snis) > 0 {
return "", "", fmt.Errorf("%w (affects %s). Caddy keeps client_auth in the server's TLS connection policies, not in the route, and an Advanced route stores routes only — so it would be discarded here and the site would accept any visitor. Configure it on the proxy host instead — Proxy Hosts → edit → Security → Client certificate authentication — and keep the rest of this block for the routing", errClientAuthInRawRoute, strings.Join(snis, ", "))
}
var addrs []string
routes := make([]map[string]any, 0, len(adaptedRoutes))
for _, ar := range adaptedRoutes {
routes = append(routes, ar.Route)
addrs = append(addrs, ar.Listen...)
}
listen := models.NormalizeRawRouteListen(addrs)
if len(routes) == 1 {
blob, err := json.Marshal(routes[0])
if err != nil {
return "", "", fmt.Errorf("serialize route: %w", err)
}
return string(blob), listen, nil
}
blob, err := json.Marshal(routes)
if err != nil {
return "", "", fmt.Errorf("serialize routes: %w", err)
}
return string(blob), listen, nil
}
// buildLayer4App adapts a server's pasted layer4 Caddyfile block (the
// CaddyServer.Layer4Caddyfile field, issue #113) through that server's own
// admin API and returns only the resulting apps.layer4 subtree — never any
// other key the adapt response might contain, even if the pasted block
// happens to produce one (see extractAdaptedLayer4App). caddyCl must already
// be the client for the server whose text is being adapted: sync callers
// pass the per-server-swapped s.Caddy, the save-time validator and
// cross-deploy both build a fresh client from the target server's own
// AdminURL/credentials.
//
// An empty caddyfileSrc (the default, and today's behavior for every
// existing server) returns (nil, nil) without making an admin-API call at
// all — no block configured, nothing to adapt. A non-2xx response from
// Caddy (unknown "layer4" directive because caddy-l4 isn't compiled in,
// Caddyfile syntax error, etc.) is returned as-is from caddy.Client.Adapt,
// which already includes Caddy's diagnostic text — callers surface this as a
// clear, actionable error rather than silently dropping the block.
func buildLayer4App(caddyCl *caddy.Client, caddyfileSrc string) (map[string]any, error) {
src := strings.TrimSpace(caddyfileSrc)
if src == "" {
return nil, nil
}
adapted, err := caddyCl.Adapt(src)
if err != nil {
return nil, err
}
return extractAdaptedLayer4App(adapted.Result), nil
}
// previewRawRouteValidate simulates syncCaddy with rr swapped into the raw_routes
// list (replacing the entry with the same ID, or appended if new) and calls
// Caddy's /load?validate_only=true. Returns a non-empty message only when Caddy
// would reject the resulting config — so callers can refuse to save instead of
// committing a change that breaks the live config on next sync.
func (s *Server) previewRawRouteValidate(serverID int64, rr *models.RawRoute) string {
proxies, redirs, raws, certs, ok := s.serverResources(serverID)
if !ok {
return ""
}
replaced := false
for i, existing := range raws {
if existing.ID == rr.ID && rr.ID != 0 {
raws[i] = *rr
replaced = true
break
}
}
if !replaced {
raws = append(raws, *rr)
}
return s.validateProposedConfig(serverID, proxies, redirs, raws, certs)
}
// validateProposedConfig builds the config a sync would push for these
// resources and asks Caddy to validate it. Returns "" when Caddy accepts it
// or cannot be reached — a save must not be blocked by an unrelated outage.
// v2.42.1 (issue #74): shared by the proxy host, redirection and certificate
// forms as well as Advanced routes.
func (s *Server) validateProposedConfig(serverID int64, proxies []models.ProxyHost, redirs []models.RedirectionHost, raws []models.RawRoute, certs []models.Certificate) string {
caddyCl := s.caddyForServer(serverID)
proxies = s.applyMiddlewareProfiles(proxies) // v2.60.0 (issue #124)
current, _, err := caddyCl.FetchConfig()
if err != nil {
return ""
}
proposed, err := deepCopyMap(current)
if err != nil {
return ""
}
previewRoutes := append(s.buildMergedRoutes(proxies, redirs, httpsRawRoutes(raws)), buildManagedCertificateRoutes(certs, s.fallbackRoute()["handle"].([]any))...)
previewRoutes = sortRoutesBySpecificity(previewRoutes) // v2.61.3 (issue #129): wildcard certificate routes after exact hosts
previewRoutes = s.withFallbackRoute(previewRoutes) // v2.59.4 (issue #123)
httpRoutes := s.buildHTTPRoutes(proxies, redirs, raws)
// issue #100: mirror the global blocklist prepend so preview validation
// matches what sync would push.
if gb := caddy.BuildGlobalBlocklistRoute(mustGetSetting(s.DB, settingGlobalIPBlocklist)); gb != nil {
previewRoutes = append([]any{gb}, previewRoutes...)
if len(httpRoutes) > 0 {
httpRoutes = append([]any{gb}, httpRoutes...)
}
}
applyRoutes(proposed, previewRoutes)
applyPlainHTTPServer(proposed, httpRoutes)
applyRawListenServers(proposed, s.buildRawListenServers(raws)) // v2.36.1 (issue #64)
loadPEM, loadFiles := buildCertLoaders(certs)
applyCertLoaders(proposed, loadPEM, loadFiles)
applySkipCertificates(proposed, buildSkipCertificates(proxies, redirs, httpsRawRoutes(raws), certs))
removeUnsupportedSkipRedirects(proposed)
applyDisableAutomaticHTTPSRedirects(proposed, len(httpRoutes) > 0)
applySkipAccessLogs(proposed, buildSkipAccessLogs(proxies))
previewPolicies := s.buildDNSAutomationPolicies(proxies, redirs, httpsRawRoutes(raws), certs)
previewPolicies = append(previewPolicies, buildInternalTLSAutomationPolicies(proxies)...) // v2.46.0
applyAutomationPolicies(proposed, previewPolicies)
// Mirror syncCaddy: preview-validation must match the config we'd push
// for real, otherwise a raw_route edit could validate clean here but
// fail with errors.routes rejection at sync time.
applyErrorPages(proposed)
if err := caddyCl.Validate(proposed); err != nil {
return "Caddy rejected the proposed config: " + friendlyCaddyError(err.Error())
}
return ""
}
func (s *Server) createRawRoute(w http.ResponseWriter, r *http.Request) {
rr, errMsg := s.parseRawRouteForm(r)
if errMsg != "" {
s.renderRawRouteFormError(w, r, rr, errMsg)
return
}
if errMsg := s.validateRawRouteForUser(s.currentUser(r), rr); errMsg != "" { // v2.57.1 — see tenant_guard.go
s.renderRawRouteFormError(w, r, rr, errMsg)
return
}
s.applyRawDNSFormSelection(rr)
if errMsg := s.previewRawRouteValidate(s.currentServerID(r), rr); errMsg != "" {
s.renderRawRouteFormError(w, r, rr, errMsg)
return
}
// v2.7.8: refuse to save a raw route whose first match.host doesn't live
// in the selected DNS zone. Same logic and rationale as the proxy-host
// path. Routes with no host matcher (path- or port-only) skip the check
// because there's no FQDN to validate against — those rows opt out of
// managed DNS regardless of what the form had selected.
if errMsg := validateZoneMatchesHostname(rr.DNSProvider, rr.DNSZoneID, rr.DNSZoneName, rawRouteHosts(*rr)); errMsg != "" {
s.renderRawRouteFormError(w, r, rr, errMsg)
return
}
if errMsg := s.validateManagedDNSRecordTarget(s.currentServerID(r), rr.DNSProvider, rr.DNSZoneID, rr.DNSSkipRecord); errMsg != "" && len(rawRouteHosts(*rr)) > 0 {
s.renderRawRouteFormError(w, r, rr, errMsg)
return
}
cu := s.currentUser(r)
var rrOwnerID int64
if cu != nil && cu.Role != models.RoleAdmin {
rrOwnerID = cu.ID
} else if cu != nil && cu.Role == models.RoleAdmin {
// v2.7.3: admin can assign this raw route to a specific user at create
// time. Same pattern as proxy/redirect handlers.
if v := strings.TrimSpace(r.FormValue("owner_id")); v != "" {
if parsed, err := strconv.ParseInt(v, 10, 64); err == nil && parsed >= 0 {
rrOwnerID = parsed
}
}
}
id, err := models.CreateRawRoute(s.DB, s.currentServerID(r), rrOwnerID, rr)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// v2.5.6: Managed DNS parity with proxy hosts — auto-create the A
// record when the user picked a provider + zone. No-op otherwise.
// Skipped for routes without a host matcher (no FQDN to target).
if rr.DNSProvider != "" && rr.DNSZoneID != "" && !rr.DNSSkipRecord && firstRawRouteHost(rr.JSONData) != "" {
s.dnsCreateRecordForRaw(s.currentServerID(r), id, rr)
}
_ = models.LogActivity(s.DB, s.currentServerID(r), s.currentUserEmail(r), "raw_create", fmt.Sprintf("raw:%d", id), rr.Label, true)
s.trySyncCaddy(s.currentServerID(r), rr.CertificateID != 0)
// v2.27.0 (issue #38): "Also deploy to" parity with proxy/redirect hosts.
// rr.ID must be set so upsertFleetRawRoute can key the fleet mapping off
// the source row. RawRoute carries no ServerID field — the source server
// is passed separately, as in the proxy/redirect cross-deploy paths.
var deployProblems []string
if deployTo := s.effectiveDeployTargets(s.currentServerID(r), parseDeployTo(r)); len(deployTo) > 0 {
rr.ID = id
deployProblems = s.crossDeployRawRoute(s.currentUserEmail(r), s.currentServerID(r), rr, deployTo)
}
// v2.5.5: park the user on the deploying checklist when the route has
// a host matcher we can probe. Path-only / port-only routes have no
// fqdn to verify, so we skip the page and bounce to the list like before.
if firstRawRouteHost(rr.JSONData) != "" && !rawRouteHTTPOnly(rr) {
http.Redirect(w, r, withDeployProblems(fmt.Sprintf("/raw-routes/%d/deploying", id), deployProblems), http.StatusSeeOther)
return
}
http.Redirect(w, r, withDeployProblems("/raw-routes", deployProblems), http.StatusSeeOther)
}
// renderRawRouteFormError re-renders the raw-route form with a validation or
// adapt error. rr may be nil when the form was missing required fields — in
// that case we reconstruct it from the raw form values so the user's input
// isn't wiped.
func (s *Server) renderRawRouteFormError(w http.ResponseWriter, r *http.Request, rr *models.RawRoute, errMsg string) {
certs, _ := s.certListForRequest(r)
if rr == nil {
certID, _ := strconv.ParseInt(r.FormValue("certificate_id"), 10, 64)
// Reconstruct DNS fields too so the picker state survives an error
// reload — same shape parseRawRouteForm would have produced.
provider := strings.ToLower(strings.TrimSpace(r.FormValue("dns_provider")))
zoneID := strings.TrimSpace(r.FormValue("dns_zone_id"))
zoneName := strings.TrimSpace(r.FormValue("dns_zone_name"))
if zoneName == "" {
zoneName = zoneID
}
rr = &models.RawRoute{
Label: r.FormValue("label"),
JSONData: r.FormValue("json_data"),
CaddyfileSrc: r.FormValue("caddyfile_src"),
Listen: models.NormalizeRawRouteListen(models.ParseRawRouteListenInput(r.FormValue("listen"))), // v2.36.1
Enabled: r.FormValue("enabled") == "on",
CertificateID: certID,
ForceSSL: r.FormValue("ssl_forced") == "on",
BlockCommonExploits: r.FormValue("block_common_exploits") == "on",
DNSProvider: provider,
DNSZoneID: zoneID,
DNSZoneName: zoneName,
DNSProfileID: strings.TrimSpace(r.FormValue("dns_profile_id")),
DNSSkipRecord: provider != "" && r.FormValue("dns_create_record") != "on",
}
s.applyRawDNSFormSelection(rr)
}
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if id != 0 {
rr.ID = id
}
s.render(w, r, "raw_route_form.html", s.applyDNSViewData(s.currentServerID(r), map[string]any{
"User": s.currentUser(r),
"Row": rr,
"Certificates": certs,
"Users": s.adminUserList(r),
"OtherServers": s.otherManagedServers(r),
"Error": errMsg,
"Section": "raw",
}))
}
func (s *Server) editRawRoute(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
rr, err := models.GetRawRoute(s.DB, id)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
cu := s.currentUser(r)
isAdmin := cu != nil && cu.Role == models.RoleAdmin
if !isAdmin {
if !s.canManageOwned(cu, rr.OwnerID) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
}
certs, _ := s.certListForRequest(r)
s.render(w, r, "raw_route_form.html", s.applyDNSViewData(s.currentServerID(r), map[string]any{
"User": s.currentUser(r),
"Row": rr,
"Certificates": certs,
"Users": s.adminUserList(r),
"OtherServers": s.otherManagedServers(r),
"Section": "raw",
}))
}
func (s *Server) updateRawRoute(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
cu := s.currentUser(r)
isAdmin := cu != nil && cu.Role == models.RoleAdmin
// Ownership check before parsing form
if !isAdmin {
existing, err := models.GetRawRoute(s.DB, id)
if err != nil || existing == nil || !s.canManageOwned(cu, existing.OwnerID) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
}
rr, errMsg := s.parseRawRouteForm(r)
if errMsg != "" {
s.renderRawRouteFormError(w, r, rr, errMsg)
return
}
rr.ID = id
if errMsg := s.validateRawRouteForUser(s.currentUser(r), rr); errMsg != "" { // v2.57.1 — see tenant_guard.go
s.renderRawRouteFormError(w, r, rr, errMsg)
return
}
s.applyRawDNSFormSelection(rr)
// v2.7.8: zone/hostname match — same as create path. Routes with no host
// matcher skip the check (rawRouteHosts returns nil → validator returns "").
if errMsg := validateZoneMatchesHostname(rr.DNSProvider, rr.DNSZoneID, rr.DNSZoneName, rawRouteHosts(*rr)); errMsg != "" {
s.renderRawRouteFormError(w, r, rr, errMsg)
return
}
if errMsg := s.validateManagedDNSRecordTarget(s.currentServerID(r), rr.DNSProvider, rr.DNSZoneID, rr.DNSSkipRecord); errMsg != "" && len(rawRouteHosts(*rr)) > 0 {
s.renderRawRouteFormError(w, r, rr, errMsg)
return
}
// Preserve the Caddyfile source on JSON-only edits: when the form didn't
// submit caddyfile_src (textarea was hidden because the row had none, or
// user cleared it), keep the existing snippet as long as the JSON matches
// — otherwise clear it so we never show a stale Caddyfile that no longer
// matches the committed JSON.
forceTLS := false
old, _ := models.GetRawRoute(s.DB, id)
if old != nil {
if rr.CaddyfileSrc == "" && old.CaddyfileSrc != "" && jsonEqual(old.JSONData, rr.JSONData) {
rr.CaddyfileSrc = old.CaddyfileSrc
}
forceTLS = old.CertificateID != rr.CertificateID
}
// v2.5.6: Managed DNS lifecycle. Same rules as proxy-host update —
// replace records when the provider, zone, or set of match[].host[]
// entries changes. v2.5.10: comparison widened from the first host
// to the full list so adding/removing a secondary hostname actually
// provisions/removes the matching A record.
var oldHosts []string
if old != nil {
oldHosts = rawRouteHosts(*old)
}
newHosts := rawRouteHosts(*rr)
fqdnChanged := !slices.Equal(oldHosts, newHosts)
newFQDN := ""
if len(newHosts) > 0 {
newFQDN = newHosts[0]
}
providerChanged := old != nil && old.DNSProvider != rr.DNSProvider
profileChanged := old != nil && old.DNSProfileID != rr.DNSProfileID
zoneChanged := old != nil && old.DNSZoneID != rr.DNSZoneID
recordModeChanged := old != nil && old.DNSSkipRecord != rr.DNSSkipRecord
needDelete := old != nil && old.DNSRecordID != "" &&
(rr.DNSProvider == "" || rr.DNSSkipRecord || providerChanged || profileChanged || zoneChanged || fqdnChanged || recordModeChanged)
if needDelete {
s.dnsDeleteRecord(old.DNSProvider, old.DNSProfileID, old.DNSZoneID, old.DNSZoneName, old.DNSRecordID)
rr.DNSRecordID = ""
} else if old != nil {
// Preserve existing record ID when nothing routing-relevant
// changed. The form doesn't resubmit record IDs, so without this
// the DB save would clear it.
rr.DNSRecordID = old.DNSRecordID
}
needCreate := rr.DNSProvider != "" && rr.DNSZoneID != "" && !rr.DNSSkipRecord && len(newHosts) > 0 &&
(rr.DNSRecordID == "" || providerChanged || profileChanged || zoneChanged || fqdnChanged || recordModeChanged)
if errMsg := s.previewRawRouteValidate(s.currentServerID(r), rr); errMsg != "" {
s.renderRawRouteFormError(w, r, rr, errMsg)
return
}
if err := models.UpdateRawRoute(s.DB, rr); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// v2.7.3: admin-only owner reassignment. Kept out of UpdateRawRoute so the
// user-role edit path can never touch ownership.
if isAdmin {
if v := strings.TrimSpace(r.FormValue("owner_id")); v != "" {
if parsed, err := strconv.ParseInt(v, 10, 64); err == nil && parsed >= 0 {
_ = models.SetRawRouteOwner(s.DB, rr.ID, parsed)
}
}
}
if needCreate {
s.dnsCreateRecordForRaw(s.currentServerID(r), rr.ID, rr)
}
_ = models.LogActivity(s.DB, s.currentServerID(r), s.currentUserEmail(r), "raw_update", fmt.Sprintf("raw:%d", id), rr.Label, true)
s.trySyncCaddy(s.currentServerID(r), forceTLS)
// v2.27.0 (issue #38): re-deploy the edited route to any selected targets.
var deployProblems []string
if deployTo := s.effectiveDeployTargets(s.currentServerID(r), parseDeployTo(r)); len(deployTo) > 0 {
deployProblems = s.crossDeployRawRoute(s.currentUserEmail(r), s.currentServerID(r), rr, deployTo)
}
// v2.5.5: show the deploying checklist on edits too — changing the
// host matcher or the backing service is the same "did it come back
// up on HTTPS?" question the create flow asks. Routes without a host
// matcher skip the page as in create.
if newFQDN != "" && !rawRouteHTTPOnly(rr) { // issue #121: an http:// route has no certificate to wait for
http.Redirect(w, r, withDeployProblems(fmt.Sprintf("/raw-routes/%d/deploying", id), deployProblems), http.StatusSeeOther)
return
}
http.Redirect(w, r, withDeployProblems("/raw-routes", deployProblems), http.StatusSeeOther)
}
// jsonEqual reports whether two JSON strings encode the same value (whitespace-insensitive).
func jsonEqual(a, b string) bool {
var av, bv any
if err := json.Unmarshal([]byte(a), &av); err != nil {
return false
}
if err := json.Unmarshal([]byte(b), &bv); err != nil {
return false
}
aj, _ := json.Marshal(av)
bj, _ := json.Marshal(bv)
return string(aj) == string(bj)
}
func (s *Server) deleteRawRoute(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
cu := s.currentUser(r)
isAdmin := cu != nil && cu.Role == models.RoleAdmin
old, _ := models.GetRawRoute(s.DB, id)
if !isAdmin {
if old == nil || !s.canManageOwned(cu, old.OwnerID) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
}
// v2.5.6: remove the managed DNS record before deleting the row so we
// don't orphan it at the provider. No-op when the route has none.
if old != nil && old.DNSRecordID != "" {
s.dnsDeleteRecord(old.DNSProvider, old.DNSProfileID, old.DNSZoneID, old.DNSZoneName, old.DNSRecordID)
}
note := s.noteFleetDeletion(models.FleetResourceRawRoute, id, "")
if old != nil {
note.label = old.Label
}
if err := models.DeleteRawRoute(s.DB, id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_ = models.LogActivity(s.DB, s.currentServerID(r), s.currentUserEmail(r), "raw_delete", fmt.Sprintf("raw:%d", id), "", true)
forceTLS := old != nil && old.CertificateID != 0
s.trySyncCaddyAfterDelete(s.currentServerID(r), forceTLS)
s.propagateNotedDeletion(s.currentUserEmail(r), note) // v2.59.0 (issue #120)
http.Redirect(w, r, "/raw-routes", http.StatusSeeOther)
}
// postReclassifyRawRoutes runs the same classifier the Caddyfile-import flow
// uses over every existing raw_route the caller is allowed to touch. Routes
// that the classifier now recognises as a simple proxy or redirect are
// converted: a new ProxyHost / RedirectionHost row is created, and the
// raw_route is deleted. Routes the classifier still can't simplify are left
// alone. Useful for cleaning up Advanced routes that pre-date v2.10.7.
// v2.10.9.
func (s *Server) postReclassifyRawRoutes(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
}
rows, err := models.ListRawRoutes(s.DB, s.currentServerID(r), viewerID, isAdmin, s.groupPeerIDs(r))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
var ownerID int64
if !isAdmin && cu != nil {
ownerID = cu.ID
}
var nProxy, nRedir, nKept int
for _, row := range rows {
// Edit-permission gate: non-admins only touch their own rows. The
// list query above already filters to visible rows, but visibility
// includes group peers — re-check ownership before mutating.
if !isAdmin {
if !s.canManageOwned(cu, row.OwnerID) {
continue
}
}
if row.JSONData == "" {
nKept++
continue
}
var route map[string]any
if err := json.Unmarshal([]byte(row.JSONData), &route); err != nil {
nKept++
continue
}
synth := map[string]any{
"apps": map[string]any{
"http": map[string]any{
"servers": map[string]any{
"_reclassify": map[string]any{
"routes": []any{route},
},
},
},
},
}
classified := caddy.ClassifyConfig(synth)
if len(classified.Proxies) == 1 {
ph := classified.Proxies[0]
ph.Enabled = row.Enabled
// GHSA-r4wm-rgc5-q834 (3rd finding) follow-up: ph.ForwardHost
// comes from the existing raw route's own JSON, which a non-admin
// could have pasted in before ever being classified as a proxy
// host — needs the same non-admin upstream guard as every other
// proxy-host creation path.
if msg := s.validateProxyUpstreamsForUser(cu, &ph); msg != "" {
nKept++
continue
}
if _, err := models.CreateProxyHost(s.DB, s.currentServerID(r), ownerID, &ph); err != nil {
nKept++
continue
}
_ = models.DeleteRawRoute(s.DB, row.ID)
nProxy++
continue
}
if len(classified.Redirect) == 1 {
rh := classified.Redirect[0]
rh.Enabled = row.Enabled
if _, err := models.CreateRedirectionHost(s.DB, s.currentServerID(r), ownerID, &rh); err != nil {
nKept++
continue
}
_ = models.DeleteRawRoute(s.DB, row.ID)
nRedir++
continue
}
nKept++
}
if nProxy > 0 || nRedir > 0 {
_ = s.syncCaddy(s.currentServerID(r), false)
}
_ = models.LogActivity(s.DB, s.currentServerID(r), s.currentUserEmail(r), "raw_reclassify", "",
fmt.Sprintf("proxies=%d redirects=%d kept=%d", nProxy, nRedir, nKept), true)
flash := url.QueryEscape(fmt.Sprintf("Re-classified %d → Proxy Hosts · %d → Redirections · %d kept as Advanced",
nProxy, nRedir, nKept))
http.Redirect(w, r, "/raw-routes?flash="+flash, http.StatusSeeOther)
}
// newCaddyClient builds a fresh caddy.Client from any server's AdminURL plus
// optional HTTP Basic Auth credentials. Credentials are forwarded on every
// admin call so setups that gate port 2019 behind a reverse-proxy + basic auth
// (a simpler alternative to WireGuard/Tailscale for remote admin) keep working.
func newCaddyClient(adminURL, username, password string) *caddy.Client {
return caddy.New(adminURL, username, password)
}
// SyncCaddy is the public entry-point used by external callers (e.g. /caddy/reload).
// It syncs the currently-selected server; serverID 1 is the safe default.
func (s *Server) SyncCaddy() error { return s.syncCaddy(1, false) }
// runAutoSyncLoop fires once per hour, checks the auto_sync_hours setting, and
// re-syncs all servers when the configured interval has elapsed since the last
// sync_applied activity log entry. Setting value 0 or empty = disabled.
func (s *Server) runAutoSyncLoop() {
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
for range ticker.C {
v, _ := models.GetSetting(s.DB, settingAutoSyncHours)
hours, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil || hours <= 0 {
continue // disabled
}
// Check when any server was last synced.
var lastSync time.Time
_ = s.DB.QueryRow(
`SELECT created_at FROM activity_log WHERE action = 'sync_applied' ORDER BY id DESC LIMIT 1`,
).Scan(&lastSync)
if time.Since(lastSync) < time.Duration(hours)*time.Hour {
continue // synced recently enough
}
// Re-sync all registered servers.
servers, err := models.ListCaddyServers(s.DB)
if err != nil {
log.Printf("auto-sync: list servers: %v", err)
continue
}
for _, srv := range servers {
if err := s.syncCaddy(srv.ID, false); err != nil {
log.Printf("auto-sync: server %d (%s): %v", srv.ID, srv.Name, err)
} else {
log.Printf("auto-sync: synced server %d (%s)", srv.ID, srv.Name)
}
}
}
}
// isMaintWindowDay reports whether t's day-of-week is in the comma-separated
// abbreviated day list (e.g. "mon,wed,fri"). An empty days string means every day.
func isMaintWindowDay(days string, t time.Time) bool {
if days == "" {
return true
}
abbr := strings.ToLower(t.Weekday().String()[:3])
for _, d := range strings.Split(strings.ToLower(days), ",") {
if strings.TrimSpace(d) == abbr {
return true
}
}
return false
}
// runMaintenanceWindowLoop wakes at every minute boundary and triggers a Caddy
// sync for any server whose proxy hosts have a scheduled maintenance window
// starting or ending at that exact minute. This keeps the scheduled state in
// sync without requiring continuous full re-syncs.
func (s *Server) runMaintenanceWindowLoop() {
for {
now := time.Now()
// Sleep until 2 seconds past the next minute boundary to avoid edge-case
// early fires when the goroutine starts right on the minute.
nextFire := now.Truncate(time.Minute).Add(time.Minute + 2*time.Second)
time.Sleep(time.Until(nextFire))
now = time.Now()
hhmm := now.Format("15:04")
hosts, err := models.ListProxyHostsWithMaintenanceWindow(s.DB)
if err != nil {
log.Printf("maintenance-window loop: list hosts: %v", err)
continue
}
serverSet := map[int64]bool{}
for _, h := range hosts {
if (h.MaintenanceWindowStart == hhmm || h.MaintenanceWindowEnd == hhmm) &&
isMaintWindowDay(h.MaintenanceWindowDays, now) {
serverSet[h.ServerID] = true
}
}
for srvID := range serverSet {
if err := s.syncCaddy(srvID, false); err != nil {
log.Printf("maintenance-window sync: server %d: %v", srvID, err)
} else {
log.Printf("maintenance-window sync: server %d at window boundary %s", srvID, hhmm)
}
}
}
}
// runActivityLogCleanup wakes once every 24 hours and purges activity_log rows
// older than the configured retention window (settingActivityLogDays). Disabled
// when the setting is 0 or empty.
func (s *Server) runActivityLogCleanup() {
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop()
// Run once on startup, then every 24 h.
s.pruneActivityLog()
for range ticker.C {
s.pruneActivityLog()
}
}
func (s *Server) pruneActivityLog() {
v, _ := models.GetSetting(s.DB, settingActivityLogDays)
days, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil || days <= 0 {
return // disabled
}
res, err := s.DB.Exec(
`DELETE FROM activity_log WHERE created_at < ?`,
time.Now().UTC().AddDate(0, 0, -days),
)
if err != nil {
log.Printf("activity log cleanup: %v", err)
return
}
n, _ := res.RowsAffected()
if n > 0 {
log.Printf("activity log cleanup: deleted %d entries older than %d days", n, days)
}
}
// syncCaddy applies CaddyUI's managed state to Caddy:
//
// 1. Reads the current live config and builds a "proposed" config with our routes,
// tls.certificates, and automatic_https.skip_certificates merged in.
// 2. Validates the proposed config via /load?validate_only=true. Nothing is applied
// if validation fails — Caddy's diagnostic is surfaced to the caller.
// 3. Snapshots the pre-change live config into config_snapshots (source='auto').
// 4. Writes the three subtrees (routes, tls, automatic_https) via POST /config/....
// POST is Caddy's set-or-replace semantic; it leaves admin, acme, email,
// and automation policies untouched.
// 5. Logs a row into activity_log with the outcome.
//
// syncCaddy pushes the current DB state to Caddy. If forceTLS is true, the tls
// and automatic_https subtrees are written unconditionally — used when a cert
// assignment changed, since the skip-when-unchanged optimization would otherwise
// mask the change from Caddy. Otherwise we skip tls/auto_https writes when
// effectively unchanged (avoids cancelling in-flight ACME challenges).
//
// trySyncCaddy is the fire-and-forget variant: calls syncCaddy and logs on
// error instead of returning it. v2.12.35: previously, ~18 handlers (proxy/
// redirect/cert/raw-route create/update/delete + AI tool calls + bulk
// actions) silently dropped the sync error. If Caddy rejected the new
// config (e.g. an unknown-field bug like the v2.12.20 `network` issue),
// the DB write succeeded but the live config never updated AND nothing
// was logged. Now every silent caller funnels through this helper so the
// failure at least lands in `docker logs caddyui`.
// trySyncCaddyAfterDelete is trySyncCaddy for the delete handlers: removing the
// last resource must clear it from the live Caddy too (v2.59.1, issue #120).
func (s *Server) trySyncCaddyAfterDelete(serverID int64, forceTLS bool) {
if err := s.syncCaddyOpts(serverID, forceTLS, true); err != nil {
log.Printf("trySyncCaddyAfterDelete(server=%d, forceTLS=%v): %v", serverID, forceTLS, err)
}
}
func (s *Server) trySyncCaddy(serverID int64, forceTLS bool) {
if err := s.syncCaddy(serverID, forceTLS); err != nil {
log.Printf("trySyncCaddy(server=%d, forceTLS=%v): %v", serverID, forceTLS, err)
}
}
// syncPrometheusMetricsOnly handles selected fleet members that do not yet
// have any CaddyUI-managed routes or certificates. The normal empty-database
// guard must still protect user routes, but metrics management should work on
// a fresh server without forcing the administrator to create a dummy host.
func (s *Server) syncPrometheusMetricsOnly(serverID int64, metricsCfg prometheusMetricsConfig) error {
current, currentJSON, err := s.Caddy.FetchConfig()
if err != nil {
return fmt.Errorf("fetch current config for metrics: %w", err)
}
proposed, err := deepCopyMap(current)
if err != nil {
return fmt.Errorf("clone config for metrics: %w", err)
}
applyPrometheusMetrics(proposed, metricsCfg, serverID)
if err := s.Caddy.Validate(proposed); err != nil {
return fmt.Errorf("caddy rejected Prometheus metrics config: %w", err)
}
if s.autoSnapshotsEnabled() && currentJSON != "" && currentJSON != "null" {
if _, err := models.CreateSnapshot(s.DB, serverID, models.SnapshotSourceAuto, "auto: before Prometheus metrics sync", currentJSON); err != nil {
log.Printf("metrics snapshot failed (non-fatal): %v", err)
}
}
if err := s.writePrometheusMetricsConfig(proposed, metricsCfg, serverID); err != nil {
_ = models.LogActivity(s.DB, serverID, "system", "sync_apply_metrics_failed", "", err.Error(), false)
return fmt.Errorf("apply Prometheus metrics: %w", err)
}
_ = models.LogActivity(s.DB, serverID, "system", "sync_metrics_applied", "", "metrics-only sync", true)
return nil
}
// syncLayer4Only handles fleet members with a layer4 Caddyfile block
// configured (issue #113) but no CaddyUI-managed routes or certificates yet.
// apps.layer4 is a standalone top-level app independent of apps.http.servers
// — like Prometheus metrics above — so it should work on a route-less server
// without forcing the administrator to create a dummy host first.
func (s *Server) syncLayer4Only(serverID int64, srv *models.CaddyServer, proxies []models.Layer4Proxy) error {
// v2.58.0 (issue #122): the raw Caddyfile block plus the managed Layer4
// proxy rows.
layer4App, err := layer4AppFor(s.Caddy, srv, proxies)
if err != nil {
return fmt.Errorf("layer4 Caddyfile: %w", err)
}
current, currentJSON, err := s.Caddy.FetchConfig()
if err != nil {
return fmt.Errorf("fetch current config for layer4: %w", err)
}
proposed, err := deepCopyMap(current)
if err != nil {
return fmt.Errorf("clone config for layer4: %w", err)
}
applyLayer4App(proposed, layer4App)
s.applyLayer4ListenerWrappers(proposed, serverID, proxies) // v2.61.0 (issue #126)
if err := s.Caddy.Validate(proposed); err != nil {
return fmt.Errorf("caddy rejected layer4 config: %w", err)
}
if s.autoSnapshotsEnabled() && currentJSON != "" && currentJSON != "null" {
if _, err := models.CreateSnapshot(s.DB, serverID, models.SnapshotSourceAuto, "auto: before layer4 sync", currentJSON); err != nil {
log.Printf("layer4 snapshot failed (non-fatal): %v", err)
}
}
if err := s.writeLayer4App(proposed); err != nil {
_ = models.LogActivity(s.DB, serverID, "system", "sync_apply_layer4_failed", "", err.Error(), false)
return fmt.Errorf("apply layer4 app: %w", err)
}
if err := s.writeLayer4ListenerWrappers(serverID, proxies); err != nil {
_ = models.LogActivity(s.DB, serverID, "system", "sync_apply_layer4_wrappers_failed", "", err.Error(), false)
return fmt.Errorf("apply layer4 listener wrappers: %w", err)
}
_ = models.LogActivity(s.DB, serverID, "system", "sync_layer4_applied", "", "layer4-only sync", true)
return nil
}
// syncCaddy pushes the DB state for serverID to its Caddy and records the
// outcome per server (v2.42.1, issue #74): a failure shows on every page
// until the next successful sync, so a rejected change can no longer fail
// silently in the log while the form says "saved".
func (s *Server) syncCaddy(serverID int64, forceTLS bool) error {
return s.syncCaddyOpts(serverID, forceTLS, false)
}
// syncCaddyOpts is syncCaddy with one extra switch. allowEmpty (v2.59.1, issue
// #120) lets a sync run when CaddyUI holds NO entries for the server: the
// normal "refusing to push empty routes" guard exists so a fresh or wiped
// database can never blank a live Caddy, but after an explicit deletion of the
// last resource the empty state is the intended one and must reach Caddy, or
// the deleted route would keep serving. Only deletion paths pass true.
func (s *Server) syncCaddyOpts(serverID int64, forceTLS, allowEmpty bool) error {
// Security/stability (v2.57.1): syncCaddyInner points the shared s.Caddy
// field at ONE server's admin API for its whole duration. Two syncs for
// different servers running at once overwrote each other's client, and a
// reproduction pushed server B's routes — with their certificate keys and
// basic-auth hashes — to server A (91 of 120 syncs in a stress run). Syncs
// are therefore serialized. Nothing reachable from a sync calls syncCaddy
// again, so this cannot deadlock.
s.syncMu.Lock()
defer s.syncMu.Unlock()
err := s.syncCaddyInner(serverID, forceTLS, allowEmpty)
if err == nil {
s.clearSyncError(serverID)
return nil
}
if s.syncHoldFor(serverID) == nil { // a post-apply hold has its own banner
name := ""
if srv, lookupErr := models.GetCaddyServer(s.DB, serverID); lookupErr == nil && srv != nil {
name = srv.Name
}
s.setSyncError(serverID, name, err)
}
return err
}
func (s *Server) syncCaddyInner(serverID int64, forceTLS, allowEmpty bool) error {
// Load the target server so we can use its AdminURL for the Caddy client.
srv, err := models.GetCaddyServer(s.DB, serverID)
if err != nil {
return fmt.Errorf("syncCaddy: unknown server %d: %w", serverID, err)
}
// External servers are read-only — skip push.
if srv.Type == models.CaddyServerTypeExternal {
log.Printf("caddy sync skipped: server %d (%s) is external", serverID, srv.Name)
return nil
}
// v2.38.0: while a post-apply check hold is set, automatic syncs are
// skipped so the failing state isn't pushed again. "Re-apply now" on the
// banner clears the hold first, then syncs.
if h := s.syncHoldFor(serverID); h != nil {
log.Printf("caddy sync skipped: server %d (%s) is held after failed post-apply checks — re-apply from the banner", serverID, srv.Name)
return fmt.Errorf("sync held for %s: post-apply checks failed at %s; re-apply or accept the rolled-back config from the banner", srv.Name, h.At.Format(time.RFC3339))
}
// Build a per-server caddy client and swap it in for the duration of this call.
// syncCaddy is called from HTTP handlers (single goroutine per request) so this
// temporary swap is safe as long as we don't sync the same server concurrently.
origClient := s.Caddy
s.Caddy = newCaddyClient(srv.AdminURL, srv.AdminUsername, srv.AdminPassword)
defer func() { s.Caddy = origClient }()
metricsCfg := loadPrometheusMetricsConfig(s.DB)
// Use admin view for sync — all routes must be pushed to Caddy regardless of owner.
proxies, err := models.ListProxyHosts(s.DB, serverID, 0, true, nil)
if err != nil {
return err
}
proxies = s.applyMiddlewareProfiles(proxies) // v2.60.0 (issue #124)
redirs, err := models.ListRedirectionHosts(s.DB, serverID, 0, true, nil)
if err != nil {
return err
}
raws, err := models.ListRawRoutes(s.DB, serverID, 0, true, nil)
if err != nil {
return err
}
certs, err := models.ListCertificates(s.DB, serverID)
if err != nil {
return err
}
layer4Proxies, err := models.ListLayer4Proxies(s.DB, serverID)
if err != nil {
return err
}
if !allowEmpty && len(proxies) == 0 && len(redirs) == 0 && len(raws) == 0 && len(certs) == 0 {
if metricsCfg.manages(serverID) {
return s.syncPrometheusMetricsOnly(serverID, metricsCfg)
}
// v2.56.0 (issue #113): apps.layer4 is independent of apps.http.servers,
// so a server dedicated to layer4 TCP/UDP routing with no CaddyUI-managed
// routes yet should still sync instead of being refused by the empty-
// routes guard below. (If a route-less server somehow has BOTH metrics
// and layer4 configured, the metrics-only branch above takes priority and
// layer4 is skipped this sync — an edge case of an edge case; it starts
// applying as soon as any proxy/redirect/raw-route/certificate exists.)
// v2.58.0 (issue #122): Layer4 proxy rows count too, and so does having
// had managed layer4 servers before — removing the LAST proxy on an
// otherwise empty server must still clear them from Caddy.
if strings.TrimSpace(srv.Layer4Caddyfile) != "" || len(layer4Proxies) > 0 || hasManagedLayer4Live(s.Caddy) {
return s.syncLayer4Only(serverID, srv, layer4Proxies)
}
log.Printf("caddy sync skipped: no entries in DB for server %d (refusing to push empty routes)", serverID)
return nil
}
// v2.12.14: prepend the global strip-response-headers list to each
// proxy host's per-host list so the eventual BuildProxyRoute emits a
// header-delete handler covering both. Mutating in-place is safe — the
// proxies slice is a fresh ListProxyHosts call, not a long-lived cache.
// v2.12.16: also populate GlobalStripHeaders so the SecurityHeaders
// bundle can filter against it (otherwise the bundle's `set` would
// fight the strip handler and X-Frame-Options would persist).
if globalStrip, _ := models.GetSetting(s.DB, settingGlobalStripResponseHeaders); strings.TrimSpace(globalStrip) != "" {
var stripList []string
for _, h := range strings.Split(globalStrip, ",") {
h = strings.TrimSpace(h)
if h != "" {
stripList = append(stripList, h)
}
}
for i := range proxies {
// The global list is applied ahead of the per-host one and
// deduplicated: a header named in both used to be emitted twice.
// Reads strip_resp_headers (canonical) rather than the retired
// strip_response_headers column — see MergeStripResponseHeaders.
proxies[i].StripRespHeaders = models.MergeHeaderNameLists(globalStrip, proxies[i].StripRespHeaders)
proxies[i].GlobalStripHeaders = stripList
}
}
accessLogCfg := loadFleetAccessLogConfig(s.DB)
crowdSecCfg := loadCrowdSecConfig(s.DB)
routes := append(s.buildMergedRoutes(proxies, redirs, httpsRawRoutes(raws)), buildManagedCertificateRoutes(certs, s.fallbackRoute()["handle"].([]any))...)
routes = sortRoutesBySpecificity(routes) // v2.61.3 (issue #129): wildcard certificate routes after exact hosts
routes = s.withFallbackRoute(routes) // v2.59.4 (issue #123)
httpRoutes := s.buildHTTPRoutes(proxies, redirs, raws)
routes = protectRoutesWithCrowdSec(routes, crowdSecCfg, serverID)
httpRoutes = protectRoutesWithCrowdSec(httpRoutes, crowdSecCfg, serverID)
// issue #100: fleet-wide IP blocklist — a top-level 403 route ahead of all
// host routing, on both the HTTPS and (when present) the plain :80 server.
if gb := caddy.BuildGlobalBlocklistRoute(mustGetSetting(s.DB, settingGlobalIPBlocklist)); gb != nil {
routes = append([]any{gb}, routes...)
if len(httpRoutes) > 0 {
httpRoutes = append([]any{gb}, httpRoutes...)
}
}
// v2.36.1 (issue #64): Advanced routes bound to their own port(s) become
// separate servers; give them the same CrowdSec protection as the rest.
rawListenServers := s.buildRawListenServers(raws)
for name, srv := range rawListenServers {
if routes, ok := srv["routes"].([]any); ok {
srv["routes"] = protectRoutesWithCrowdSec(routes, crowdSecCfg, serverID)
}
rawListenServers[name] = srv
}
loadPEM, loadFiles := buildCertLoaders(certs)
skipList := buildSkipCertificates(proxies, redirs, httpsRawRoutes(raws), certs)
skipAccessLogs := buildSkipAccessLogs(proxies)
// v2.9.0: per-SNI TLS connection policies — minimum version, and since
// v2.64.0 client certificate authentication. nil when no host needs
// either; writeTLSConnectionPoliciesSubtree handles the nil case by
// clearing stale policies that may exist.
tlsConnPolicies := caddy.BuildTLSConnectionPolicies(proxies)
// DNS-01 issuance policies plus (v2.46.0) internal-CA issuance policies,
// pushed together into apps.tls.automation.
tlsAutomationPolicies := s.buildDNSAutomationPolicies(proxies, redirs, httpsRawRoutes(raws), certs)
tlsAutomationPolicies = append(tlsAutomationPolicies, buildInternalTLSAutomationPolicies(proxies)...)
// v2.56.0 (issue #113): adapt the optional layer4 Caddyfile block through
// this server's own admin API before fetching/mutating its config, so a
// typo'd block (or a build missing caddy-l4) fails the sync up front with
// a clear error instead of after other subtrees have already been written.
layer4App, err := layer4AppFor(s.Caddy, srv, layer4Proxies)
if err != nil {
return fmt.Errorf("layer4 Caddyfile for %s: %w", srv.Name, err)
}
current, currentJSON, err := s.Caddy.FetchConfig()
if err != nil {
_ = models.LogActivity(s.DB, serverID, "system", "sync_fetch_failed", "", err.Error(), false)
return fmt.Errorf("fetch current config: %w", err)
}
proposed, err := deepCopyMap(current)
if err != nil {
return fmt.Errorf("clone config: %w", err)
}
applyRoutes(proposed, routes)
applyPlainHTTPServer(proposed, httpRoutes)
applyRawListenServers(proposed, rawListenServers)
applyListen(proposed)
applyProtocols(proposed, s.DB)
applyCertLoaders(proposed, loadPEM, loadFiles)
applySkipCertificates(proposed, skipList)
removeUnsupportedSkipRedirects(proposed)
applyDisableAutomaticHTTPSRedirects(proposed, len(httpRoutes) > 0)
applySkipAccessLogs(proposed, skipAccessLogs)
applyTLSConnectionPolicies(proposed, tlsConnPolicies)
applyAutomationPolicies(proposed, tlsAutomationPolicies)
applyClientIPSettings(proposed, s.DB)
applyFleetAccessLog(proposed, accessLogCfg, loadAnalyticsConfig(s.DB).Enabled, serverID)
applyPrometheusMetrics(proposed, metricsCfg, serverID)
applyCrowdSecApp(proposed, crowdSecCfg, serverID)
applyLayer4App(proposed, layer4App)
s.applyLayer4ListenerWrappers(proposed, serverID, layer4Proxies) // v2.61.0 (issue #126)
// v2.4.12: branded 404/502/503/504 pages with error ID + timestamp so
// users hitting a restart window see something nicer than Caddy's
// plaintext fallback and ops can correlate to access logs via {err.id}.
applyErrorPages(proposed)
// Validate before touching anything. Caddy runs full provisioning.
if err := s.Caddy.Validate(proposed); err != nil {
_ = models.LogActivity(s.DB, serverID, "system", "sync_validation_failed", "", err.Error(), false)
return fmt.Errorf("caddy rejected proposed config: %s", friendlyCaddyError(err.Error()))
}
// Snapshot current state so we can roll back if anything goes wrong later.
if s.autoSnapshotsEnabled() && currentJSON != "" && currentJSON != "null" {
note := fmt.Sprintf("auto: before sync — %d proxies, %d redirects, %d passthrough, %d certs",
len(proxies), len(redirs), len(raws), len(certs))
if _, err := models.CreateSnapshot(s.DB, serverID, models.SnapshotSourceAuto, note, currentJSON); err != nil {
log.Printf("snapshot failed (non-fatal): %v", err)
}
_ = models.PruneAutoSnapshots(s.DB, serverID, 20)
}
// Apply. Each subtree write is atomic in Caddy. CrowdSec's app must be
// provisioned before routes that reference its HTTP handler. When disabling,
// the inverse happens below: routes are cleared first, then the app.
crowdSecEnabled := crowdSecCfg.enabledFor(serverID)
if crowdSecEnabled {
if err := s.writeCrowdSecApp(proposed, true); err != nil {
_ = models.LogActivity(s.DB, serverID, "system", "sync_apply_crowdsec_failed", "", err.Error(), false)
return fmt.Errorf("apply CrowdSec app: %w", err)
}
}
if err := s.writeLoggingConfig(proposed); err != nil {
_ = models.LogActivity(s.DB, serverID, "system", "sync_apply_access_logger_failed", "", err.Error(), false)
return fmt.Errorf("apply access logger: %w", err)
}
if err := s.writePrometheusMetricsConfig(proposed, metricsCfg, serverID); err != nil {
_ = models.LogActivity(s.DB, serverID, "system", "sync_apply_metrics_failed", "", err.Error(), false)
return fmt.Errorf("apply Prometheus metrics: %w", err)
}
if err := s.writeLayer4App(proposed); err != nil {
_ = models.LogActivity(s.DB, serverID, "system", "sync_apply_layer4_failed", "", err.Error(), false)
return fmt.Errorf("apply layer4 app: %w", err)
}
if err := s.writeRoutesSubtree(routes); err != nil {
_ = models.LogActivity(s.DB, serverID, "system", "sync_apply_routes_failed", "", err.Error(), false)
return err
}
if err := s.writeListenSubtree(); err != nil {
_ = models.LogActivity(s.DB, serverID, "system", "sync_apply_listen_failed", "", err.Error(), false)
return err
}
if err := s.writeTLSSubtree(loadPEM, loadFiles, forceTLS); err != nil {
_ = models.LogActivity(s.DB, serverID, "system", "sync_apply_tls_failed", "", err.Error(), false)
return err
}
// Transition port 80 without ever provisioning two listeners at once.
// When enabling CaddyUI's HTTP server, suppress Caddy's generated redirect
// listener first. When removing it, delete ours before restoring automatic
// redirects.
if len(httpRoutes) > 0 {
if err := s.writeAutomaticHTTPSSubtree(skipList, true, forceTLS); err != nil {
_ = models.LogActivity(s.DB, serverID, "system", "sync_apply_autohttps_failed", "", err.Error(), false)
return err
}
if err := s.writePlainHTTPServerSubtree(httpRoutes); err != nil {
_ = models.LogActivity(s.DB, serverID, "system", "sync_apply_http_routes_failed", "", err.Error(), false)
return err
}
} else {
if err := s.writePlainHTTPServerSubtree(nil); err != nil {
_ = models.LogActivity(s.DB, serverID, "system", "sync_apply_http_routes_failed", "", err.Error(), false)
return err
}
if err := s.writeAutomaticHTTPSSubtree(skipList, false, forceTLS); err != nil {
_ = models.LogActivity(s.DB, serverID, "system", "sync_apply_autohttps_failed", "", err.Error(), false)
return err
}
}
if err := s.writeRawListenServersSubtree(rawListenServers); err != nil {
_ = models.LogActivity(s.DB, serverID, "system", "sync_apply_listen_servers_failed", "", err.Error(), false)
return err
}
// v2.61.0 (issue #126): layer4 listener wrappers go on the HTTP servers, so
// they are written once those servers exist.
if err := s.writeLayer4ListenerWrappers(serverID, layer4Proxies); err != nil {
_ = models.LogActivity(s.DB, serverID, "system", "sync_apply_layer4_wrappers_failed", "", err.Error(), false)
return fmt.Errorf("apply layer4 listener wrappers: %w", err)
}
if err := s.writeTLSConnectionPoliciesSubtree(tlsConnPolicies); err != nil {
// Non-fatal: log but don't abort — routes and certs are already applied.
// TLS version policy is a best-effort security enhancement; a sync
// failure here shouldn't roll back the primary route push.
log.Printf("caddy sync: tls_connection_policies write failed (non-fatal): %v", err)
_ = models.LogActivity(s.DB, serverID, "system", "sync_apply_tls_policies_failed", "", err.Error(), false)
}
if err := s.writeAccessLogsSubtree(skipAccessLogs); err != nil {
// Non-fatal: access log skip is a UX feature; a failure here
// shouldn't roll back the primary sync.
log.Printf("caddy sync: access_logs write failed (non-fatal): %v", err)
}
if err := s.writeProtocolsSubtree(s.DB); err != nil {
// Non-fatal: protocol restriction is a UX feature; failure here
// shouldn't roll back the primary sync.
log.Printf("caddy sync: protocols write failed (non-fatal): %v", err)
}
if err := s.writeFleetServerOptions(proposed); err != nil {
_ = models.LogActivity(s.DB, serverID, "system", "sync_apply_fleet_options_failed", "", err.Error(), false)
return fmt.Errorf("apply fleet server options: %w", err)
}
if !crowdSecEnabled {
if err := s.writeCrowdSecApp(proposed, false); err != nil {
_ = models.LogActivity(s.DB, serverID, "system", "sync_remove_crowdsec_failed", "", err.Error(), false)
return fmt.Errorf("remove CrowdSec app: %w", err)
}
}
// Hosts with Managed DNS selected use that provider for ACME DNS-01. The
// same policies were included in validation above, so a missing Caddy DNS
// module is reported before any subtrees are modified.
if len(tlsAutomationPolicies) > 0 {
if err := pushAutomationPoliciesVia(s.Caddy, tlsAutomationPolicies); err != nil {
_ = models.LogActivity(s.DB, serverID, "system", "sync_apply_automation_failed", "", err.Error(), false)
return fmt.Errorf("apply TLS automation policies: %w", err)
}
log.Printf("caddy sync: pushed %d TLS automation polic(ies)", len(tlsAutomationPolicies))
}
detail := fmt.Sprintf("proxies=%d redirects=%d passthrough=%d certs=%d",
len(proxies), len(redirs), len(raws), len(certs))
_ = models.LogActivity(s.DB, serverID, "system", "sync_applied", "", detail, true)
log.Printf("caddy synced server %d (%s): %s", serverID, srv.Name, detail)
// v2.38.0: post-apply expectations. `current` is the live config fetched
// before the apply; on failure it is loaded straight back.
if err := s.verifyAppliedConfig(serverID, srv.Name, s.Caddy, current); err != nil {
return err
}
return nil
}
// validateForwardAuthURL rejects a forward auth URL CaddyUI cannot turn into an
// auth subrequest (v2.59.5): it must be http:// or https:// with a host, e.g.
// http://authentik:9000/outpost.goauthentik.io/auth/caddy. Blank = forward auth off.
func validateForwardAuthURL(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
u, err := url.Parse(raw)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" {
return "Forward auth URL must be http:// or https:// followed by the auth service host, e.g. http://authentik:9000/outpost.goauthentik.io/auth/caddy"
}
return ""
}
// validateProxyAdvanced runs AdvancedConfig through Caddy's /adapt at save time
// so the user gets a form error instead of a silent sync failure later. Returns
// "" when empty or valid; otherwise a user-facing message.
// validateSSLFlags rejects the impossible state `ssl_forced=true` +
// `ssl_enabled=false` when there's no custom certificate attached. A custom
// cert (certID > 0) binds TLS for the host explicitly, so Force SSL is fine
// even with the "Auto SSL" checkbox unchecked.
func validateSSLFlags(enabled, forced bool, certID int64) string {
if forced && !enabled && certID == 0 {
return "Force SSL is on but SSL Enabled is off and no custom certificate is attached — pick a certificate, enable SSL, or turn off Force SSL."
}
return ""
}
func (s *Server) renderRedirectionHostFormError(w http.ResponseWriter, r *http.Request, rh *models.RedirectionHost, errMsg string) {
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": rh,
"Certificates": certs,
"Users": s.adminUserList(r),
"OtherServers": s.otherManagedServers(r),
"Error": errMsg,
"Section": "redirect",
}))
}
func (s *Server) validateProxyAdvanced(caddyCl *caddy.Client, p *models.ProxyHost) string {
if strings.TrimSpace(p.AdvancedConfig) == "" {
return ""
}
// v2.42.2: judge the repaired config, exactly what the adapter sees.
if errMsg := validateProxyAdvancedDirectives(normalizeProxyAdvancedConfig(p.AdvancedConfig)); errMsg != "" {
return errMsg
}
handlers, _, err := s.adaptProxyAdvancedWithClient(caddyCl, *p)
if err != nil {
return friendlyAdvancedRejection(err) // v2.42.2
}
// v2.57.1: for a non-admin's request (the client carries AdaptTextCheck),
// inspect what the text adapted to as well — a handler nested inside a block
// (file_server, an upstream aimed at the admin API, ...) is invisible to the
// top-level directive scan above. The sync-time guard would skip such a
// route silently; this gives the user the reason instead.
if caddyCl != nil && caddyCl.AdaptTextCheck != nil {
if msg := s.validateTenantRoute(map[string]any{"handle": handlers}); msg != "" {
return msg
}
}
return ""
}
func validateProxyAdvancedDirectives(src string) string {
// Directives that terminate a route — reverse_proxy ships the request, redir
// writes a 3xx, respond writes a fixed body, file_server serves from disk.
// The proxy host route ALWAYS ends with its own reverse_proxy, so allowing
// any of these as top-level directives here would splice a second terminal
// handler before it, silently breaking routing. Reject at save time rather
// than waiting for the sync to succeed with a broken result.
// v2.40.0: reverse_proxy is no longer banned — a `reverse_proxy { … }`
// block with no upstream is merged into the host's own handler (see
// proxy_advanced_overrides.go); one that names an upstream or sits
// behind a matcher is rejected after adapting.
banned := []string{"redir", "respond", "file_server"}
if bad := scanTopLevelDirective(src, banned); bad != "" {
return fmt.Sprintf("Advanced config can't contain `%s` — this field runs BEFORE the proxy's own reverse_proxy handler. Put request-side directives here (header, encode, request_body, rewrite, etc.) and let the Forward host/port handle the upstream.", bad)
}
return reverseProxySubdirectiveError(src)
}
// scanTopLevelDirective returns the first top-level (brace-depth 0) directive
// in src whose name matches any entry in banned, or "" if none. Used to reject
// terminal handlers in per-host AdvancedConfig before they reach Caddy's
// adapter, which would happily accept them and produce a broken config.
func scanTopLevelDirective(src string, banned []string) string {
depth := 0
for _, line := range strings.Split(src, "\n") {
trimmed := strings.TrimSpace(line)
if i := strings.Index(trimmed, "#"); i >= 0 {
trimmed = strings.TrimSpace(trimmed[:i])
}
if trimmed == "" {
continue
}
if depth == 0 {
first := strings.Fields(trimmed)[0]
for _, b := range banned {
if first == b {
return b
}
}
}
for _, ch := range trimmed {
switch ch {
case '{':
depth++
case '}':
depth--
}
}
}
return ""
}
func (s *Server) renderProxyHostFormError(w http.ResponseWriter, r *http.Request, p *models.ProxyHost, errMsg string) {
certs, _ := s.certListForRequest(r)
selectedProfile, _ := strconv.ParseInt(strings.TrimSpace(r.FormValue("middleware_profile_id")), 10, 64)
if r.Method != http.MethodPost && p != nil {
selectedProfile = models.ProxyHostProfileID(s.DB, p.ID)
}
s.render(w, r, "proxy_host_form.html", s.withProfileViewData(s.applyDNSViewData(s.currentServerID(r), map[string]any{
"User": s.currentUser(r),
"Host": p,
"Certificates": certs,
"Users": s.adminUserList(r),
"OtherServers": s.otherManagedServers(r),
"Error": errMsg,
"Guided": r.FormValue("guided") == "1",
"Section": "proxy",
}), selectedProfile))
}
// adaptProxyAdvanced converts a proxy host's per-host AdvancedConfig (a Caddyfile
// snippet containing request/response directives like `header`, `encode`,
// `request_body`) into the Caddy JSON handlers those directives expand to. The
// handlers are intended to run BEFORE the reverse_proxy handler, so directives
// like `request_body` or header-stripping take effect on the proxied request.
//
// We wrap the snippet in a synthetic site block `(localhost) { ... }` so Caddy's
// adapter has a valid site-address context. The adapter normally enforces
// directive order inside the site block, so we get a handle[] list in the
// correct order — we return that list untouched for the caller to splice in.
func (s *Server) adaptProxyAdvanced(p models.ProxyHost) ([]any, map[string]any, error) {
return s.adaptProxyAdvancedWithClient(s.Caddy, p)
}
// adaptProxyAdvancedWithClient adapts the Advanced config through Caddy and
// returns the handlers that run before the host's reverse_proxy plus, since
// v2.40.0, the fields of a `reverse_proxy { … }` block to merge into it.
func (s *Server) adaptProxyAdvancedWithClient(caddyCl *caddy.Client, p models.ProxyHost) ([]any, map[string]any, error) {
// v2.42.2: repairs applied here too, so configs saved before the
// repairs existed adapt on the next sync without being re-saved.
src := fmt.Sprintf("localhost {\n%s\n}\n", normalizeProxyAdvancedConfig(p.AdvancedConfig))
// Issue #119: `import <snippet>` must see the snippets from CADDYFILE_PATH,
// exactly as it does in Advanced routes.
adapted, err := caddyCl.Adapt(s.withAutoLoadedSnippets(src))
if err != nil {
return nil, nil, err
}
routes := extractAdaptedRoutes(adapted.Result)
if len(routes) == 0 {
return nil, nil, nil
}
handle, _ := routes[0]["handle"].([]any)
return extractReverseProxyOverrides(handle)
}