// SPDX-License-Identifier: Apache-2.0 package server import ( "database/sql" "encoding/json" "fmt" "net/http" "path/filepath" "sort" "strconv" "strings" "time" "github.com/X4Applegate/caddyui/internal/caddy" "github.com/X4Applegate/caddyui/internal/models" ) const ( settingAccessLogEnabled = "fleet_access_log_enabled" settingAccessLogPath = "fleet_access_log_path" settingAccessLogFormat = "fleet_access_log_format" settingAccessLogScope = "fleet_access_log_scope" settingAccessLogRollSize = "fleet_access_log_roll_size_mb" settingAccessLogRollKeep = "fleet_access_log_roll_keep" settingAccessLogRollDays = "fleet_access_log_roll_keep_days" settingAccessLogServerIDs = "fleet_access_log_server_ids" settingClientIPHeaders = "client_ip_headers" settingCrowdSecEnabled = "crowdsec_enabled" settingCrowdSecAPIURL = "crowdsec_api_url" settingCrowdSecAPIKey = "crowdsec_api_key" settingCrowdSecStreaming = "crowdsec_streaming" settingCrowdSecTicker = "crowdsec_ticker_interval" settingCrowdSecHardFails = "crowdsec_hard_fails" settingCrowdSecServerIDs = "crowdsec_server_ids" settingCrowdSecExcludeHost = "crowdsec_excluded_hosts" settingCrowdSecExcludePath = "crowdsec_excluded_paths" settingMetricsEnabled = "prometheus_metrics_enabled" settingMetricsPerHost = "prometheus_metrics_per_host" settingMetricsCatchAll = "prometheus_metrics_observe_catchall_hosts" settingMetricsServerIDs = "prometheus_metrics_server_ids" fleetAccessLoggerName = "caddyui_file_access" ) type fleetAccessLogConfig struct { Enabled bool Path string Format string Scope string RollSizeMB int RollKeep int RollKeepDays int ServerIDs map[int64]bool } // prometheusMetricsConfig owns only the HTTP app's metrics enablement, // per_host, and observe_catchall_hosts fields on explicitly selected Caddy // servers. Unselected servers are deliberately untouched so a global metrics // block loaded from a Caddyfile or maintained through the admin API survives // every CaddyUI sync. type prometheusMetricsConfig struct { Enabled bool PerHost bool ObserveCatchAllHost bool ServerIDs map[int64]bool } func loadPrometheusMetricsConfig(db *sql.DB) prometheusMetricsConfig { return prometheusMetricsConfig{ Enabled: mustGetSetting(db, settingMetricsEnabled) == "1", PerHost: mustGetSetting(db, settingMetricsPerHost) == "1", ObserveCatchAllHost: mustGetSetting(db, settingMetricsCatchAll) == "1", ServerIDs: parseServerIDs(mustGetSetting(db, settingMetricsServerIDs)), } } func (c prometheusMetricsConfig) manages(serverID int64) bool { return c.ServerIDs[serverID] } func prometheusMetricsConfigFromForm(r *http.Request) (prometheusMetricsConfig, error) { cfg := prometheusMetricsConfig{ Enabled: formHasValue(r, "prometheus_metrics_enabled", "1"), PerHost: formHasValue(r, "prometheus_metrics_per_host", "1"), ObserveCatchAllHost: formHasValue(r, "prometheus_metrics_observe_catchall_hosts", "1"), ServerIDs: parseServerIDs(strings.Join(r.PostForm["prometheus_metrics_server_ids"], ",")), } return cfg, validatePrometheusMetricsConfig(cfg) } func validatePrometheusMetricsConfig(cfg prometheusMetricsConfig) error { if cfg.ObserveCatchAllHost && !cfg.PerHost { return fmt.Errorf("observe catch-all hosts requires per-host metrics") } if cfg.Enabled && len(cfg.ServerIDs) == 0 { return fmt.Errorf("select at least one Caddy server for Prometheus metrics") } return nil } func loadFleetAccessLogConfig(db *sql.DB) fleetAccessLogConfig { cfg := fleetAccessLogConfig{ Enabled: mustGetSetting(db, settingAccessLogEnabled) == "1", Path: strings.TrimSpace(mustGetSetting(db, settingAccessLogPath)), Format: strings.ToLower(strings.TrimSpace(mustGetSetting(db, settingAccessLogFormat))), Scope: strings.ToLower(strings.TrimSpace(mustGetSetting(db, settingAccessLogScope))), RollSizeMB: positiveIntSetting(db, settingAccessLogRollSize, 100), RollKeep: nonNegativeIntSetting(db, settingAccessLogRollKeep, 10), RollKeepDays: nonNegativeIntSetting(db, settingAccessLogRollDays, 90), ServerIDs: parseServerIDs(mustGetSetting(db, settingAccessLogServerIDs)), } if cfg.Path == "" { cfg.Path = "/var/log/caddy/access.log" } if cfg.Format != "console" { cfg.Format = "json" } if cfg.Scope != "http" && cfg.Scope != "https" { cfg.Scope = "all" } return cfg } func (c fleetAccessLogConfig) enabledFor(serverID int64) bool { return c.Enabled && c.ServerIDs[serverID] } func (c fleetAccessLogConfig) logger() map[string]any { return map[string]any{ "writer": map[string]any{ "output": "file", "filename": c.Path, "roll_size_mb": c.RollSizeMB, "roll_keep": c.RollKeep, "roll_keep_days": c.RollKeepDays, }, "encoder": map[string]any{"format": c.Format}, "include": []any{"http.log.access"}, } } type crowdSecConfig struct { Enabled bool APIURL string APIKey string Streaming bool Ticker string HardFails bool ServerIDs map[int64]bool ExcludedHosts map[string]bool ExcludedPaths []string } func loadCrowdSecConfig(db *sql.DB) crowdSecConfig { cfg := crowdSecConfig{ Enabled: mustGetSetting(db, settingCrowdSecEnabled) == "1", APIURL: strings.TrimSpace(mustGetSetting(db, settingCrowdSecAPIURL)), APIKey: strings.TrimSpace(mustGetSetting(db, settingCrowdSecAPIKey)), Streaming: mustGetSetting(db, settingCrowdSecStreaming) != "0", Ticker: strings.TrimSpace(mustGetSetting(db, settingCrowdSecTicker)), HardFails: mustGetSetting(db, settingCrowdSecHardFails) == "1", ServerIDs: parseServerIDs(mustGetSetting(db, settingCrowdSecServerIDs)), ExcludedHosts: parseHostSet(mustGetSetting(db, settingCrowdSecExcludeHost)), ExcludedPaths: parsePathList(mustGetSetting(db, settingCrowdSecExcludePath)), } if cfg.APIURL == "" { cfg.APIURL = "http://crowdsec:8080/" } if cfg.Ticker == "" { cfg.Ticker = "15s" } return cfg } func fleetAccessLogConfigFromForm(r *http.Request) (fleetAccessLogConfig, error) { rollSize, err := strconv.Atoi(strings.TrimSpace(r.FormValue("fleet_access_log_roll_size_mb"))) if err != nil { return fleetAccessLogConfig{}, fmt.Errorf("access log maximum file size must be a number") } rollKeep, err := strconv.Atoi(strings.TrimSpace(r.FormValue("fleet_access_log_roll_keep"))) if err != nil { return fleetAccessLogConfig{}, fmt.Errorf("access log retained-file count must be a number") } rollDays, err := strconv.Atoi(strings.TrimSpace(r.FormValue("fleet_access_log_roll_keep_days"))) if err != nil { return fleetAccessLogConfig{}, fmt.Errorf("access log retention period must be a number") } cfg := fleetAccessLogConfig{ Enabled: formHasValue(r, "fleet_access_log_enabled", "1"), Path: strings.TrimSpace(r.FormValue("fleet_access_log_path")), Format: strings.ToLower(strings.TrimSpace(r.FormValue("fleet_access_log_format"))), Scope: strings.ToLower(strings.TrimSpace(r.FormValue("fleet_access_log_scope"))), RollSizeMB: rollSize, RollKeep: rollKeep, RollKeepDays: rollDays, ServerIDs: parseServerIDs(strings.Join(r.PostForm["fleet_access_log_server_ids"], ",")), } if cfg.Format != "console" { cfg.Format = "json" } if cfg.Scope != "http" && cfg.Scope != "https" { cfg.Scope = "all" } return cfg, validateFleetAccessLogConfig(cfg) } func crowdSecConfigFromForm(r *http.Request, db *sql.DB) (crowdSecConfig, error) { key := strings.TrimSpace(r.FormValue("crowdsec_api_key")) if key == "" { key = strings.TrimSpace(mustGetSetting(db, settingCrowdSecAPIKey)) } cfg := crowdSecConfig{ Enabled: formHasValue(r, "crowdsec_enabled", "1"), APIURL: strings.TrimSpace(r.FormValue("crowdsec_api_url")), APIKey: key, Streaming: formHasValue(r, "crowdsec_streaming", "1"), Ticker: strings.TrimSpace(r.FormValue("crowdsec_ticker_interval")), HardFails: formHasValue(r, "crowdsec_hard_fails", "1"), ServerIDs: parseServerIDs(strings.Join(r.PostForm["crowdsec_server_ids"], ",")), ExcludedHosts: parseHostSet(r.FormValue("crowdsec_excluded_hosts")), ExcludedPaths: parsePathList(r.FormValue("crowdsec_excluded_paths")), } return cfg, validateCrowdSecConfig(cfg) } func formHasValue(r *http.Request, key, wanted string) bool { for _, value := range r.PostForm[key] { if value == wanted { return true } } return false } func fleetIntegrationSettings(access fleetAccessLogConfig, crowd crowdSecConfig, metrics prometheusMetricsConfig, clientIPHeaders string) map[string]string { boolString := func(v bool) string { if v { return "1" } return "0" } pathList := strings.Join(crowd.ExcludedPaths, "\n") hosts := make([]string, 0, len(crowd.ExcludedHosts)) for host := range crowd.ExcludedHosts { hosts = append(hosts, host) } sort.Strings(hosts) return map[string]string{ settingAccessLogEnabled: boolString(access.Enabled), settingAccessLogPath: access.Path, settingAccessLogFormat: access.Format, settingAccessLogScope: access.Scope, settingAccessLogRollSize: strconv.Itoa(access.RollSizeMB), settingAccessLogRollKeep: strconv.Itoa(access.RollKeep), settingAccessLogRollDays: strconv.Itoa(access.RollKeepDays), settingAccessLogServerIDs: serverIDsCSV(mapKeys(access.ServerIDs)), settingClientIPHeaders: strings.TrimSpace(clientIPHeaders), settingCrowdSecEnabled: boolString(crowd.Enabled), settingCrowdSecAPIURL: crowd.APIURL, settingCrowdSecStreaming: boolString(crowd.Streaming), settingCrowdSecTicker: crowd.Ticker, settingCrowdSecHardFails: boolString(crowd.HardFails), settingCrowdSecServerIDs: serverIDsCSV(mapKeys(crowd.ServerIDs)), settingCrowdSecExcludeHost: strings.Join(hosts, "\n"), settingCrowdSecExcludePath: pathList, settingMetricsEnabled: boolString(metrics.Enabled), settingMetricsPerHost: boolString(metrics.PerHost), settingMetricsCatchAll: boolString(metrics.ObserveCatchAllHost), settingMetricsServerIDs: serverIDsCSV(mapKeys(metrics.ServerIDs)), } } func prometheusMetricsConfigFingerprint(cfg prometheusMetricsConfig) string { return strings.Join([]string{ strconv.FormatBool(cfg.Enabled), strconv.FormatBool(cfg.PerHost), strconv.FormatBool(cfg.ObserveCatchAllHost), serverIDsCSV(mapKeys(cfg.ServerIDs)), }, "\x00") } func mapKeys(values map[int64]bool) []string { out := make([]string, 0, len(values)) for id, enabled := range values { if enabled { out = append(out, strconv.FormatInt(id, 10)) } } return out } func crowdSecConfigFingerprint(cfg crowdSecConfig) string { servers := serverIDsCSV(mapKeys(cfg.ServerIDs)) hosts := make([]string, 0, len(cfg.ExcludedHosts)) for host := range cfg.ExcludedHosts { hosts = append(hosts, host) } sort.Strings(hosts) return strings.Join([]string{ strconv.FormatBool(cfg.Enabled), cfg.APIURL, cfg.APIKey, strconv.FormatBool(cfg.Streaming), cfg.Ticker, strconv.FormatBool(cfg.HardFails), servers, strings.Join(hosts, ","), strings.Join(cfg.ExcludedPaths, ","), }, "\x00") } func (c crowdSecConfig) enabledFor(serverID int64) bool { return c.Enabled && c.ServerIDs[serverID] } func (c crowdSecConfig) app() map[string]any { return map[string]any{ "api_url": c.APIURL, "api_key": c.APIKey, "ticker_interval": c.Ticker, "enable_streaming": c.Streaming, "enable_hard_fails": c.HardFails, } } func positiveIntSetting(db *sql.DB, key string, fallback int) int { v, err := strconv.Atoi(strings.TrimSpace(mustGetSetting(db, key))) if err != nil || v <= 0 { return fallback } return v } func nonNegativeIntSetting(db *sql.DB, key string, fallback int) int { v, err := strconv.Atoi(strings.TrimSpace(mustGetSetting(db, key))) if err != nil || v < 0 { return fallback } return v } func parseServerIDs(raw string) map[int64]bool { out := map[int64]bool{} for _, field := range strings.FieldsFunc(raw, func(r rune) bool { return r == ',' || r == '\n' || r == ' ' }) { if id, err := strconv.ParseInt(strings.TrimSpace(field), 10, 64); err == nil && id > 0 { out[id] = true } } return out } func serverIDsCSV(values []string) string { set := parseServerIDs(strings.Join(values, ",")) ids := make([]int64, 0, len(set)) for id := range set { ids = append(ids, id) } sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) parts := make([]string, 0, len(ids)) for _, id := range ids { parts = append(parts, strconv.FormatInt(id, 10)) } return strings.Join(parts, ",") } func parseHostSet(raw string) map[string]bool { out := map[string]bool{} for _, field := range strings.FieldsFunc(raw, func(r rune) bool { return r == ',' || r == '\n' || r == ' ' }) { host := strings.ToLower(strings.TrimSpace(field)) if host != "" { out[host] = true } } return out } func parsePathList(raw string) []string { seen := map[string]bool{} var out []string for _, field := range strings.FieldsFunc(raw, func(r rune) bool { return r == ',' || r == '\n' }) { path := strings.TrimSpace(field) if path == "" { continue } if !strings.HasPrefix(path, "/") { path = "/" + path } if !seen[path] { seen[path] = true out = append(out, path) } } return out } func validateFleetAccessLogConfig(cfg fleetAccessLogConfig) error { if !cfg.Enabled { return nil } if len(cfg.ServerIDs) == 0 { return fmt.Errorf("select at least one Caddy server for file access logging") } if !filepath.IsAbs(cfg.Path) { return fmt.Errorf("access log path must be absolute") } if cfg.RollSizeMB <= 0 || cfg.RollSizeMB > 1_000_000 { return fmt.Errorf("access log maximum size must be between 1 and 1000000 MB") } if cfg.RollKeep < 0 || cfg.RollKeep > 100_000 || cfg.RollKeepDays < 0 || cfg.RollKeepDays > 36500 { return fmt.Errorf("access log retention values are outside the supported range") } return nil } func validateCrowdSecConfig(cfg crowdSecConfig) error { if !cfg.Enabled { return nil } if len(cfg.ServerIDs) == 0 { return fmt.Errorf("select at least one Caddy server for CrowdSec") } if cfg.APIKey == "" { return fmt.Errorf("CrowdSec bouncer API key is required") } if !strings.HasPrefix(cfg.APIURL, "http://") && !strings.HasPrefix(cfg.APIURL, "https://") { return fmt.Errorf("CrowdSec LAPI URL must begin with http:// or https://") } if d, err := time.ParseDuration(cfg.Ticker); err != nil || d <= 0 { return fmt.Errorf("CrowdSec decision refresh interval must be a positive duration such as 15s") } return nil } // applyPrometheusMetrics updates only the fields CaddyUI explicitly manages. // When the server is not selected, the complete live metrics object is left // alone. This is the important round-trip guarantee for issue #25: Caddyfile // global options and direct JSON/API configuration must not be downgraded by a // routine route sync. func applyPrometheusMetrics(cfg map[string]any, metricsCfg prometheusMetricsConfig, serverID int64) { if !metricsCfg.manages(serverID) { return } var apps, httpApp map[string]any if metricsCfg.Enabled { apps = ensureMap(cfg, "apps") httpApp = ensureMap(apps, "http") } else { // Disabling an already-absent feature must not synthesize an empty HTTP // app in the validation copy. apps, _ = cfg["apps"].(map[string]any) if apps == nil { return } httpApp, _ = apps["http"].(map[string]any) if httpApp == nil { return } } metrics, _ := httpApp["metrics"].(map[string]any) if metrics == nil { if !metricsCfg.Enabled { return } metrics = map[string]any{} } if metricsCfg.Enabled { if metricsCfg.PerHost { metrics["per_host"] = true } else { delete(metrics, "per_host") } if metricsCfg.ObserveCatchAllHost { metrics["observe_catchall_hosts"] = true } else { delete(metrics, "observe_catchall_hosts") } // An empty object is meaningful: it enables the base HTTP metrics. httpApp["metrics"] = metrics return } // Disabling removes only CaddyUI's two detailed-metrics options. Preserve // future/externally-owned fields such as otlp rather than replacing the // entire metrics object. delete(metrics, "per_host") delete(metrics, "observe_catchall_hosts") if len(metrics) == 0 { delete(httpApp, "metrics") } else { httpApp["metrics"] = metrics } } func (s *Server) writePrometheusMetricsConfig(proposed map[string]any, metricsCfg prometheusMetricsConfig, serverID int64) error { if !metricsCfg.manages(serverID) { return nil } var want any if apps, _ := proposed["apps"].(map[string]any); apps != nil { if httpApp, _ := apps["http"].(map[string]any); httpApp != nil { want = httpApp["metrics"] } } return s.writeOptionalConfigValue("/config/apps/http/metrics", want) } // validatePrometheusMetricsServers lets each selected Caddy validate its own // schema before settings are persisted. In particular, // observe_catchall_hosts requires Caddy 2.11+, while per_host works on older // releases. Using /load?validate_only=true also supports custom builds without // relying on a manually-entered version string. func (s *Server) validatePrometheusMetricsServers(metricsCfg prometheusMetricsConfig) error { if !metricsCfg.Enabled { return nil } servers, err := models.ListCaddyServers(s.DB) if err != nil { return fmt.Errorf("list Caddy servers: %w", err) } seen := map[int64]bool{} for _, srv := range servers { if !metricsCfg.manages(srv.ID) { continue } seen[srv.ID] = true if srv.Type == models.CaddyServerTypeExternal { return fmt.Errorf("Prometheus metrics cannot be managed on read-only server %q", srv.Name) } client := newCaddyClient(srv.AdminURL, srv.AdminUsername, srv.AdminPassword) current, _, err := client.FetchConfig() if err != nil { return fmt.Errorf("check metrics support on %s: %w", srv.Name, err) } probe, err := deepCopyMap(current) if err != nil { return fmt.Errorf("build metrics check for %s: %w", srv.Name, err) } applyPrometheusMetrics(probe, metricsCfg, srv.ID) if err := client.Validate(probe); err != nil { if metricsCfg.ObserveCatchAllHost { return fmt.Errorf("Caddy server %q rejected detailed metrics; observe_catchall_hosts requires Caddy 2.11 or newer: %w", srv.Name, err) } return fmt.Errorf("Caddy server %q rejected Prometheus metrics: %w", srv.Name, err) } } for id := range metricsCfg.ServerIDs { if !seen[id] { return fmt.Errorf("selected metrics server %d no longer exists", id) } } return nil } func prometheusScrapeTarget(adminURL string) string { adminURL = strings.TrimRight(strings.TrimSpace(adminURL), "/") if strings.HasPrefix(adminURL, "unix://") { return adminURL + " (/metrics)" } if adminURL == "" { return "/metrics" } return adminURL + "/metrics" } func applyClientIPSettings(cfg map[string]any, db *sql.DB) { apps := ensureMap(cfg, "apps") httpApp := ensureMap(apps, "http") servers := ensureMap(httpApp, "servers") rawProxies := strings.TrimSpace(mustGetSetting(db, settingTrustedProxies)) rawHeaders := strings.TrimSpace(mustGetSetting(db, settingClientIPHeaders)) var ranges []any for _, field := range strings.FieldsFunc(rawProxies, func(r rune) bool { return r == ',' || r == '\n' }) { if value := strings.TrimSpace(field); value != "" { ranges = append(ranges, value) } } var headers []any for _, field := range strings.FieldsFunc(rawHeaders, func(r rune) bool { return r == ',' || r == '\n' }) { if value := http.CanonicalHeaderKey(strings.TrimSpace(field)); value != "" { headers = append(headers, value) } } for _, name := range []string{"srv0", "caddyui_http"} { srv, ok := servers[name].(map[string]any) if !ok { continue } if len(ranges) > 0 { srv["trusted_proxies"] = map[string]any{"source": "static", "ranges": ranges} } else { delete(srv, "trusted_proxies") } if len(headers) > 0 { srv["client_ip_headers"] = headers } else { delete(srv, "client_ip_headers") } } } func applyFleetAccessLog(cfg map[string]any, access fleetAccessLogConfig, analyticsEnabled bool, serverID int64) { logging, _ := cfg["logging"].(map[string]any) if logging == nil { logging = map[string]any{} cfg["logging"] = logging } logs := ensureMap(logging, "logs") enabled := access.enabledFor(serverID) if enabled { logs[fleetAccessLoggerName] = access.logger() } else { delete(logs, fleetAccessLoggerName) } if len(logs) == 0 { delete(logging, "logs") } if len(logging) == 0 { delete(cfg, "logging") } apps := ensureMap(cfg, "apps") httpApp := ensureMap(apps, "http") servers := ensureMap(httpApp, "servers") for _, name := range []string{"srv0", "caddyui_http"} { srv, ok := servers[name].(map[string]any) if !ok { continue } fleetApplies := enabled && (access.Scope == "all" || (access.Scope == "https" && name == "srv0") || (access.Scope == "http" && name == "caddyui_http")) applies := analyticsEnabled || fleetApplies logsCfg, _ := srv["logs"].(map[string]any) if applies { if logsCfg == nil { logsCfg = map[string]any{} srv["logs"] = logsCfg } // v2.25.0/v2.25.1 selected a CaddyUI-owned logger as the HTTP // server default, which redirected access output away from the // user's console/file logger. Named include rules already tee the // event, so remove only our legacy override and preserve all others. if logsCfg["default_logger_name"] == fleetAccessLoggerName || logsCfg["default_logger_name"] == caddy.AccessLogLoggerName { delete(logsCfg, "default_logger_name") } continue } if logsCfg == nil { continue } if logsCfg["default_logger_name"] == fleetAccessLoggerName || logsCfg["default_logger_name"] == caddy.AccessLogLoggerName { delete(logsCfg, "default_logger_name") } if len(logsCfg) == 0 { delete(srv, "logs") } } } func applyCrowdSecApp(cfg map[string]any, crowd crowdSecConfig, serverID int64) { apps := ensureMap(cfg, "apps") if crowd.enabledFor(serverID) { apps["crowdsec"] = crowd.app() } else { delete(apps, "crowdsec") } } // applyLayer4App merges layer4App — the apps.layer4 subtree produced by // adapting a server's pasted layer4 Caddyfile block (see buildLayer4App and // extractAdaptedLayer4App) — into the proposed config. v2.56.0 (issue #113). // Mirrors applyCrowdSecApp: layer4 is a whole top-level app most syncs don't // have, injected only when configured and removed otherwise. Unlike the // CrowdSec fleet integration there's no separate "enabled for this serverID" // flag to consult — Layer4Caddyfile is a plain per-server field, so the // emptiness of layer4App itself (nil/empty when the field is "" or the // adapt response had no apps.layer4 key) is the on/off switch. func applyLayer4App(cfg map[string]any, layer4App map[string]any) { apps := ensureMap(cfg, "apps") if len(layer4App) > 0 { apps["layer4"] = layer4App } else { delete(apps, "layer4") } } func protectRoutesWithCrowdSec(routes []any, crowd crowdSecConfig, serverID int64) []any { if !crowd.enabledFor(serverID) { return routes } out := make([]any, 0, len(routes)) for _, raw := range routes { route, ok := raw.(map[string]any) if !ok || crowdSecRouteExcluded(route, crowd.ExcludedHosts) { out = append(out, raw) continue } handlers, _ := route["handle"].([]any) guard := any(map[string]any{"handler": "crowdsec"}) if len(crowd.ExcludedPaths) > 0 { paths := make([]any, 0, len(crowd.ExcludedPaths)) for _, path := range crowd.ExcludedPaths { paths = append(paths, path) } guard = map[string]any{ "handler": "subroute", "routes": []any{ map[string]any{"match": []any{map[string]any{"path": paths}}, "terminal": true}, map[string]any{"handle": []any{map[string]any{"handler": "crowdsec"}}}, }, } } route["handle"] = append([]any{guard}, handlers...) out = append(out, route) } return out } func crowdSecRouteExcluded(route map[string]any, excluded map[string]bool) bool { if len(excluded) == 0 { return false } matches, _ := route["match"].([]any) for _, rawMatch := range matches { match, _ := rawMatch.(map[string]any) hosts, _ := match["host"].([]any) for _, rawHost := range hosts { if host, ok := rawHost.(string); ok && excluded[strings.ToLower(strings.TrimSpace(host))] { return true } } } return false } func (s *Server) writeLoggingConfig(proposed map[string]any) error { want, _ := proposed["logging"].(map[string]any) existing, err := s.Caddy.FetchPath("/config/logging") if err != nil { return err } if len(want) == 0 { if existing == nil { return nil } return s.Caddy.DeletePath("/logging") } if configValuesEqual(existing, want) { return nil } return s.Caddy.PutPath("/config/logging", want) } func (s *Server) writeCrowdSecApp(proposed map[string]any, enabled bool) error { apps, _ := proposed["apps"].(map[string]any) want, _ := apps["crowdsec"].(map[string]any) existing, err := s.Caddy.FetchPath("/config/apps/crowdsec") if err != nil { return err } if !enabled || len(want) == 0 { if existing == nil { return nil } return s.Caddy.DeletePath("/apps/crowdsec") } if configValuesEqual(existing, want) { return nil } return s.Caddy.PutPath("/config/apps/crowdsec", want) } // writeLayer4App pushes or removes the apps.layer4 subtree populated by // applyLayer4App. v2.56.0 (issue #113). Mirrors writeCrowdSecApp: layer4 is a // whole top-level app CaddyUI now owns once a server's Layer4Caddyfile is // non-empty, so clearing the field must remove apps.layer4 from the live // config on the next sync, not just stop adding to it. func (s *Server) writeLayer4App(proposed map[string]any) error { apps, _ := proposed["apps"].(map[string]any) want, _ := apps["layer4"].(map[string]any) existing, err := s.Caddy.FetchPath("/config/apps/layer4") if err != nil { return err } if len(want) == 0 { if existing == nil { return nil } return s.Caddy.DeletePath("/apps/layer4") } if configValuesEqual(existing, want) { return nil } return s.Caddy.PutPath("/config/apps/layer4", want) } // writeFleetServerOptions applies the small server-level fields managed by the // fleet integration cards without replacing the complete server object. That // preserves listeners, TLS policies, routes, and any options owned outside // CaddyUI while ensuring HTTP/HTTPS log and real-client-IP settings stay in // sync on both generated servers. func (s *Server) writeFleetServerOptions(proposed map[string]any) error { apps, _ := proposed["apps"].(map[string]any) httpApp, _ := apps["http"].(map[string]any) servers, _ := httpApp["servers"].(map[string]any) for _, name := range []string{"srv0", "caddyui_http"} { srv, exists := servers[name].(map[string]any) if !exists { continue } for _, key := range []string{"logs", "trusted_proxies", "client_ip_headers"} { path := "/config/apps/http/servers/" + name + "/" + key if err := s.writeOptionalConfigValue(path, srv[key]); err != nil { return fmt.Errorf("write %s.%s: %w", name, key, err) } } } return nil } func (s *Server) writeOptionalConfigValue(path string, want any) error { existing, err := s.Caddy.FetchPath(path) if err != nil { return err } if want == nil { if existing == nil { return nil } return s.Caddy.DeletePath(strings.TrimPrefix(path, "/config")) } if existing == nil { return s.Caddy.PutPath(path, want) } if configValuesEqual(existing, want) { return nil } return s.Caddy.PatchPath(path, want) } func configValuesEqual(a, b any) bool { aJSON, errA := json.Marshal(a) bJSON, errB := json.Marshal(b) return errA == nil && errB == nil && string(aJSON) == string(bJSON) } func (s *Server) validateCrowdSecServers(crowd crowdSecConfig) error { if !crowd.Enabled { return nil } servers, err := models.ListCaddyServers(s.DB) if err != nil { return fmt.Errorf("list Caddy servers: %w", err) } seen := map[int64]bool{} for _, srv := range servers { if !crowd.ServerIDs[srv.ID] { continue } seen[srv.ID] = true if srv.Type == models.CaddyServerTypeExternal { return fmt.Errorf("CrowdSec cannot be enabled on read-only server %q", srv.Name) } client := newCaddyClient(srv.AdminURL, srv.AdminUsername, srv.AdminPassword) current, _, err := client.FetchConfig() if err != nil { return fmt.Errorf("check CrowdSec modules on %s: %w", srv.Name, err) } probe, err := deepCopyMap(current) if err != nil { return fmt.Errorf("build CrowdSec module check for %s: %w", srv.Name, err) } apps := ensureMap(probe, "apps") apps["crowdsec"] = crowd.app() httpApp := ensureMap(apps, "http") httpServers := ensureMap(httpApp, "servers") httpServers["caddyui_crowdsec_probe"] = map[string]any{ "listen": []any{"127.0.0.1:0"}, "routes": []any{map[string]any{ "handle": []any{map[string]any{"handler": "crowdsec"}}, }}, } if err := client.Validate(probe); err != nil { return fmt.Errorf("Caddy server %q does not accept the CrowdSec app and http.handlers.crowdsec modules: %w", srv.Name, err) } } for id := range crowd.ServerIDs { if !seen[id] { return fmt.Errorf("selected CrowdSec server %d no longer exists", id) } } return nil } func (s *Server) postTestCrowdSec(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() serverID, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("server_id")), 10, 64) if err != nil || serverID <= 0 { http.Error(w, "invalid Caddy server", http.StatusBadRequest) return } crowd := loadCrowdSecConfig(s.DB) if !crowd.enabledFor(serverID) { http.Error(w, "CrowdSec is not enabled for that server", http.StatusBadRequest) return } srv, err := models.GetCaddyServer(s.DB, serverID) if err != nil || srv.Type == models.CaddyServerTypeExternal { http.Error(w, "Caddy server is unavailable or read-only", http.StatusBadRequest) return } client := newCaddyClient(srv.AdminURL, srv.AdminUsername, srv.AdminPassword) var response struct { OK bool `json:"Ok"` } if err := client.PostAdminJSON("/crowdsec/ping", map[string]any{}, &response); err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadGateway) _, _ = w.Write([]byte(`{"ok":false,"error":` + strconv.Quote(err.Error()) + `}`)) return } w.Header().Set("Content-Type", "application/json") if !response.OK { w.WriteHeader(http.StatusBadGateway) _, _ = w.Write([]byte(`{"ok":false,"error":"CrowdSec LAPI did not respond successfully"}`)) return } _, _ = w.Write([]byte(`{"ok":true}`)) } func integrationServerSelection(servers []models.CaddyServer, selected map[int64]bool) map[int64]bool { out := map[int64]bool{} for _, srv := range servers { out[srv.ID] = selected[srv.ID] } return out }