Ctrl K
ComunidadGuías…

internal/server/fleet_integrations.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// 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
}