Skip to content

Commit 4af9caa

Browse files
committed
Add feature-config DB + detection/apply
Introduce a feature_configs table and DB helpers to store, retrieve, and mark feature configuration as applied. Add detection and apply workflows for captcha and Discord notifications (handlers: save/apply/detect) and refactor captcha setup to use helper functions for writing captcha.html and restarting containers. Add a compose scanner to read env vars from docker-compose files without modifying them. Expose new API routes and update the web client types and APIs. Also add UI components (FeatureWizard, StepProgress) and model types (FeatureConfig, FeatureDetectionResult) to support the new flows.
1 parent 3d1810d commit 4af9caa

18 files changed

Lines changed: 2403 additions & 609 deletions

internal/api/handlers/captcha.go

Lines changed: 65 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,55 @@ import (
1717
"github.com/gin-gonic/gin"
1818
)
1919

20+
// createCaptchaHTML writes the captcha.html file to the host filesystem under cfg.ConfigDir.
21+
// It returns the absolute path of the written file, or an error.
22+
func createCaptchaHTML(cfg *config.Config, provider, siteKey string) error {
23+
captchaHTML := strings.ReplaceAll(captchaHTMLTemplate, "{{.SiteKey}}", siteKey)
24+
captchaHTML = strings.ReplaceAll(captchaHTML, "{{.RedirectURL}}", "")
25+
captchaHTML = strings.ReplaceAll(captchaHTML, "{{.CaptchaValue}}", "")
26+
27+
traefikConfigDir := filepath.Join(cfg.ConfigDir, "traefik")
28+
if _, err := os.Stat(traefikConfigDir); err != nil {
29+
return fmt.Errorf("traefik configuration directory not found: %w", err)
30+
}
31+
32+
confDir := filepath.Join(traefikConfigDir, "conf")
33+
if err := os.MkdirAll(confDir, 0755); err != nil {
34+
return fmt.Errorf("failed to create conf directory: %w", err)
35+
}
36+
37+
captchaHTMLPath := filepath.Join(confDir, "captcha.html")
38+
if err := os.WriteFile(captchaHTMLPath, []byte(captchaHTML), 0644); err != nil {
39+
return fmt.Errorf("failed to write captcha.html: %w", err)
40+
}
41+
42+
logger.Info("Captcha HTML file created", "path", captchaHTMLPath, "provider", provider)
43+
return nil
44+
}
45+
46+
// restartTraefikContainer performs a clean stop+start of the Traefik container.
47+
func restartTraefikContainer(dockerClient *docker.Client, cfg *config.Config) error {
48+
if err := dockerClient.StopContainer(cfg.TraefikContainerName); err != nil {
49+
logger.Warn("Failed to stop Traefik (continuing)", "error", err)
50+
} else {
51+
time.Sleep(1 * time.Second)
52+
}
53+
if err := dockerClient.StartContainer(cfg.TraefikContainerName); err != nil {
54+
return fmt.Errorf("failed to start Traefik: %w", err)
55+
}
56+
time.Sleep(3 * time.Second)
57+
return nil
58+
}
59+
60+
// restartCrowdSecContainer restarts the CrowdSec container.
61+
func restartCrowdSecContainer(dockerClient *docker.Client, cfg *config.Config) error {
62+
if err := dockerClient.RestartContainer(cfg.CrowdsecContainerName); err != nil {
63+
return fmt.Errorf("failed to restart CrowdSec: %w", err)
64+
}
65+
time.Sleep(3 * time.Second)
66+
return nil
67+
}
68+
2069
// SetupCaptcha sets up Cloudflare Turnstile captcha
2170
func SetupCaptcha(dockerClient *docker.Client, cfg *config.Config) gin.HandlerFunc {
2271
return func(c *gin.Context) {
@@ -43,49 +92,19 @@ func SetupCaptcha(dockerClient *docker.Client, cfg *config.Config) gin.HandlerFu
4392

4493
// STEP 1: Create captcha.html file on host
4594
logger.Info("Creating captcha.html file")
46-
captchaHTML := strings.ReplaceAll(captchaHTMLTemplate, "{{.SiteKey}}", req.SiteKey)
47-
captchaHTML = strings.ReplaceAll(captchaHTML, "{{.RedirectURL}}", "")
48-
captchaHTML = strings.ReplaceAll(captchaHTML, "{{.CaptchaValue}}", "")
49-
50-
// Use local path for Traefik config directory (mapped via /app/config)
51-
traefikConfigDir := filepath.Join(cfg.ConfigDir, "traefik")
52-
53-
// Verify the directory exists
54-
if _, err := os.Stat(traefikConfigDir); err != nil {
55-
logger.Error("Traefik config directory not found", "path", traefikConfigDir, "error", err)
56-
c.JSON(http.StatusInternalServerError, models.Response{
57-
Success: false,
58-
Error: "Traefik configuration directory not found",
59-
})
60-
return
61-
}
62-
63-
// Create conf directory if it doesn't exist
64-
confDir := filepath.Join(traefikConfigDir, "conf")
65-
if err := os.MkdirAll(confDir, 0755); err != nil {
66-
logger.Error("Failed to create conf directory", "error", err, "path", confDir)
67-
c.JSON(http.StatusInternalServerError, models.Response{
68-
Success: false,
69-
Error: fmt.Sprintf("Failed to create conf directory: %v", err),
70-
})
71-
return
72-
}
73-
logger.Info("Ensured conf directory exists", "path", confDir)
74-
75-
// Write captcha.html to conf directory
76-
captchaHTMLPath := filepath.Join(confDir, "captcha.html")
77-
if err := os.WriteFile(captchaHTMLPath, []byte(captchaHTML), 0644); err != nil {
78-
logger.Error("Failed to write captcha.html", "error", err, "path", captchaHTMLPath)
95+
if err := createCaptchaHTML(cfg, req.Provider, req.SiteKey); err != nil {
96+
logger.Error("Failed to create captcha.html", "error", err)
7997
c.JSON(http.StatusInternalServerError, models.Response{
8098
Success: false,
8199
Error: fmt.Sprintf("Failed to create captcha.html: %v", err),
82100
})
83101
return
84102
}
85-
logger.Info("Captcha HTML file created", "path", captchaHTMLPath)
103+
captchaHTMLPath := filepath.Join(cfg.ConfigDir, "traefik", "conf", "captcha.html")
86104

87105
// STEP 2: Update Traefik dynamic_config.yml
88106
logger.Info("Updating Traefik dynamic configuration")
107+
traefikConfigDir := filepath.Join(cfg.ConfigDir, "traefik")
89108
if err := updateTraefikCaptchaConfig(dockerClient, cfg, req, traefikConfigDir); err != nil {
90109
logger.Error("Failed to update Traefik config", "error", err)
91110
c.JSON(http.StatusInternalServerError, models.Response{
@@ -107,33 +126,23 @@ func SetupCaptcha(dockerClient *docker.Client, cfg *config.Config) gin.HandlerFu
107126
}
108127

109128
// STEP 4: Stop and Start Traefik container (for clean reload)
110-
logger.Info("Stopping Traefik container")
111-
if err := dockerClient.StopContainer(cfg.TraefikContainerName); err != nil {
112-
logger.Warn("Failed to stop Traefik", "error", err)
113-
} else {
114-
logger.Info("Traefik stopped successfully")
115-
time.Sleep(1 * time.Second)
116-
}
117-
118-
logger.Info("Starting Traefik container")
119-
if err := dockerClient.StartContainer(cfg.TraefikContainerName); err != nil {
120-
logger.Error("Failed to start Traefik", "error", err)
129+
logger.Info("Restarting Traefik container")
130+
if err := restartTraefikContainer(dockerClient, cfg); err != nil {
131+
logger.Error("Failed to restart Traefik", "error", err)
121132
c.JSON(http.StatusInternalServerError, models.Response{
122133
Success: false,
123-
Error: fmt.Sprintf("Failed to start Traefik after configuration update: %v", err),
134+
Error: fmt.Sprintf("Failed to restart Traefik after configuration update: %v", err),
124135
})
125136
return
126137
}
127-
logger.Info("Traefik started successfully")
128-
time.Sleep(3 * time.Second)
138+
logger.Info("Traefik restarted successfully")
129139

130140
// STEP 5: Restart CrowdSec container
131141
logger.Info("Restarting CrowdSec container")
132-
if err := dockerClient.RestartContainer(cfg.CrowdsecContainerName); err != nil {
142+
if err := restartCrowdSecContainer(dockerClient, cfg); err != nil {
133143
logger.Warn("Failed to restart CrowdSec", "error", err)
134144
} else {
135145
logger.Info("CrowdSec restarted successfully")
136-
time.Sleep(3 * time.Second)
137146
}
138147

139148
// STEP 6: Verify setup
@@ -147,13 +156,13 @@ func SetupCaptcha(dockerClient *docker.Client, cfg *config.Config) gin.HandlerFu
147156
Success: true,
148157
Message: "Captcha configured successfully",
149158
Data: gin.H{
150-
"captcha_html_created": true,
151-
"traefik_config_updated": true,
159+
"captcha_html_created": true,
160+
"traefik_config_updated": true,
152161
"crowdsec_config_updated": true,
153-
"traefik_restarted": true,
154-
"crowdsec_restarted": true,
155-
"verified": verified,
156-
"captcha_html_path": captchaHTMLPath,
162+
"traefik_restarted": true,
163+
"crowdsec_restarted": true,
164+
"verified": verified,
165+
"captcha_html_path": captchaHTMLPath,
157166
},
158167
})
159168
}
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
package handlers
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
"path/filepath"
7+
8+
"crowdsec-manager/internal/config"
9+
"crowdsec-manager/internal/database"
10+
"crowdsec-manager/internal/docker"
11+
"crowdsec-manager/internal/logger"
12+
"crowdsec-manager/internal/models"
13+
14+
"github.com/gin-gonic/gin"
15+
)
16+
17+
// SaveCaptchaConfig persists captcha configuration to the database without applying it.
18+
// This allows users to review the configuration before it is written to config files.
19+
func SaveCaptchaConfig(db *database.Database) gin.HandlerFunc {
20+
return func(c *gin.Context) {
21+
var req models.CaptchaSetupRequest
22+
if err := c.ShouldBindJSON(&req); err != nil {
23+
c.JSON(http.StatusBadRequest, models.Response{
24+
Success: false,
25+
Error: "Invalid request: " + err.Error(),
26+
})
27+
return
28+
}
29+
30+
if req.SiteKey == "" || req.SecretKey == "" {
31+
c.JSON(http.StatusBadRequest, models.Response{
32+
Success: false,
33+
Error: "site_key and secret_key are required",
34+
})
35+
return
36+
}
37+
38+
configJSON, err := json.Marshal(req)
39+
if err != nil {
40+
c.JSON(http.StatusInternalServerError, models.Response{
41+
Success: false,
42+
Error: "Failed to serialize config: " + err.Error(),
43+
})
44+
return
45+
}
46+
47+
if err := db.SaveFeatureConfig("captcha", string(configJSON), "user"); err != nil {
48+
c.JSON(http.StatusInternalServerError, models.Response{
49+
Success: false,
50+
Error: "Failed to save config to database: " + err.Error(),
51+
})
52+
return
53+
}
54+
55+
logger.Info("Captcha config saved to database", "provider", req.Provider)
56+
57+
c.JSON(http.StatusOK, models.Response{
58+
Success: true,
59+
Message: "Captcha configuration saved. Use POST /api/captcha/apply to apply it.",
60+
Data: gin.H{
61+
"provider": req.Provider,
62+
"saved": true,
63+
"next_steps": []string{
64+
"Review the configuration",
65+
"Click Apply to write config files and restart services",
66+
},
67+
},
68+
})
69+
}
70+
}
71+
72+
// ApplyCaptchaConfig reads the captcha configuration saved by SaveCaptchaConfig and applies it
73+
// to all systems: captcha HTML, Traefik dynamic config, CrowdSec profiles, and container restarts.
74+
func ApplyCaptchaConfig(dockerClient *docker.Client, db *database.Database, cfg *config.Config) gin.HandlerFunc {
75+
return func(c *gin.Context) {
76+
dockerClient = resolveDockerClient(c, dockerClient)
77+
78+
// Load config from DB.
79+
featureCfg, err := db.GetFeatureConfig("captcha")
80+
if err != nil || featureCfg == nil {
81+
c.JSON(http.StatusBadRequest, models.Response{
82+
Success: false,
83+
Error: "No captcha configuration found. Save config first via POST /api/captcha/config",
84+
})
85+
return
86+
}
87+
88+
var req models.CaptchaSetupRequest
89+
if err := json.Unmarshal([]byte(featureCfg.ConfigJSON), &req); err != nil {
90+
c.JSON(http.StatusInternalServerError, models.Response{
91+
Success: false,
92+
Error: "Failed to parse saved config: " + err.Error(),
93+
})
94+
return
95+
}
96+
97+
steps := []gin.H{}
98+
99+
// Step 1: Write captcha.html to host filesystem.
100+
htmlErr := createCaptchaHTML(cfg, req.Provider, req.SiteKey)
101+
steps = append(steps, gin.H{
102+
"step": 1,
103+
"name": "Create captcha HTML page",
104+
"success": htmlErr == nil,
105+
"error": errString(htmlErr),
106+
})
107+
108+
// Step 2: Update Traefik dynamic_config.yml.
109+
traefikConfigDir := filepath.Join(cfg.ConfigDir, "traefik")
110+
traefikErr := updateTraefikCaptchaConfig(dockerClient, cfg, req, traefikConfigDir)
111+
steps = append(steps, gin.H{
112+
"step": 2,
113+
"name": "Update Traefik dynamic config",
114+
"success": traefikErr == nil,
115+
"error": errString(traefikErr),
116+
})
117+
118+
// Step 3: Update CrowdSec profiles.yaml.
119+
profilesErr := updateCrowdSecProfiles(dockerClient, cfg)
120+
steps = append(steps, gin.H{
121+
"step": 3,
122+
"name": "Update CrowdSec profiles",
123+
"success": profilesErr == nil,
124+
"error": errString(profilesErr),
125+
})
126+
127+
// Step 4: Restart Traefik (stop + start for clean config reload).
128+
traefikRestartErr := restartTraefikContainer(dockerClient, cfg)
129+
steps = append(steps, gin.H{
130+
"step": 4,
131+
"name": "Restart Traefik",
132+
"success": traefikRestartErr == nil,
133+
"error": errString(traefikRestartErr),
134+
})
135+
136+
// Step 5: Restart CrowdSec.
137+
csRestartErr := restartCrowdSecContainer(dockerClient, cfg)
138+
steps = append(steps, gin.H{
139+
"step": 5,
140+
"name": "Restart CrowdSec",
141+
"success": csRestartErr == nil,
142+
"error": errString(csRestartErr),
143+
})
144+
145+
// Step 6: Verify the full setup.
146+
verified := htmlErr == nil && traefikErr == nil && profilesErr == nil
147+
steps = append(steps, gin.H{
148+
"step": 6,
149+
"name": "Verify setup",
150+
"success": verified,
151+
})
152+
153+
// Mark as applied in DB when all critical steps (HTML + Traefik + profiles) passed.
154+
allCriticalOK := htmlErr == nil && traefikErr == nil && profilesErr == nil
155+
if allCriticalOK {
156+
if markErr := db.MarkFeatureApplied("captcha"); markErr != nil {
157+
logger.Warn("Failed to mark captcha as applied in DB", "error", markErr)
158+
}
159+
}
160+
161+
message := "Captcha applied successfully"
162+
if !allCriticalOK {
163+
message = "Captcha applied with some errors — check step details"
164+
}
165+
166+
c.JSON(http.StatusOK, models.Response{
167+
Success: allCriticalOK,
168+
Message: message,
169+
Data: gin.H{
170+
"steps": steps,
171+
"applied": allCriticalOK,
172+
"provider": req.Provider,
173+
},
174+
})
175+
}
176+
}
177+
178+
// errString safely converts an error to a string, returning "" for nil.
179+
func errString(err error) string {
180+
if err == nil {
181+
return ""
182+
}
183+
return err.Error()
184+
}

0 commit comments

Comments
 (0)