Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.pangolin.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion docs/content/docs/configuration/environment.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

Expand All @@ -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.
5 changes: 3 additions & 2 deletions docs/content/docs/configuration/settings.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand All @@ -24,5 +25,5 @@ CrowdSec Manager uses this path to:
- Check middleware configurations.

<Callout type="important">
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`.
</Callout>
2 changes: 1 addition & 1 deletion docs/content/docs/features/captcha.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
</Steps>

<Callout type="info">
Expand Down
4 changes: 3 additions & 1 deletion docs/content/docs/installation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.
4 changes: 3 additions & 1 deletion docs/content/docs/quick-start.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/src/app/(home)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
13 changes: 6 additions & 7 deletions internal/api/handlers/captcha.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -192,17 +192,16 @@ 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 {
dynamicConfigPath = path
}
}

configContent, err := dockerClient.ExecCommand(cfg.TraefikContainerName, []string{
"cat", dynamicConfigPath,
})
readResult, err := traefikconfig.ReadContainer(dockerClient, cfg.TraefikContainerName, dynamicConfigPath)
configContent := readResult.Content

detectedProvider := ""
hasHTMLPath := false
Expand Down
5 changes: 1 addition & 4 deletions internal/api/handlers/captcha_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package handlers
import (
"encoding/json"
"net/http"
"path/filepath"
"strconv"

"crowdsec-manager/internal/config"
Expand Down Expand Up @@ -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,
Expand All @@ -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)
},
},
{
Expand Down
15 changes: 8 additions & 7 deletions internal/api/handlers/captcha_detect.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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
Expand Down Expand Up @@ -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") {
Expand Down
6 changes: 3 additions & 3 deletions internal/api/handlers/captcha_profiles.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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")
Expand Down
74 changes: 43 additions & 31 deletions internal/api/handlers/captcha_setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -78,7 +78,7 @@ const captchaHTMLTemplate = `<!DOCTYPE html>
</body>
</html>`

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

Expand All @@ -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
}
}
}
Expand All @@ -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 {
Expand All @@ -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]

Expand Down Expand Up @@ -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
}
Loading