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