From 1e0b5941ab53ef064700d8aebe62f0e46b5cca54 Mon Sep 17 00:00:00 2001 From: smoochy Date: Sat, 28 Mar 2026 18:06:11 +0100 Subject: [PATCH 1/2] Fix blank bouncers page after client-side navigation --- web/src/pages/Bouncers.tsx | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/web/src/pages/Bouncers.tsx b/web/src/pages/Bouncers.tsx index 9f141d4..83436f4 100644 --- a/web/src/pages/Bouncers.tsx +++ b/web/src/pages/Bouncers.tsx @@ -38,6 +38,19 @@ import { toast } from 'sonner' import { EmptyState, PageHeader, QueryError, ResultsSummary } from '@/components/common' import { useUrlFilters } from '@/hooks' +function normalizeBouncers(raw: unknown): Bouncer[] { + if (Array.isArray(raw)) return raw as Bouncer[] + + if (raw && typeof raw === 'object') { + const obj = raw as Record + for (const value of Object.values(obj)) { + if (Array.isArray(value)) return value as Bouncer[] + } + } + + return [] +} + export default function Bouncers() { const queryClient = useQueryClient() const [urlFilters, setUrlFilter] = useUrlFilters(['q'], { q: '' }) @@ -50,18 +63,11 @@ export default function Bouncers() { queryKey: ['bouncers'], queryFn: async () => { const response = await api.crowdsec.getBouncers() - const raw = response.data.data - // Adapt to whatever shape the backend returns - if (Array.isArray(raw)) return raw as Bouncer[] - if (raw && typeof raw === 'object') { - // Backend wraps as { bouncers: [...], count: N } - const obj = raw as Record - for (const val of Object.values(obj)) { - if (Array.isArray(val)) return val as Bouncer[] - } - } - return [] as Bouncer[] + return response.data.data }, + // The dashboard populates this cache key with the raw { bouncers, count } + // payload shape, so normalize it per observer on the Bouncers page. + select: normalizeBouncers, }) const addBouncerMutation = useMutation({ From 407b581295581f692656c4e0433580277bf4183e Mon Sep 17 00:00:00 2001 From: smoochy Date: Sat, 28 Mar 2026 20:33:41 +0100 Subject: [PATCH 2/2] Support file and directory Traefik dynamic config paths --- README.md | 4 +- docker-compose.pangolin.yml | 2 +- docker-compose.yml | 2 +- .../docs/configuration/environment.mdx | 3 +- docs/content/docs/configuration/settings.mdx | 5 +- docs/content/docs/features/captcha.mdx | 2 +- docs/content/docs/installation.mdx | 4 +- docs/content/docs/quick-start.mdx | 4 +- docs/src/app/(home)/page.tsx | 2 +- internal/api/handlers/captcha.go | 13 +- internal/api/handlers/captcha_config.go | 5 +- internal/api/handlers/captcha_detect.go | 15 +- internal/api/handlers/captcha_profiles.go | 6 +- internal/api/handlers/captcha_setup.go | 74 +-- internal/api/handlers/captcha_setup_test.go | 123 +++++ internal/api/handlers/health_diagnostics.go | 28 +- internal/api/handlers/ip.go | 14 +- internal/api/handlers/services.go | 28 +- internal/api/handlers/services_config.go | 51 ++- internal/api/handlers/whitelist.go | 40 +- internal/api/handlers/whitelist_ops.go | 46 +- internal/config/config.go | 2 + internal/config/traefik_dynamic_config.go | 53 +++ .../config/traefik_dynamic_config_test.go | 77 ++++ internal/configvalidator/validator.go | 44 +- .../traefikconfig/traefik_dynamic_config.go | 423 ++++++++++++++++++ .../traefik_dynamic_config_test.go | 134 ++++++ web/src/lib/api/errors.ts | 2 +- web/src/pages/Configuration.tsx | 13 +- 29 files changed, 1040 insertions(+), 179 deletions(-) create mode 100644 internal/api/handlers/captcha_setup_test.go create mode 100644 internal/config/traefik_dynamic_config.go create mode 100644 internal/config/traefik_dynamic_config_test.go create mode 100644 internal/traefikconfig/traefik_dynamic_config.go create mode 100644 internal/traefikconfig/traefik_dynamic_config_test.go diff --git a/README.md b/README.md index cc51f38..6d25016 100644 --- a/README.md +++ b/README.md @@ -147,7 +147,7 @@ services: # Core Configuration - PORT=8080 - ENVIRONMENT=production - - TRAEFIK_DYNAMIC_CONFIG=/etc/traefik/dynamic_config.yml + - TRAEFIK_DYNAMIC_CONFIG=/etc/traefik/rules - TRAEFIK_CONTAINER_NAME=traefik - TRAEFIK_STATIC_CONFIG=/etc/traefik/traefik_config.yml - CROWDSEC_METRICS_URL=http://crowdsec:6060/metrics @@ -166,6 +166,8 @@ volumes: tailscale-data: ``` +`TRAEFIK_DYNAMIC_CONFIG` can point to either a single YAML file such as `/etc/traefik/dynamic_config.yml` or a directory of fragments such as `/etc/traefik/rules`. When a directory is used, CrowdSec Manager writes its own overlay to `crowdsec-manager.yml` and leaves shared files like `base.yml` untouched. + ## Run ```bash diff --git a/docker-compose.pangolin.yml b/docker-compose.pangolin.yml index 81abea8..343905d 100644 --- a/docker-compose.pangolin.yml +++ b/docker-compose.pangolin.yml @@ -130,7 +130,7 @@ services: - PANGOLIN_DIR=/app - CONFIG_DIR=/app/config - DATABASE_PATH=/app/data/settings.db - - TRAEFIK_DYNAMIC_CONFIG=/rules/dynamic_config.yml + - TRAEFIK_DYNAMIC_CONFIG=/rules - TRAEFIK_STATIC_CONFIG=/etc/traefik/traefik_config.yml - TRAEFIK_ACCESS_LOG=/var/log/traefik/access.log - TRAEFIK_ERROR_LOG=/var/log/traefik/traefik.log diff --git a/docker-compose.yml b/docker-compose.yml index 3df6e32..f93381f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,7 +21,7 @@ services: - CONFIG_DIR=/app/config - DATABASE_PATH=/app/data/settings.db # Traefik Configuration Paths - - TRAEFIK_DYNAMIC_CONFIG=/etc/traefik/dynamic_config.yml + - TRAEFIK_DYNAMIC_CONFIG=/etc/traefik/rules - TRAEFIK_STATIC_CONFIG=/etc/traefik/traefik_config.yml - TRAEFIK_ACCESS_LOG=/var/log/traefik/access.log - TRAEFIK_ERROR_LOG=/var/log/traefik/traefik.log diff --git a/docs/content/docs/configuration/environment.mdx b/docs/content/docs/configuration/environment.mdx index cee83e7..31bf481 100644 --- a/docs/content/docs/configuration/environment.mdx +++ b/docs/content/docs/configuration/environment.mdx @@ -13,7 +13,7 @@ The minimum deployment needs only a small environment set. Add more variables on | :--- | :--- | :--- | | `PORT` | `8080` | HTTP port inside the container. | | `ENVIRONMENT` | `production` | Runtime mode. | -| `TRAEFIK_DYNAMIC_CONFIG` | `/etc/traefik/dynamic_config.yml` | Traefik dynamic config path used by manager actions. | +| `TRAEFIK_DYNAMIC_CONFIG` | `/etc/traefik/dynamic_config.yml` | Traefik dynamic config file or directory path used by manager actions. | | `TRAEFIK_CONTAINER_NAME` | `traefik` | Traefik container name in your stack. | | `TRAEFIK_STATIC_CONFIG` | `/etc/traefik/traefik_config.yml` | Traefik static config path. | @@ -29,5 +29,6 @@ The minimum deployment needs only a small environment set. Add more variables on ## Notes - Keep environment values as in-container paths. +- `TRAEFIK_DYNAMIC_CONFIG` can point to a directory such as `/etc/traefik/rules`; CrowdSec Manager will then manage `crowdsec-manager.yml` inside that directory. - Use Docker volume mappings to map host paths to those container paths. - Multi-proxy support is not available in this release. diff --git a/docs/content/docs/configuration/settings.mdx b/docs/content/docs/configuration/settings.mdx index 2ed7e34..0d8bddb 100644 --- a/docs/content/docs/configuration/settings.mdx +++ b/docs/content/docs/configuration/settings.mdx @@ -11,9 +11,10 @@ The **Settings** page (labeled Configuration in the UI) allows you to manage cri ## Traefik Configuration Path -The most important setting is the **Traefik Dynamic Configuration Path**. This tells CrowdSec Manager where to find the `dynamic_config.yml` file inside the Traefik container. +The most important setting is the **Traefik Dynamic Configuration Path**. This tells CrowdSec Manager where to find the Traefik dynamic config inside the Traefik container. - **Default Path**: `/etc/traefik/dynamic_config.yml` +- **Common Directory Path**: `/etc/traefik/rules` ### Why is this important? @@ -24,5 +25,5 @@ CrowdSec Manager uses this path to: - Check middleware configurations. - If your Traefik setup uses a different path or filename for its dynamic configuration, you **must** update it here. Otherwise, features like Captcha and Whitelists will not function correctly. + If your Traefik setup uses a different path, filename, or a directory of YAML fragments for its dynamic configuration, you **must** update it here. Otherwise, features like Captcha and Whitelists will not function correctly. In directory mode, CrowdSec Manager writes to `crowdsec-manager.yml` and does not modify shared files such as `base.yml`. diff --git a/docs/content/docs/features/captcha.mdx b/docs/content/docs/features/captcha.mdx index 43bf421..42926a9 100644 --- a/docs/content/docs/features/captcha.mdx +++ b/docs/content/docs/features/captcha.mdx @@ -36,7 +36,7 @@ The dashboard provides real-time feedback on your captcha configuration: Enter the **Site Key** (public) and **Secret Key** (private) obtained from your provider's dashboard. ### Apply Configuration - Click **Configure Captcha**. This will update the `dynamic_config.yml` file and ensure the `captcha.html` template is present. + Click **Configure Captcha**. This will update the configured Traefik dynamic config path and ensure the `captcha.html` template is present. If the path is a directory, CrowdSec Manager writes to `crowdsec-manager.yml`. diff --git a/docs/content/docs/installation.mdx b/docs/content/docs/installation.mdx index bc45830..e6281f9 100644 --- a/docs/content/docs/installation.mdx +++ b/docs/content/docs/installation.mdx @@ -35,7 +35,7 @@ services: environment: - PORT=8080 - ENVIRONMENT=production - - TRAEFIK_DYNAMIC_CONFIG=/etc/traefik/dynamic_config.yml + - TRAEFIK_DYNAMIC_CONFIG=/etc/traefik/rules - TRAEFIK_CONTAINER_NAME=traefik - TRAEFIK_STATIC_CONFIG=/etc/traefik/traefik_config.yml volumes: @@ -73,3 +73,5 @@ curl http://localhost:8080/health ``` If the endpoint returns healthy status, open the UI on `http://localhost:8080` or behind your existing reverse proxy route. + +`TRAEFIK_DYNAMIC_CONFIG` may be a single file path or a directory path. For directory-based Traefik setups, point it at the directory and CrowdSec Manager will manage `crowdsec-manager.yml` inside that directory. diff --git a/docs/content/docs/quick-start.mdx b/docs/content/docs/quick-start.mdx index 588abe1..d0737bf 100644 --- a/docs/content/docs/quick-start.mdx +++ b/docs/content/docs/quick-start.mdx @@ -26,7 +26,7 @@ services: environment: - PORT=8080 - ENVIRONMENT=production - - TRAEFIK_DYNAMIC_CONFIG=/etc/traefik/dynamic_config.yml + - TRAEFIK_DYNAMIC_CONFIG=/etc/traefik/rules - TRAEFIK_CONTAINER_NAME=traefik - TRAEFIK_STATIC_CONFIG=/etc/traefik/traefik_config.yml volumes: @@ -50,6 +50,8 @@ docker network create pangolin docker compose up -d ``` +`TRAEFIK_DYNAMIC_CONFIG` accepts either a single YAML file path or a directory of Traefik config fragments. If you use a directory such as `/etc/traefik/rules`, CrowdSec Manager writes only `crowdsec-manager.yml` inside that directory. + ## 4. Check health ```bash diff --git a/docs/src/app/(home)/page.tsx b/docs/src/app/(home)/page.tsx index 7d47569..323a41f 100644 --- a/docs/src/app/(home)/page.tsx +++ b/docs/src/app/(home)/page.tsx @@ -36,7 +36,7 @@ const quickInstall = `services: environment: - PORT=8080 - ENVIRONMENT=production - - TRAEFIK_DYNAMIC_CONFIG=/etc/traefik/dynamic_config.yml + - TRAEFIK_DYNAMIC_CONFIG=/etc/traefik/rules - TRAEFIK_CONTAINER_NAME=traefik - TRAEFIK_STATIC_CONFIG=/etc/traefik/traefik_config.yml volumes: diff --git a/internal/api/handlers/captcha.go b/internal/api/handlers/captcha.go index 2c2e916..bc1bdb7 100644 --- a/internal/api/handlers/captcha.go +++ b/internal/api/handlers/captcha.go @@ -13,6 +13,7 @@ import ( "crowdsec-manager/internal/docker" "crowdsec-manager/internal/logger" "crowdsec-manager/internal/models" + "crowdsec-manager/internal/traefikconfig" "github.com/gin-gonic/gin" ) @@ -96,10 +97,9 @@ func SetupCaptcha(dockerClient *docker.Client, cfg *config.Config) gin.HandlerFu } captchaHTMLPath := filepath.Join(cfg.ConfigDir, "traefik", "conf", "captcha.html") - // STEP 2: Update Traefik dynamic_config.yml + // STEP 2: Update the CrowdSec-managed Traefik dynamic config path. logger.Info("Updating Traefik dynamic configuration") - traefikConfigDir := filepath.Join(cfg.ConfigDir, "traefik") - if err := updateTraefikCaptchaConfig(dockerClient, cfg, req, traefikConfigDir); err != nil { + if err := updateTraefikCaptchaConfig(cfg, req); err != nil { logger.Error("Failed to update Traefik config", "error", err) c.JSON(http.StatusInternalServerError, models.Response{ Success: false, @@ -192,7 +192,7 @@ func GetCaptchaStatus(dockerClient *docker.Client, db *database.Database, cfg *c } } - // Supplementary: check dynamic_config.yml in Traefik container for live state + // Supplementary: check the Traefik dynamic config path in the container for live state. dynamicConfigPath := cfg.TraefikDynamicConfig if db != nil { if path, err := db.GetTraefikDynamicConfigPath(); err == nil { @@ -200,9 +200,8 @@ func GetCaptchaStatus(dockerClient *docker.Client, db *database.Database, cfg *c } } - configContent, err := dockerClient.ExecCommand(cfg.TraefikContainerName, []string{ - "cat", dynamicConfigPath, - }) + readResult, err := traefikconfig.ReadContainer(dockerClient, cfg.TraefikContainerName, dynamicConfigPath) + configContent := readResult.Content detectedProvider := "" hasHTMLPath := false diff --git a/internal/api/handlers/captcha_config.go b/internal/api/handlers/captcha_config.go index c64d61b..fc34c08 100644 --- a/internal/api/handlers/captcha_config.go +++ b/internal/api/handlers/captcha_config.go @@ -3,7 +3,6 @@ package handlers import ( "encoding/json" "net/http" - "path/filepath" "strconv" "crowdsec-manager/internal/config" @@ -120,8 +119,6 @@ func ApplyCaptchaConfig(dockerClient *docker.Client, db *database.Database, cfg } // Define the pipeline of steps. - traefikConfigDir := filepath.Join(cfg.ConfigDir, "traefik") - pipeline := []captchaApplyStep{ { Num: 1, @@ -134,7 +131,7 @@ func ApplyCaptchaConfig(dockerClient *docker.Client, db *database.Database, cfg Num: 2, Name: "Update Traefik dynamic config", Run: func(r models.CaptchaSetupRequest) error { - return updateTraefikCaptchaConfig(dockerClient, cfg, r, traefikConfigDir) + return updateTraefikCaptchaConfig(cfg, r) }, }, { diff --git a/internal/api/handlers/captcha_detect.go b/internal/api/handlers/captcha_detect.go index f40be42..3db6a52 100644 --- a/internal/api/handlers/captcha_detect.go +++ b/internal/api/handlers/captcha_detect.go @@ -13,6 +13,7 @@ import ( "crowdsec-manager/internal/docker" "crowdsec-manager/internal/logger" "crowdsec-manager/internal/models" + "crowdsec-manager/internal/traefikconfig" "github.com/gin-gonic/gin" ) @@ -31,7 +32,7 @@ func DetectCaptchaConfig(dockerClient *docker.Client, db *database.Database, cfg "html_file": false, } - // 1. Scan Traefik dynamic_config.yml for captcha keys. + // 1. Scan the configured Traefik dynamic config path for captcha keys. traefikValues := detectCaptchaInTraefikConfig(dockerClient, cfg) if len(traefikValues) > 0 { sources["traefik_dynamic_config"] = true @@ -106,21 +107,21 @@ func DetectCaptchaConfig(dockerClient *docker.Client, db *database.Database, cfg } } -// detectCaptchaInTraefikConfig reads Traefik dynamic config and extracts captcha-related values. -// It first attempts to read from the running container; on failure it falls back to the local file. +// detectCaptchaInTraefikConfig reads the configured Traefik dynamic config path and extracts captcha-related values. +// It first attempts to read from the running container; on failure it falls back to the local filesystem. func detectCaptchaInTraefikConfig(dockerClient *docker.Client, cfg *config.Config) map[string]interface{} { result := map[string]interface{}{} - output, err := dockerClient.ExecCommand(cfg.TraefikContainerName, []string{"cat", cfg.TraefikDynamicConfig}) + readResult, err := traefikconfig.ReadContainer(dockerClient, cfg.TraefikContainerName, cfg.TraefikDynamicConfig) if err != nil { - localPath := filepath.Join(cfg.ConfigDir, "traefik", "dynamic_config.yml") - data, readErr := os.ReadFile(localPath) + hostResult, readErr := traefikconfig.ReadHost(cfg, cfg.TraefikDynamicConfig) if readErr != nil { logger.Debug("Could not read Traefik dynamic config", "containerErr", err, "localErr", readErr) return result } - output = string(data) + readResult = hostResult } + output := readResult.Content lower := strings.ToLower(output) if !strings.Contains(lower, "captchaprovider") && !strings.Contains(lower, "captchasitekey") { diff --git a/internal/api/handlers/captcha_profiles.go b/internal/api/handlers/captcha_profiles.go index a6d7fa1..f30c8be 100644 --- a/internal/api/handlers/captcha_profiles.go +++ b/internal/api/handlers/captcha_profiles.go @@ -7,6 +7,7 @@ import ( "crowdsec-manager/internal/config" "crowdsec-manager/internal/docker" "crowdsec-manager/internal/logger" + "crowdsec-manager/internal/traefikconfig" "gopkg.in/yaml.v3" ) @@ -192,13 +193,12 @@ func verifyCaptchaSetup(dockerClient *docker.Client, cfg *config.Config) bool { logger.Info("Captcha HTML file verified", "path", cfg.TraefikCaptchaHTMLPath) // Check 2: Dynamic config contains captcha settings - configContent, err := dockerClient.ExecCommand(cfg.TraefikContainerName, []string{ - "cat", cfg.TraefikDynamicConfig, - }) + readResult, err := traefikconfig.ReadContainer(dockerClient, cfg.TraefikContainerName, cfg.TraefikDynamicConfig) if err != nil { logger.Warn("Failed to read dynamic config for verification", "error", err) return false } + configContent := readResult.Content if !strings.Contains(strings.ToLower(configContent), "captcha") { logger.Warn("Captcha not found in dynamic config") diff --git a/internal/api/handlers/captcha_setup.go b/internal/api/handlers/captcha_setup.go index e9a744c..e74e771 100644 --- a/internal/api/handlers/captcha_setup.go +++ b/internal/api/handlers/captcha_setup.go @@ -8,9 +8,9 @@ import ( "strings" "crowdsec-manager/internal/config" - "crowdsec-manager/internal/docker" "crowdsec-manager/internal/logger" "crowdsec-manager/internal/models" + "crowdsec-manager/internal/traefikconfig" "gopkg.in/yaml.v3" ) @@ -78,7 +78,7 @@ const captchaHTMLTemplate = ` ` -// detectCaptchaInConfig checks if captcha is configured in dynamic_config.yml and profiles.yaml +// detectCaptchaInConfig checks if captcha is configured in the Traefik dynamic config and profiles.yaml func detectCaptchaInConfig(configContent string) (enabled bool, provider string, hasHTMLPath bool) { configLower := strings.ToLower(configContent) @@ -103,28 +103,31 @@ func detectCaptchaInConfig(configContent string) (enabled bool, provider string, return } -// extractCaptchaKeys extracts site key and secret key from dynamic_config.yml content +// extractCaptchaKeys extracts site key and secret key from one or more YAML documents. func extractCaptchaKeys(configContent string) (siteKey string, secretKey string) { - var config map[string]interface{} - if err := yaml.Unmarshal([]byte(configContent), &config); err != nil { - return "", "" - } + decoder := yaml.NewDecoder(strings.NewReader(configContent)) + for { + var config map[string]interface{} + if err := decoder.Decode(&config); err != nil { + break + } - if http, ok := config["http"].(map[string]interface{}); ok { - if middlewares, ok := http["middlewares"].(map[string]interface{}); ok { - for _, mw := range middlewares { - if mwMap, ok := mw.(map[string]interface{}); ok { - if plugin, ok := mwMap["plugin"].(map[string]interface{}); ok { - for k, v := range plugin { - if strings.Contains(strings.ToLower(k), "crowdsec") { - if crowdsec, ok := v.(map[string]interface{}); ok { - if key, ok := crowdsec["captchaSiteKey"].(string); ok { - siteKey = key - } - if key, ok := crowdsec["captchaSecretKey"].(string); ok { - secretKey = key + if http, ok := config["http"].(map[string]interface{}); ok { + if middlewares, ok := http["middlewares"].(map[string]interface{}); ok { + for _, mw := range middlewares { + if mwMap, ok := mw.(map[string]interface{}); ok { + if plugin, ok := mwMap["plugin"].(map[string]interface{}); ok { + for k, v := range plugin { + if strings.Contains(strings.ToLower(k), "crowdsec") { + if crowdsec, ok := v.(map[string]interface{}); ok { + if key, ok := crowdsec["captchaSiteKey"].(string); ok { + siteKey = key + } + if key, ok := crowdsec["captchaSecretKey"].(string); ok { + secretKey = key + } + return siteKey, secretKey } - return siteKey, secretKey } } } @@ -137,18 +140,27 @@ func extractCaptchaKeys(configContent string) (siteKey string, secretKey string) return siteKey, secretKey } -// updateTraefikCaptchaConfig updates Traefik's dynamic_config.yml with captcha configuration -func updateTraefikCaptchaConfig(dockerClient *docker.Client, cfg *config.Config, req models.CaptchaSetupRequest, traefikConfigDir string) error { - dynamicConfigPath := filepath.Join(traefikConfigDir, "dynamic_config.yml") +// updateTraefikCaptchaConfig updates the CrowdSec-managed Traefik dynamic config path with captcha configuration. +func updateTraefikCaptchaConfig(cfg *config.Config, req models.CaptchaSetupRequest) error { + dynamicConfigPath, err := traefikconfig.ManagedHostFilePath(cfg, cfg.TraefikDynamicConfig) + if err != nil { + return fmt.Errorf("failed to resolve Traefik dynamic config path: %v", err) + } + if err := os.MkdirAll(filepath.Dir(dynamicConfigPath), 0755); err != nil { + return fmt.Errorf("failed to prepare Traefik dynamic config path: %v", err) + } configBytes, err := os.ReadFile(dynamicConfigPath) if err != nil { - return fmt.Errorf("failed to read dynamic_config.yml from local path: %v", err) + if !os.IsNotExist(err) { + return fmt.Errorf("failed to read Traefik dynamic config from local path: %v", err) + } + configBytes = []byte{} } var node yaml.Node if err := yaml.Unmarshal(configBytes, &node); err != nil { - return fmt.Errorf("failed to parse dynamic_config.yml: %v", err) + return fmt.Errorf("failed to parse Traefik dynamic config: %v", err) } if len(node.Content) == 0 { @@ -157,7 +169,7 @@ func updateTraefikCaptchaConfig(dockerClient *docker.Client, cfg *config.Config, {Kind: yaml.MappingNode}, } } else if node.Content[0].Kind != yaml.MappingNode { - return fmt.Errorf("dynamic_config.yml root is not a mapping") + return fmt.Errorf("Traefik dynamic config root is not a mapping") } rootMap := node.Content[0] @@ -285,21 +297,21 @@ func updateTraefikCaptchaConfig(dockerClient *docker.Client, cfg *config.Config, // Create backup before modifying backupPath := dynamicConfigPath + ".bak" if err := os.WriteFile(backupPath, configBytes, 0644); err != nil { - logger.Warn("Failed to create backup of dynamic_config.yml", "error", err) + logger.Warn("Failed to create backup of Traefik dynamic config", "error", err) } newConfigBytes, err := yaml.Marshal(&node) if err != nil { - return fmt.Errorf("failed to marshal dynamic_config.yml: %v", err) + return fmt.Errorf("failed to marshal Traefik dynamic config: %v", err) } if err := os.WriteFile(dynamicConfigPath, newConfigBytes, 0644); err != nil { if backupBytes, err2 := os.ReadFile(backupPath); err2 == nil { os.WriteFile(dynamicConfigPath, backupBytes, 0644) } - return fmt.Errorf("failed to write dynamic_config.yml to local path: %v", err) + return fmt.Errorf("failed to write Traefik dynamic config to local path: %v", err) } - logger.Info("Traefik dynamic config updated successfully on local filesystem") + logger.Info("Traefik dynamic config updated successfully on local filesystem", "path", dynamicConfigPath) return nil } diff --git a/internal/api/handlers/captcha_setup_test.go b/internal/api/handlers/captcha_setup_test.go new file mode 100644 index 0000000..836ae2f --- /dev/null +++ b/internal/api/handlers/captcha_setup_test.go @@ -0,0 +1,123 @@ +package handlers + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "crowdsec-manager/internal/config" + "crowdsec-manager/internal/models" +) + +func TestUpdateTraefikCaptchaConfigFileMode(t *testing.T) { + t.Parallel() + + root := t.TempDir() + traefikDir := filepath.Join(root, "traefik") + if err := os.MkdirAll(traefikDir, 0755); err != nil { + t.Fatalf("failed to create traefik directory: %v", err) + } + + configPath := filepath.Join(traefikDir, "dynamic_config.yml") + initialContent := "http:\n middlewares:\n existing:\n headers: {}\n" + if err := os.WriteFile(configPath, []byte(initialContent), 0644); err != nil { + t.Fatalf("failed to write dynamic config: %v", err) + } + + cfg := &config.Config{ + ConfigDir: root, + TraefikDynamicConfig: "/etc/traefik/dynamic_config.yml", + TraefikCaptchaHTMLPath: "/etc/traefik/conf/captcha.html", + CaptchaGracePeriod: 1800, + } + req := models.CaptchaSetupRequest{ + Provider: "turnstile", + SiteKey: "site-key", + SecretKey: "secret-key", + } + + if err := updateTraefikCaptchaConfig(cfg, req); err != nil { + t.Fatalf("updateTraefikCaptchaConfig returned error: %v", err) + } + + updatedContent, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("failed to read updated config: %v", err) + } + + expectedParts := []string{ + "captchaProvider: turnstile", + "captchaSiteKey: site-key", + "captchaSecretKey: secret-key", + "captchaHTMLFilePath: /etc/traefik/conf/captcha.html", + "captchaGracePeriodSeconds: 1800", + } + for _, part := range expectedParts { + if !strings.Contains(string(updatedContent), part) { + t.Fatalf("updated config missing %q:\n%s", part, string(updatedContent)) + } + } + + if _, err := os.Stat(configPath + ".bak"); err != nil { + t.Fatalf("expected backup file to exist: %v", err) + } +} + +func TestUpdateTraefikCaptchaConfigDirectoryModeCreatesManagedOverlay(t *testing.T) { + t.Parallel() + + root := t.TempDir() + rulesDir := filepath.Join(root, "traefik", "rules") + if err := os.MkdirAll(rulesDir, 0755); err != nil { + t.Fatalf("failed to create rules directory: %v", err) + } + + basePath := filepath.Join(rulesDir, "base.yml") + baseContent := "http:\n routers:\n app:\n rule: Host(`example.com`)\n" + if err := os.WriteFile(basePath, []byte(baseContent), 0644); err != nil { + t.Fatalf("failed to write base config: %v", err) + } + + cfg := &config.Config{ + ConfigDir: root, + TraefikDynamicConfig: "/etc/traefik/rules", + TraefikCaptchaHTMLPath: "/etc/traefik/conf/captcha.html", + CaptchaGracePeriod: 900, + } + req := models.CaptchaSetupRequest{ + Provider: "hcaptcha", + SiteKey: "dir-site-key", + SecretKey: "dir-secret-key", + } + + if err := updateTraefikCaptchaConfig(cfg, req); err != nil { + t.Fatalf("updateTraefikCaptchaConfig returned error: %v", err) + } + + managedPath := filepath.Join(rulesDir, "crowdsec-manager.yml") + managedContent, err := os.ReadFile(managedPath) + if err != nil { + t.Fatalf("failed to read managed overlay: %v", err) + } + + expectedParts := []string{ + "captchaProvider: hcaptcha", + "captchaSiteKey: dir-site-key", + "captchaSecretKey: dir-secret-key", + "captchaGracePeriodSeconds: 900", + } + for _, part := range expectedParts { + if !strings.Contains(string(managedContent), part) { + t.Fatalf("managed overlay missing %q:\n%s", part, string(managedContent)) + } + } + + baseAfter, err := os.ReadFile(basePath) + if err != nil { + t.Fatalf("failed to read base config: %v", err) + } + if string(baseAfter) != baseContent { + t.Fatalf("base config was modified unexpectedly:\n%s", string(baseAfter)) + } +} diff --git a/internal/api/handlers/health_diagnostics.go b/internal/api/handlers/health_diagnostics.go index 37fd4b8..1be46ca 100644 --- a/internal/api/handlers/health_diagnostics.go +++ b/internal/api/handlers/health_diagnostics.go @@ -10,6 +10,7 @@ import ( "crowdsec-manager/internal/database" "crowdsec-manager/internal/docker" "crowdsec-manager/internal/models" + "crowdsec-manager/internal/traefikconfig" "github.com/buger/jsonparser" ) @@ -200,32 +201,27 @@ func checkTraefikIntegrationDiagnostic(dockerClient *docker.Client, db *database AppsecEnabled: false, } - configPaths := []string{ - cfg.TraefikDynamicConfig, - cfg.TraefikStaticConfig, - } - + dynamicConfigPath := cfg.TraefikDynamicConfig if db != nil { if path, err := db.GetTraefikDynamicConfigPath(); err == nil { - configPaths = append([]string{path}, configPaths...) + dynamicConfigPath = path } } var configContent string - var foundConfigPath string - - for _, path := range configPaths { - output, err := dockerClient.ExecCommand(cfg.TraefikContainerName, []string{"cat", path}) - if err == nil && output != "" { - configContent = output - foundConfigPath = path - break - } + var foundConfigPaths []string + + if result, err := traefikconfig.ReadContainer(dockerClient, cfg.TraefikContainerName, dynamicConfigPath); err == nil && result.Content != "" { + configContent = result.Content + foundConfigPaths = append(foundConfigPaths, result.SourcePaths...) + } else if output, err := dockerClient.ReadFileFromContainer(cfg.TraefikContainerName, cfg.TraefikStaticConfig); err == nil && output != "" { + configContent = output + foundConfigPaths = append(foundConfigPaths, cfg.TraefikStaticConfig) } if configContent != "" { traefikIntegration.MiddlewareConfigured = true - traefikIntegration.ConfigFiles = append(traefikIntegration.ConfigFiles, foundConfigPath) + traefikIntegration.ConfigFiles = append(traefikIntegration.ConfigFiles, foundConfigPaths...) configLower := strings.ToLower(configContent) diff --git a/internal/api/handlers/ip.go b/internal/api/handlers/ip.go index 9bfd8b3..5973a4b 100644 --- a/internal/api/handlers/ip.go +++ b/internal/api/handlers/ip.go @@ -12,6 +12,7 @@ import ( "crowdsec-manager/internal/docker" "crowdsec-manager/internal/logger" "crowdsec-manager/internal/models" + "crowdsec-manager/internal/traefikconfig" "github.com/buger/jsonparser" "github.com/gin-gonic/gin" @@ -232,18 +233,15 @@ func CheckIPSecurity(dockerClient *docker.Client, cfg *config.Config) gin.Handle } } - // 4. Check Traefik dynamic_config.yml for ipWhiteList - // Read Traefik dynamic configuration + // 4. Check the configured Traefik dynamic config path for ipAllowList/sourceRange. dynamicConfigPaths := append([]string{cfg.TraefikDynamicConfig}, cfg.TraefikDynamicConfigSearch...) for _, configPath := range dynamicConfigPaths { - traefikData, err := dockerClient.ExecCommand(cfg.TraefikContainerName, []string{ - "cat", configPath, - }) + readResult, err := traefikconfig.ReadContainer(dockerClient, cfg.TraefikContainerName, configPath) + traefikData := readResult.Content if err == nil && traefikData != "" { - // Check if IP is in the sourceRange list - // Parse YAML to check ipWhiteList.sourceRange - if strings.Contains(traefikData, "ipWhiteList") && (strings.Contains(traefikData, ip) || checkIPInCIDRList(ip, traefikData)) { + if (strings.Contains(traefikData, "ipWhiteList") || strings.Contains(traefikData, "ipAllowList") || strings.Contains(traefikData, "sourceRange")) && + (strings.Contains(traefikData, ip) || checkIPInCIDRList(ip, traefikData)) { result.InTraefik = true result.IsWhitelisted = true break diff --git a/internal/api/handlers/services.go b/internal/api/handlers/services.go index 9d5fda5..d565a9e 100644 --- a/internal/api/handlers/services.go +++ b/internal/api/handlers/services.go @@ -10,6 +10,7 @@ import ( "crowdsec-manager/internal/docker" "crowdsec-manager/internal/logger" "crowdsec-manager/internal/models" + "crowdsec-manager/internal/traefikconfig" "github.com/gin-gonic/gin" ) @@ -174,27 +175,22 @@ func CheckTraefikIntegration(dockerClient *docker.Client, db *database.Database, CaptchaHTMLExists: false, } - configPaths := []string{ - cfg.TraefikDynamicConfig, - cfg.TraefikStaticConfig, - } - + dynamicConfigPath := cfg.TraefikDynamicConfig if db != nil { if path, err := db.GetTraefikDynamicConfigPath(); err == nil { - configPaths = append([]string{path}, configPaths...) + dynamicConfigPath = path } } var config string - var configPath string - - for _, path := range configPaths { - output, err := dockerClient.ExecCommand(cfg.TraefikContainerName, []string{"cat", path}) - if err == nil && output != "" { - config = output - configPath = path - break - } + var configPaths []string + + if result, err := traefikconfig.ReadContainer(dockerClient, cfg.TraefikContainerName, dynamicConfigPath); err == nil && result.Content != "" { + config = result.Content + configPaths = append(configPaths, result.SourcePaths...) + } else if output, err := dockerClient.ReadFileFromContainer(cfg.TraefikContainerName, cfg.TraefikStaticConfig); err == nil && output != "" { + config = output + configPaths = append(configPaths, cfg.TraefikStaticConfig) } if config == "" { @@ -207,7 +203,7 @@ func CheckTraefikIntegration(dockerClient *docker.Client, db *database.Database, } integration.MiddlewareConfigured = true - integration.ConfigFiles = append(integration.ConfigFiles, configPath) + integration.ConfigFiles = append(integration.ConfigFiles, configPaths...) configLower := strings.ToLower(config) diff --git a/internal/api/handlers/services_config.go b/internal/api/handlers/services_config.go index d7e46f3..c8c2d69 100644 --- a/internal/api/handlers/services_config.go +++ b/internal/api/handlers/services_config.go @@ -4,10 +4,12 @@ import ( "fmt" "net/http" + "crowdsec-manager/internal/config" "crowdsec-manager/internal/database" "crowdsec-manager/internal/docker" "crowdsec-manager/internal/logger" "crowdsec-manager/internal/models" + "crowdsec-manager/internal/traefikconfig" "github.com/gin-gonic/gin" ) @@ -32,7 +34,7 @@ func GetTraefikConfig() gin.HandlerFunc { // In a real implementation, read from config file config := gin.H{ "static": "traefik_config.yml content", - "dynamic": "dynamic_config.yml content", + "dynamic": "dynamic config path content", } c.JSON(http.StatusOK, models.Response{ @@ -83,6 +85,14 @@ func SetTraefikConfigPath(db *database.Database) gin.HandlerFunc { logger.Info("Setting Traefik config path", "path", req.DynamicConfigPath) + if _, err := config.ResolveTraefikDynamicConfigTarget(req.DynamicConfigPath); err != nil { + c.JSON(http.StatusBadRequest, models.Response{ + Success: false, + Error: fmt.Sprintf("Invalid config path: %v", err), + }) + return + } + // Update database err := db.SetTraefikDynamicConfigPath(req.DynamicConfigPath) if err != nil { @@ -163,20 +173,41 @@ func GetFileContent(dockerClient *docker.Client, db *database.Database) gin.Hand return } - content, err := dockerClient.ExecCommand(container, []string{"cat", filePath}) - if err != nil { - c.JSON(http.StatusInternalServerError, models.Response{ - Success: false, - Error: fmt.Sprintf("Failed to read file: %v", err), - }) - return + content := "" + sourcePaths := []string{filePath} + managedPath := "" + + if fileType == "dynamic_config" { + result, err := traefikconfig.ReadContainer(dockerClient, container, filePath) + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{ + Success: false, + Error: fmt.Sprintf("Failed to read file: %v", err), + }) + return + } + content = result.Content + sourcePaths = result.SourcePaths + managedPath = result.Target.ManagedFilePath + } else { + var err error + content, err = dockerClient.ReadFileFromContainer(container, filePath) + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{ + Success: false, + Error: fmt.Sprintf("Failed to read file: %v", err), + }) + return + } } c.JSON(http.StatusOK, models.Response{ Success: true, Data: gin.H{ - "path": filePath, - "content": content, + "path": filePath, + "managed_path": managedPath, + "source_paths": sourcePaths, + "content": content, }, }) } diff --git a/internal/api/handlers/whitelist.go b/internal/api/handlers/whitelist.go index dc0a724..2adfa3e 100644 --- a/internal/api/handlers/whitelist.go +++ b/internal/api/handlers/whitelist.go @@ -9,6 +9,7 @@ import ( "crowdsec-manager/internal/docker" "crowdsec-manager/internal/logger" "crowdsec-manager/internal/models" + "crowdsec-manager/internal/traefikconfig" "github.com/gin-gonic/gin" ) @@ -67,11 +68,9 @@ func ViewWhitelist(dockerClient *docker.Client, cfg *config.Config) gin.HandlerF } // Get Traefik whitelist - traefikWL, err := dockerClient.ExecCommand(cfg.TraefikContainerName, []string{ - "cat", cfg.TraefikDynamicConfig, - }) + readResult, err := traefikconfig.ReadContainer(dockerClient, cfg.TraefikContainerName, cfg.TraefikDynamicConfig) if err == nil { - whitelist["traefik"] = parseTraefikWhitelist(traefikWL) + whitelist["traefik"] = parseTraefikWhitelist(readResult.Content) } c.JSON(http.StatusOK, models.Response{ @@ -178,14 +177,24 @@ whitelist: } if req.AddToTraefik { - // Update Traefik dynamic config - err := dockerClient.AppendLineToFileInContainer(cfg.TraefikContainerName, cfg.TraefikDynamicConfig, "sourceRange:", "- "+req.IP) + managedContent, _, err := traefikconfig.ReadManagedContainer(dockerClient, cfg.TraefikContainerName, cfg.TraefikDynamicConfig) if err != nil { - errMsg := fmt.Sprintf("Failed to add IP to Traefik whitelist: %v", err) + errMsg := fmt.Sprintf("Failed to read Traefik whitelist config: %v", err) logger.Error(errMsg, "error", err) errors = append(errors, errMsg) } else { - successMessages = append(successMessages, "Added to Traefik whitelist") + updatedContent, updateErr := traefikconfig.UpsertWhitelistEntry(managedContent, req.IP) + if updateErr != nil { + errMsg := fmt.Sprintf("Failed to update Traefik whitelist config: %v", updateErr) + logger.Error(errMsg, "error", updateErr) + errors = append(errors, errMsg) + } else if _, writeErr := traefikconfig.WriteManagedContainer(dockerClient, cfg.TraefikContainerName, cfg.TraefikDynamicConfig, []byte(updatedContent)); writeErr != nil { + errMsg := fmt.Sprintf("Failed to add IP to Traefik whitelist: %v", writeErr) + logger.Error(errMsg, "error", writeErr) + errors = append(errors, errMsg) + } else { + successMessages = append(successMessages, "Added to Traefik whitelist") + } } } @@ -254,11 +263,22 @@ whitelist: } if req.AddToTraefik { - err := dockerClient.AppendLineToFileInContainer(cfg.TraefikContainerName, cfg.TraefikDynamicConfig, "sourceRange:", "- "+req.CIDR) + managedContent, _, err := traefikconfig.ReadManagedContainer(dockerClient, cfg.TraefikContainerName, cfg.TraefikDynamicConfig) if err != nil { - errMsg := fmt.Sprintf("Failed to add CIDR to Traefik whitelist: %v", err) + errMsg := fmt.Sprintf("Failed to read Traefik whitelist config: %v", err) logger.Error(errMsg, "error", err) errors = append(errors, errMsg) + } else { + updatedContent, updateErr := traefikconfig.UpsertWhitelistEntry(managedContent, req.CIDR) + if updateErr != nil { + errMsg := fmt.Sprintf("Failed to update Traefik whitelist config: %v", updateErr) + logger.Error(errMsg, "error", updateErr) + errors = append(errors, errMsg) + } else if _, writeErr := traefikconfig.WriteManagedContainer(dockerClient, cfg.TraefikContainerName, cfg.TraefikDynamicConfig, []byte(updatedContent)); writeErr != nil { + errMsg := fmt.Sprintf("Failed to add CIDR to Traefik whitelist: %v", writeErr) + logger.Error(errMsg, "error", writeErr) + errors = append(errors, errMsg) + } } } diff --git a/internal/api/handlers/whitelist_ops.go b/internal/api/handlers/whitelist_ops.go index ddcbed2..e5749f9 100644 --- a/internal/api/handlers/whitelist_ops.go +++ b/internal/api/handlers/whitelist_ops.go @@ -12,6 +12,7 @@ import ( "crowdsec-manager/internal/docker" "crowdsec-manager/internal/logger" "crowdsec-manager/internal/models" + "crowdsec-manager/internal/traefikconfig" "github.com/gin-gonic/gin" ) @@ -116,7 +117,16 @@ func AddToTraefikWhitelist(dockerClient *docker.Client, cfg *config.Config) gin. logger.Info("Adding to Traefik whitelist", "ip", req.IP) - err := dockerClient.AppendLineToFileInContainer(cfg.TraefikContainerName, cfg.TraefikDynamicConfig, "sourceRange:", "- "+req.IP) + managedContent, _, err := traefikconfig.ReadManagedContainer(dockerClient, cfg.TraefikContainerName, cfg.TraefikDynamicConfig) + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{ + Success: false, + Error: fmt.Sprintf("Failed to read whitelist config: %v", err), + }) + return + } + + updatedContent, err := traefikconfig.UpsertWhitelistEntry(managedContent, req.IP) if err != nil { c.JSON(http.StatusInternalServerError, models.Response{ Success: false, @@ -125,6 +135,14 @@ func AddToTraefikWhitelist(dockerClient *docker.Client, cfg *config.Config) gin. return } + if _, err := traefikconfig.WriteManagedContainer(dockerClient, cfg.TraefikContainerName, cfg.TraefikDynamicConfig, []byte(updatedContent)); err != nil { + c.JSON(http.StatusInternalServerError, models.Response{ + Success: false, + Error: fmt.Sprintf("Failed to update whitelist: %v", err), + }) + return + } + autoSnapshot("dynamic_config") c.JSON(http.StatusOK, models.Response{ @@ -180,22 +198,14 @@ func RemoveFromWhitelist(dockerClient *docker.Client, cfg *config.Config) gin.Ha if req.RemoveFromTraefik { // Read current Traefik config, remove the IP, write back - currentConfig, err := dockerClient.ExecCommand(cfg.TraefikContainerName, []string{ - "cat", cfg.TraefikDynamicConfig, - }) + currentConfig, _, err := traefikconfig.ReadManagedContainer(dockerClient, cfg.TraefikContainerName, cfg.TraefikDynamicConfig) if err != nil { errors = append(errors, fmt.Sprintf("Failed to read Traefik config: %v", err)) } else { - lines := strings.Split(currentConfig, "\n") - var newLines []string - ipPattern := regexp.MustCompile(`^\s*-\s+` + regexp.QuoteMeta(req.IP) + `\s*$`) - for _, line := range lines { - if !ipPattern.MatchString(line) { - newLines = append(newLines, line) - } - } - newContent := strings.Join(newLines, "\n") - if err := dockerClient.WriteFileToContainer(cfg.TraefikContainerName, cfg.TraefikDynamicConfig, []byte(newContent)); err != nil { + newContent, _, removeErr := traefikconfig.RemoveWhitelistEntry(currentConfig, req.IP) + if removeErr != nil { + errors = append(errors, fmt.Sprintf("Failed to update Traefik config: %v", removeErr)) + } else if _, err := traefikconfig.WriteManagedContainer(dockerClient, cfg.TraefikContainerName, cfg.TraefikDynamicConfig, []byte(newContent)); err != nil { errors = append(errors, fmt.Sprintf("Failed to update Traefik config: %v", err)) } else { successMessages = append(successMessages, "Removed from Traefik whitelist") @@ -272,8 +282,12 @@ whitelist: } // Add to Traefik - if err := dockerClient.AppendLineToFileInContainer(cfg.TraefikContainerName, cfg.TraefikDynamicConfig, "sourceRange:", "- "+ip); err == nil { - results["traefik"] = true + if managedContent, _, err := traefikconfig.ReadManagedContainer(dockerClient, cfg.TraefikContainerName, cfg.TraefikDynamicConfig); err == nil { + if updatedContent, updateErr := traefikconfig.UpsertWhitelistEntry(managedContent, ip); updateErr == nil { + if _, writeErr := traefikconfig.WriteManagedContainer(dockerClient, cfg.TraefikContainerName, cfg.TraefikDynamicConfig, []byte(updatedContent)); writeErr == nil { + results["traefik"] = true + } + } } autoSnapshot("whitelist") diff --git a/internal/config/config.go b/internal/config/config.go index df7abae..e5a2831 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -113,6 +113,8 @@ func Load() (*Config, error) { TraefikCaptchaHTMLPath: getEnv("TRAEFIK_CAPTCHA_HTML_PATH", "/etc/traefik/conf/captcha.html"), TraefikCaptchaEnvPath: getEnv("TRAEFIK_CAPTCHA_ENV_PATH", "/etc/traefik/captcha.env"), TraefikDynamicConfigSearch: []string{ + "/etc/traefik/rules", + "/rules", "/etc/traefik/config/dynamic_config.yml", "/etc/traefik/dynamic_config.yaml", "/etc/traefik/config/dynamic_config.yaml", diff --git a/internal/config/traefik_dynamic_config.go b/internal/config/traefik_dynamic_config.go new file mode 100644 index 0000000..6c072fb --- /dev/null +++ b/internal/config/traefik_dynamic_config.go @@ -0,0 +1,53 @@ +package config + +import ( + "fmt" + "path" + "strings" +) + +const ManagedTraefikDynamicConfigFileName = "crowdsec-manager.yml" + +type TraefikDynamicConfigMode string + +const ( + TraefikDynamicConfigModeFile TraefikDynamicConfigMode = "file" + TraefikDynamicConfigModeDirectory TraefikDynamicConfigMode = "directory" +) + +type TraefikDynamicConfigTarget struct { + ConfiguredPath string + Mode TraefikDynamicConfigMode + ManagedFilePath string +} + +func ResolveTraefikDynamicConfigTarget(configuredPath string) (TraefikDynamicConfigTarget, error) { + trimmed := strings.TrimSpace(configuredPath) + if trimmed == "" { + return TraefikDynamicConfigTarget{}, fmt.Errorf("traefik dynamic config path is empty") + } + + cleaned := path.Clean(trimmed) + if cleaned == "." { + return TraefikDynamicConfigTarget{}, fmt.Errorf("traefik dynamic config path is invalid: %q", configuredPath) + } + + if isTraefikDynamicConfigFilePath(cleaned) { + return TraefikDynamicConfigTarget{ + ConfiguredPath: cleaned, + Mode: TraefikDynamicConfigModeFile, + ManagedFilePath: cleaned, + }, nil + } + + return TraefikDynamicConfigTarget{ + ConfiguredPath: cleaned, + Mode: TraefikDynamicConfigModeDirectory, + ManagedFilePath: path.Join(cleaned, ManagedTraefikDynamicConfigFileName), + }, nil +} + +func isTraefikDynamicConfigFilePath(configuredPath string) bool { + lower := strings.ToLower(configuredPath) + return strings.HasSuffix(lower, ".yml") || strings.HasSuffix(lower, ".yaml") +} diff --git a/internal/config/traefik_dynamic_config_test.go b/internal/config/traefik_dynamic_config_test.go new file mode 100644 index 0000000..b933570 --- /dev/null +++ b/internal/config/traefik_dynamic_config_test.go @@ -0,0 +1,77 @@ +package config + +import "testing" + +func TestResolveTraefikDynamicConfigTarget(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + wantMode TraefikDynamicConfigMode + wantConfigured string + wantManagedTarget string + wantErr bool + }{ + { + name: "yaml file path stays file target", + input: "/etc/traefik/dynamic_config.yml", + wantMode: TraefikDynamicConfigModeFile, + wantConfigured: "/etc/traefik/dynamic_config.yml", + wantManagedTarget: "/etc/traefik/dynamic_config.yml", + }, + { + name: "yaml file path trims whitespace", + input: " /etc/traefik/dynamic_config.yaml ", + wantMode: TraefikDynamicConfigModeFile, + wantConfigured: "/etc/traefik/dynamic_config.yaml", + wantManagedTarget: "/etc/traefik/dynamic_config.yaml", + }, + { + name: "directory path resolves managed overlay file", + input: "/etc/traefik/rules", + wantMode: TraefikDynamicConfigModeDirectory, + wantConfigured: "/etc/traefik/rules", + wantManagedTarget: "/etc/traefik/rules/crowdsec-manager.yml", + }, + { + name: "directory path with trailing slash is normalized", + input: "/rules/", + wantMode: TraefikDynamicConfigModeDirectory, + wantConfigured: "/rules", + wantManagedTarget: "/rules/crowdsec-manager.yml", + }, + { + name: "empty path fails", + input: " ", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + target, err := ResolveTraefikDynamicConfigTarget(tt.input) + if tt.wantErr { + if err == nil { + t.Fatalf("expected error, got target=%+v", target) + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if target.Mode != tt.wantMode { + t.Fatalf("mode = %q, want %q", target.Mode, tt.wantMode) + } + if target.ConfiguredPath != tt.wantConfigured { + t.Fatalf("configured path = %q, want %q", target.ConfiguredPath, tt.wantConfigured) + } + if target.ManagedFilePath != tt.wantManagedTarget { + t.Fatalf("managed file path = %q, want %q", target.ManagedFilePath, tt.wantManagedTarget) + } + }) + } +} diff --git a/internal/configvalidator/validator.go b/internal/configvalidator/validator.go index 472e09f..2e077df 100644 --- a/internal/configvalidator/validator.go +++ b/internal/configvalidator/validator.go @@ -1,11 +1,8 @@ package configvalidator import ( - "archive/tar" - "bytes" "crypto/sha256" "fmt" - "strings" "time" "crowdsec-manager/internal/config" @@ -14,6 +11,7 @@ import ( "crowdsec-manager/internal/logger" "crowdsec-manager/internal/messaging" "crowdsec-manager/internal/models" + "crowdsec-manager/internal/traefikconfig" ) // configEntry defines a tracked config file @@ -43,18 +41,23 @@ func NewValidator(db *database.Database, dockerClient *docker.Client, hub *messa // trackedConfigs returns the list of config files to track func (v *Validator) trackedConfigs() []configEntry { + dynamicConfigPath := v.cfg.TraefikDynamicConfig + if resolvedPath, err := traefikconfig.ManagedFilePath(v.cfg.TraefikDynamicConfig); err == nil { + dynamicConfigPath = resolvedPath + } + return []configEntry{ {ConfigType: "acquis", FilePath: v.cfg.CrowdSecAcquisFile, ContainerName: v.cfg.CrowdsecContainerName}, {ConfigType: "profiles", FilePath: v.cfg.CrowdSecProfilesPath, ContainerName: v.cfg.CrowdsecContainerName}, {ConfigType: "whitelist", FilePath: v.cfg.CrowdSecWhitelistPath, ContainerName: v.cfg.CrowdsecContainerName}, - {ConfigType: "dynamic_config", FilePath: v.cfg.TraefikDynamicConfig, ContainerName: v.cfg.TraefikContainerName}, + {ConfigType: "dynamic_config", FilePath: dynamicConfigPath, ContainerName: v.cfg.TraefikContainerName}, {ConfigType: "static_config", FilePath: v.cfg.TraefikStaticConfig, ContainerName: v.cfg.TraefikContainerName}, } } // readFileFromContainer reads a file's contents from inside a container func (v *Validator) readFileFromContainer(containerName, filePath string) (string, error) { - output, err := v.docker.ExecCommand(containerName, []string{"cat", filePath}) + output, err := v.docker.ReadFileFromContainer(containerName, filePath) if err != nil { return "", fmt.Errorf("failed to read %s from %s: %w", filePath, containerName, err) } @@ -63,36 +66,7 @@ func (v *Validator) readFileFromContainer(containerName, filePath string) (strin // writeFileToContainer writes content to a file inside a container using tar copy func (v *Validator) writeFileToContainer(containerName, filePath, content string) error { - // Create a tar archive with the file - var buf bytes.Buffer - tw := tar.NewWriter(&buf) - - // Extract filename from path - parts := strings.Split(filePath, "/") - fileName := parts[len(parts)-1] - - hdr := &tar.Header{ - Name: fileName, - Mode: 0644, - Size: int64(len(content)), - } - if err := tw.WriteHeader(hdr); err != nil { - return fmt.Errorf("failed to write tar header: %w", err) - } - if _, err := tw.Write([]byte(content)); err != nil { - return fmt.Errorf("failed to write tar content: %w", err) - } - if err := tw.Close(); err != nil { - return fmt.Errorf("failed to close tar writer: %w", err) - } - - // Extract directory path - dirPath := filePath[:strings.LastIndex(filePath, "/")] - if dirPath == "" { - dirPath = "/" - } - - return v.docker.CopyToContainer(containerName, dirPath, &buf) + return v.docker.WriteFileToContainer(containerName, filePath, []byte(content)) } // hashContent returns SHA-256 hex digest of content diff --git a/internal/traefikconfig/traefik_dynamic_config.go b/internal/traefikconfig/traefik_dynamic_config.go new file mode 100644 index 0000000..ffdbf12 --- /dev/null +++ b/internal/traefikconfig/traefik_dynamic_config.go @@ -0,0 +1,423 @@ +package traefikconfig + +import ( + "archive/tar" + "fmt" + "io" + "os" + "path" + "path/filepath" + "sort" + "strings" + + "crowdsec-manager/internal/config" + "crowdsec-manager/internal/docker" + + "gopkg.in/yaml.v3" +) + +const ManagedWhitelistMiddlewareName = "crowdsec-manager-ip-whitelist" + +type ReadResult struct { + Content string + SourcePaths []string + Target config.TraefikDynamicConfigTarget +} + +func Resolve(configuredPath string) (config.TraefikDynamicConfigTarget, error) { + return config.ResolveTraefikDynamicConfigTarget(configuredPath) +} + +func ManagedFilePath(configuredPath string) (string, error) { + target, err := Resolve(configuredPath) + if err != nil { + return "", err + } + return target.ManagedFilePath, nil +} + +func ReadContainer(dockerClient *docker.Client, containerName, configuredPath string) (ReadResult, error) { + target, err := Resolve(configuredPath) + if err != nil { + return ReadResult{}, err + } + + if target.Mode == config.TraefikDynamicConfigModeFile { + content, err := dockerClient.ReadFileFromContainer(containerName, target.ManagedFilePath) + if err != nil { + return ReadResult{}, err + } + return ReadResult{ + Content: content, + SourcePaths: []string{target.ManagedFilePath}, + Target: target, + }, nil + } + + files, err := readYAMLFilesFromContainerDirectory(dockerClient, containerName, target.ConfiguredPath) + if err != nil { + return ReadResult{}, err + } + return ReadResult{ + Content: combineYAMLDocuments(files), + SourcePaths: sortedKeys(files), + Target: target, + }, nil +} + +func ReadHost(cfg *config.Config, configuredPath string) (ReadResult, error) { + target, err := Resolve(configuredPath) + if err != nil { + return ReadResult{}, err + } + + if target.Mode == config.TraefikDynamicConfigModeFile { + hostPath, err := containerPathToHostPath(cfg, target.ManagedFilePath) + if err != nil { + return ReadResult{}, err + } + data, err := os.ReadFile(hostPath) + if err != nil { + return ReadResult{}, err + } + return ReadResult{ + Content: string(data), + SourcePaths: []string{target.ManagedFilePath}, + Target: target, + }, nil + } + + hostDir, err := containerPathToHostPath(cfg, target.ConfiguredPath) + if err != nil { + return ReadResult{}, err + } + entries, err := os.ReadDir(hostDir) + if err != nil { + return ReadResult{}, err + } + + files := map[string]string{} + for _, entry := range entries { + if entry.IsDir() || !isYAMLFileName(entry.Name()) { + continue + } + data, err := os.ReadFile(filepath.Join(hostDir, entry.Name())) + if err != nil { + return ReadResult{}, err + } + files[path.Join(target.ConfiguredPath, entry.Name())] = string(data) + } + + return ReadResult{ + Content: combineYAMLDocuments(files), + SourcePaths: sortedKeys(files), + Target: target, + }, nil +} + +func ReadManagedContainer(dockerClient *docker.Client, containerName, configuredPath string) (string, string, error) { + target, err := Resolve(configuredPath) + if err != nil { + return "", "", err + } + + if target.Mode == config.TraefikDynamicConfigModeDirectory { + exists, err := dockerClient.FileExists(containerName, target.ManagedFilePath) + if err != nil { + return "", "", err + } + if !exists { + return "", target.ManagedFilePath, nil + } + } + + content, err := dockerClient.ReadFileFromContainer(containerName, target.ManagedFilePath) + if err != nil { + return "", "", err + } + return content, target.ManagedFilePath, nil +} + +func WriteManagedContainer(dockerClient *docker.Client, containerName, configuredPath string, content []byte) (string, error) { + target, err := Resolve(configuredPath) + if err != nil { + return "", err + } + if err := dockerClient.WriteFileToContainer(containerName, target.ManagedFilePath, content); err != nil { + return "", err + } + return target.ManagedFilePath, nil +} + +func ManagedHostFilePath(cfg *config.Config, configuredPath string) (string, error) { + target, err := Resolve(configuredPath) + if err != nil { + return "", err + } + return containerPathToHostPath(cfg, target.ManagedFilePath) +} + +func containerPathToHostPath(cfg *config.Config, containerPath string) (string, error) { + switch { + case containerPath == "/etc/traefik": + return filepath.Join(cfg.ConfigDir, "traefik"), nil + case strings.HasPrefix(containerPath, "/etc/traefik/"): + return filepath.Join(cfg.ConfigDir, "traefik", filepath.FromSlash(strings.TrimPrefix(containerPath, "/etc/traefik/"))), nil + case containerPath == "/rules": + return filepath.Join(cfg.ConfigDir, "traefik", "rules"), nil + case strings.HasPrefix(containerPath, "/rules/"): + return filepath.Join(cfg.ConfigDir, "traefik", "rules", filepath.FromSlash(strings.TrimPrefix(containerPath, "/rules/"))), nil + default: + return "", fmt.Errorf("unsupported Traefik container path: %s", containerPath) + } +} + +func readYAMLFilesFromContainerDirectory(dockerClient *docker.Client, containerName, dirPath string) (map[string]string, error) { + reader, err := dockerClient.CopyFromContainer(containerName, dirPath) + if err != nil { + return nil, err + } + defer reader.Close() + + files := map[string]string{} + tr := tar.NewReader(reader) + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return nil, err + } + if hdr.FileInfo().IsDir() || !isYAMLFileName(hdr.Name) { + continue + } + data, err := io.ReadAll(tr) + if err != nil { + return nil, err + } + fullPath := archiveEntryToContainerPath(dirPath, hdr.Name) + files[fullPath] = string(data) + } + + return files, nil +} + +func archiveEntryToContainerPath(basePath, entryName string) string { + cleanBase := path.Clean(basePath) + cleanEntry := path.Clean(strings.TrimPrefix(entryName, "./")) + baseName := path.Base(cleanBase) + + switch { + case cleanEntry == "." || cleanEntry == "": + return cleanBase + case cleanEntry == baseName: + return cleanBase + case strings.HasPrefix(cleanEntry, baseName+"/"): + return path.Join(cleanBase, strings.TrimPrefix(cleanEntry, baseName+"/")) + default: + return path.Join(cleanBase, cleanEntry) + } +} + +func isYAMLFileName(name string) bool { + lower := strings.ToLower(name) + return strings.HasSuffix(lower, ".yml") || strings.HasSuffix(lower, ".yaml") +} + +func combineYAMLDocuments(files map[string]string) string { + paths := sortedKeys(files) + var builder strings.Builder + + for _, filePath := range paths { + content := strings.TrimSpace(files[filePath]) + if content == "" { + continue + } + if builder.Len() > 0 { + builder.WriteString("\n") + } + builder.WriteString("---\n") + builder.WriteString("# Source: ") + builder.WriteString(filePath) + builder.WriteString("\n") + builder.WriteString(content) + builder.WriteString("\n") + } + + return strings.TrimSpace(builder.String()) +} + +func sortedKeys(values map[string]string) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +func UpsertWhitelistEntry(content, entry string) (string, error) { + doc, err := parseYAMLDocument(content) + if err != nil { + return "", err + } + + sourceRange := ensureWhitelistSourceRange(doc.Content[0]) + for _, item := range sourceRange.Content { + if strings.TrimSpace(item.Value) == entry { + return marshalYAMLDocument(doc) + } + } + + sourceRange.Content = append(sourceRange.Content, &yaml.Node{ + Kind: yaml.ScalarNode, + Value: entry, + }) + + return marshalYAMLDocument(doc) +} + +func RemoveWhitelistEntry(content, entry string) (string, bool, error) { + doc, err := parseYAMLDocument(content) + if err != nil { + return "", false, err + } + + sourceRange, ok := findWhitelistSourceRange(doc.Content[0]) + if !ok { + out, err := marshalYAMLDocument(doc) + return out, false, err + } + + filtered := make([]*yaml.Node, 0, len(sourceRange.Content)) + removed := false + for _, item := range sourceRange.Content { + if strings.TrimSpace(item.Value) == entry { + removed = true + continue + } + filtered = append(filtered, item) + } + sourceRange.Content = filtered + + out, err := marshalYAMLDocument(doc) + return out, removed, err +} + +func parseYAMLDocument(content string) (*yaml.Node, error) { + var doc yaml.Node + trimmed := strings.TrimSpace(content) + if trimmed == "" { + doc.Kind = yaml.DocumentNode + doc.Content = []*yaml.Node{{Kind: yaml.MappingNode}} + return &doc, nil + } + + if err := yaml.Unmarshal([]byte(content), &doc); err != nil { + return nil, fmt.Errorf("failed to parse Traefik dynamic config: %w", err) + } + if len(doc.Content) == 0 { + doc.Kind = yaml.DocumentNode + doc.Content = []*yaml.Node{{Kind: yaml.MappingNode}} + return &doc, nil + } + if doc.Content[0].Kind != yaml.MappingNode { + return nil, fmt.Errorf("Traefik dynamic config root is not a mapping") + } + return &doc, nil +} + +func marshalYAMLDocument(doc *yaml.Node) (string, error) { + var builder strings.Builder + encoder := yaml.NewEncoder(&builder) + encoder.SetIndent(2) + if err := encoder.Encode(doc); err != nil { + return "", err + } + if err := encoder.Close(); err != nil { + return "", err + } + return strings.TrimSpace(builder.String()) + "\n", nil +} + +func ensureWhitelistSourceRange(root *yaml.Node) *yaml.Node { + httpNode := findOrCreateMap(root, "http") + middlewaresNode := findOrCreateMap(httpNode, "middlewares") + whitelistNode := findOrCreateMap(middlewaresNode, ManagedWhitelistMiddlewareName) + ipAllowListNode := findOrCreateMap(whitelistNode, "ipAllowList") + return findOrCreateSequence(ipAllowListNode, "sourceRange") +} + +func findWhitelistSourceRange(root *yaml.Node) (*yaml.Node, bool) { + httpNode, ok := findMap(root, "http") + if !ok { + return nil, false + } + middlewaresNode, ok := findMap(httpNode, "middlewares") + if !ok { + return nil, false + } + whitelistNode, ok := findMap(middlewaresNode, ManagedWhitelistMiddlewareName) + if !ok { + return nil, false + } + ipAllowListNode, ok := findMap(whitelistNode, "ipAllowList") + if !ok { + return nil, false + } + sourceRangeNode, ok := findSequence(ipAllowListNode, "sourceRange") + return sourceRangeNode, ok +} + +func findMap(parent *yaml.Node, key string) (*yaml.Node, bool) { + for i := 0; i < len(parent.Content); i += 2 { + if parent.Content[i].Value == key { + return parent.Content[i+1], true + } + } + return nil, false +} + +func findOrCreateMap(parent *yaml.Node, key string) *yaml.Node { + if node, ok := findMap(parent, key); ok { + if node.Kind != yaml.MappingNode { + node.Kind = yaml.MappingNode + node.Tag = "" + node.Value = "" + node.Content = nil + } + return node + } + + keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: key} + valueNode := &yaml.Node{Kind: yaml.MappingNode} + parent.Content = append(parent.Content, keyNode, valueNode) + return valueNode +} + +func findSequence(parent *yaml.Node, key string) (*yaml.Node, bool) { + for i := 0; i < len(parent.Content); i += 2 { + if parent.Content[i].Value == key { + return parent.Content[i+1], true + } + } + return nil, false +} + +func findOrCreateSequence(parent *yaml.Node, key string) *yaml.Node { + if node, ok := findSequence(parent, key); ok { + if node.Kind != yaml.SequenceNode { + node.Kind = yaml.SequenceNode + node.Tag = "" + node.Value = "" + node.Content = nil + } + return node + } + + keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: key} + valueNode := &yaml.Node{Kind: yaml.SequenceNode} + parent.Content = append(parent.Content, keyNode, valueNode) + return valueNode +} diff --git a/internal/traefikconfig/traefik_dynamic_config_test.go b/internal/traefikconfig/traefik_dynamic_config_test.go new file mode 100644 index 0000000..2087b81 --- /dev/null +++ b/internal/traefikconfig/traefik_dynamic_config_test.go @@ -0,0 +1,134 @@ +package traefikconfig + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "crowdsec-manager/internal/config" +) + +func TestManagedHostFilePath(t *testing.T) { + t.Parallel() + + cfg := &config.Config{ConfigDir: filepath.Join("C:", "app", "config")} + + tests := []struct { + name string + input string + wantSuffix string + }{ + { + name: "file path maps into traefik directory", + input: "/etc/traefik/dynamic_config.yml", + wantSuffix: filepath.Join("traefik", "dynamic_config.yml"), + }, + { + name: "directory path maps to managed overlay file", + input: "/etc/traefik/rules", + wantSuffix: filepath.Join("traefik", "rules", "crowdsec-manager.yml"), + }, + { + name: "rules mount root maps to managed overlay file", + input: "/rules", + wantSuffix: filepath.Join("traefik", "rules", "crowdsec-manager.yml"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := ManagedHostFilePath(cfg, tt.input) + if err != nil { + t.Fatalf("ManagedHostFilePath returned error: %v", err) + } + if !strings.HasSuffix(got, tt.wantSuffix) { + t.Fatalf("managed host path = %q, want suffix %q", got, tt.wantSuffix) + } + }) + } +} + +func TestUpsertWhitelistEntryCreatesManagedStructure(t *testing.T) { + t.Parallel() + + content, err := UpsertWhitelistEntry("", "192.168.1.10") + if err != nil { + t.Fatalf("UpsertWhitelistEntry returned error: %v", err) + } + + expectedParts := []string{ + "http:", + "middlewares:", + ManagedWhitelistMiddlewareName + ":", + "ipAllowList:", + "sourceRange:", + "- 192.168.1.10", + } + for _, part := range expectedParts { + if !strings.Contains(content, part) { + t.Fatalf("managed whitelist content missing %q:\n%s", part, content) + } + } +} + +func TestRemoveWhitelistEntryRemovesOnlyRequestedValue(t *testing.T) { + t.Parallel() + + input := `http: + middlewares: + crowdsec-manager-ip-whitelist: + ipAllowList: + sourceRange: + - 10.0.0.1 + - 10.0.0.2 +` + + output, removed, err := RemoveWhitelistEntry(input, "10.0.0.1") + if err != nil { + t.Fatalf("RemoveWhitelistEntry returned error: %v", err) + } + if !removed { + t.Fatal("expected whitelist entry to be removed") + } + if strings.Contains(output, "- 10.0.0.1") { + t.Fatalf("removed value still present:\n%s", output) + } + if !strings.Contains(output, "- 10.0.0.2") { + t.Fatalf("other whitelist value missing:\n%s", output) + } +} + +func TestReadHostDirectoryCombinesYAMLDocuments(t *testing.T) { + t.Parallel() + + root := t.TempDir() + rulesDir := filepath.Join(root, "traefik", "rules") + if err := os.MkdirAll(rulesDir, 0755); err != nil { + t.Fatalf("failed to create rules directory: %v", err) + } + if err := os.WriteFile(filepath.Join(rulesDir, "base.yml"), []byte("http:\n routers: {}\n"), 0644); err != nil { + t.Fatalf("failed to write base.yml: %v", err) + } + if err := os.WriteFile(filepath.Join(rulesDir, "crowdsec-manager.yml"), []byte("http:\n middlewares: {}\n"), 0644); err != nil { + t.Fatalf("failed to write crowdsec-manager.yml: %v", err) + } + + cfg := &config.Config{ConfigDir: root} + result, err := ReadHost(cfg, "/etc/traefik/rules") + if err != nil { + t.Fatalf("ReadHost returned error: %v", err) + } + + if len(result.SourcePaths) != 2 { + t.Fatalf("source paths = %d, want 2", len(result.SourcePaths)) + } + if !strings.Contains(result.Content, "# Source: /etc/traefik/rules/base.yml") { + t.Fatalf("combined content missing base source header:\n%s", result.Content) + } + if !strings.Contains(result.Content, "# Source: /etc/traefik/rules/crowdsec-manager.yml") { + t.Fatalf("combined content missing managed source header:\n%s", result.Content) + } +} diff --git a/web/src/lib/api/errors.ts b/web/src/lib/api/errors.ts index b528eae..25ade75 100644 --- a/web/src/lib/api/errors.ts +++ b/web/src/lib/api/errors.ts @@ -77,7 +77,7 @@ const ERROR_RULES: ErrorRule[] = [ ErrorContexts.CaptchaSetup, ], patterns: [/no such file or directory/i, /stat .* no such file or directory/i], - message: 'Traefik dynamic config file not found. Verify the config path in Settings.', + message: 'Traefik dynamic config path not found. Verify the config path in Settings.', }, { contexts: 'any', diff --git a/web/src/pages/Configuration.tsx b/web/src/pages/Configuration.tsx index 20257a6..5fcbca2 100644 --- a/web/src/pages/Configuration.tsx +++ b/web/src/pages/Configuration.tsx @@ -43,12 +43,12 @@ function EditableConfigForm({ initialPath, currentPath }: { initialPath: string; setConfigPath(e.target.value)} />

- The absolute path to the dynamic_config.yml file in the Traefik container + The absolute path to the Traefik dynamic config file or directory inside the Traefik container

@@ -92,7 +92,7 @@ export default function Configuration() { Traefik Dynamic Configuration Path - Configure the path to the Traefik dynamic_config.yml file + Configure the path to the Traefik dynamic config file or fragment directory @@ -130,12 +130,15 @@ export default function Configuration() {
  • Updating captcha settings
  • -

    Default Path:

    +

    Examples:

    /etc/traefik/dynamic_config.yml
    +
    + /etc/traefik/rules +

    - If your Traefik configuration uses a different path or filename, update it here to ensure proper integration with CrowdSec Manager. + If your Traefik configuration uses a different file or a directory of YAML fragments, update it here to ensure proper integration with CrowdSec Manager.