+## 📋 Plan: {Title}
+
+### 🧐 UX & Context Analysis
+{Describe the desired user flow. e.g., "User clicks 'Scan', sees a spinner, then a live list of results."}
+
+### 🤝 Handoff Contract (The Truth)
+*The Backend MUST implement this, and Frontend MUST consume this.*
+```json
+// POST /api/v1/resource
+{
+ "request_payload": { ... },
+ "response_success": {
+ "id": "uuid",
+ "created_at": "ISO8601",
+ "status": "pending" // enums: pending, active, error
+ }
+}
diff --git a/backend/internal/api/handlers/security_handler.go b/backend/internal/api/handlers/security_handler.go
index b0b924de..140ede92 100644
--- a/backend/internal/api/handlers/security_handler.go
+++ b/backend/internal/api/handlers/security_handler.go
@@ -57,9 +57,8 @@ func (h *SecurityHandler) GetStatus(c *gin.Context) {
}
}
- // Treat external crowdsec mode as unsupported in this release. If configured as 'external',
- // present it as disabled so the UI doesn't attempt to call out to an external agent.
- if mode == "external" {
+ // Only allow 'local' as an enabled mode. Any other value should be treated as disabled.
+ if mode != "local" {
mode = "disabled"
apiURL = ""
}
diff --git a/backend/internal/api/handlers/security_handler_clean_test.go b/backend/internal/api/handlers/security_handler_clean_test.go
index 28087463..cbc959cf 100644
--- a/backend/internal/api/handlers/security_handler_clean_test.go
+++ b/backend/internal/api/handlers/security_handler_clean_test.go
@@ -181,7 +181,7 @@ func TestSecurityHandler_CrowdSec_ExternalMappedToDisabled_DBOverride(t *testing
gin.SetMode(gin.TestMode)
db := setupTestDB(t)
// set DB to configure crowdsec.mode to external
- if err := db.Create(&models.Setting{Key: "security.crowdsec.mode", Value: "external"}).Error; err != nil {
+ if err := db.Create(&models.Setting{Key: "security.crowdsec.mode", Value: "unknown"}).Error; err != nil {
t.Fatalf("failed to insert setting: %v", err)
}
cfg := config.SecurityConfig{CrowdSecMode: "local"}
@@ -204,7 +204,7 @@ func TestSecurityHandler_CrowdSec_ExternalMappedToDisabled_DBOverride(t *testing
func TestSecurityHandler_ExternalModeMappedToDisabled(t *testing.T) {
gin.SetMode(gin.TestMode)
cfg := config.SecurityConfig{
- CrowdSecMode: "external",
+ CrowdSecMode: "unknown",
WAFMode: "disabled",
RateLimitMode: "disabled",
ACLMode: "disabled",
diff --git a/backend/internal/caddy/client_test.go b/backend/internal/caddy/client_test.go
index de96c091..98fa9bd6 100644
--- a/backend/internal/caddy/client_test.go
+++ b/backend/internal/caddy/client_test.go
@@ -31,7 +31,7 @@ func TestClient_Load_Success(t *testing.T) {
ForwardPort: 8080,
Enabled: true,
},
- }, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, true, "")
+ }, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, true, "", nil, nil, nil)
err := client.Load(context.Background(), config)
require.NoError(t, err)
diff --git a/backend/internal/caddy/config.go b/backend/internal/caddy/config.go
index c06dd5ae..4ea37815 100644
--- a/backend/internal/caddy/config.go
+++ b/backend/internal/caddy/config.go
@@ -13,7 +13,7 @@ import (
// GenerateConfig creates a Caddy JSON configuration from proxy hosts.
// This is the core transformation layer from our database model to Caddy config.
-func GenerateConfig(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string) (*Config, error) {
+func GenerateConfig(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, decisions []models.SecurityDecision, secCfg *models.SecurityConfig) (*Config, error) {
// Define log file paths
// We assume storageDir is like ".../data/caddy/data", so we go up to ".../data/logs"
// storageDir is .../data/caddy/data
@@ -202,23 +202,70 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir string, acmeEmail strin
// Build security pre-handlers for this host, in pipeline order.
securityHandlers := make([]Handler, 0)
- // CrowdSec handler (placeholder) — first in pipeline
- if crowdsecEnabled {
- if csH, err := buildCrowdSecHandler(&host); err == nil && csH != nil {
- securityHandlers = append(securityHandlers, csH)
+ // Global decisions (e.g. manual block by IP) are applied first; collect IP blocks where action == "block"
+ decisionIPs := make([]string, 0)
+ for _, d := range decisions {
+ if d.Action == "block" && d.IP != "" {
+ decisionIPs = append(decisionIPs, d.IP)
}
}
+ if len(decisionIPs) > 0 {
+ // Build a subroute to match these remote IPs and serve 403
+ // Admin whitelist exclusion must be applied: exclude adminWhitelist if present
+ // Build matchParts
+ var matchParts []map[string]interface{}
+ matchParts = append(matchParts, map[string]interface{}{"remote_ip": map[string]interface{}{"ranges": decisionIPs}})
+ if adminWhitelist != "" {
+ adminParts := strings.Split(adminWhitelist, ",")
+ trims := make([]string, 0)
+ for _, p := range adminParts {
+ p = strings.TrimSpace(p)
+ if p == "" {
+ continue
+ }
+ trims = append(trims, p)
+ }
+ if len(trims) > 0 {
+ matchParts = append(matchParts, map[string]interface{}{"not": []map[string]interface{}{{"remote_ip": map[string]interface{}{"ranges": trims}}}})
+ }
+ }
+ decHandler := Handler{
+ "handler": "subroute",
+ "routes": []map[string]interface{}{
+ {
+ "match": matchParts,
+ "handle": []map[string]interface{}{
+ {
+ "handler": "static_response",
+ "status_code": 403,
+ "body": "Access denied: Blocked by security decision",
+ },
+ },
+ "terminal": true,
+ },
+ },
+ }
+ // Prepend at the start of securityHandlers so it's evaluated first
+ securityHandlers = append(securityHandlers, decHandler)
+ }
+
+ // CrowdSec handler (placeholder) — first in pipeline. The handler builder
+ // now consumes the runtime flag so we can rely on the computed value
+ // rather than requiring a persisted SecurityConfig row to be present.
+ if csH, err := buildCrowdSecHandler(&host, secCfg, crowdsecEnabled); err == nil && csH != nil {
+ securityHandlers = append(securityHandlers, csH)
+ }
// WAF handler (placeholder)
if wafEnabled {
- if wafH, err := buildWAFHandler(&host); err == nil && wafH != nil {
+ if wafH, err := buildWAFHandler(&host, rulesets, secCfg); err == nil && wafH != nil {
securityHandlers = append(securityHandlers, wafH)
}
}
// Rate Limit handler (placeholder)
if rateLimitEnabled {
- if rlH, err := buildRateLimitHandler(&host); err == nil && rlH != nil {
+ if rlH, err := buildRateLimitHandler(&host, secCfg); err == nil && rlH != nil {
securityHandlers = append(securityHandlers, rlH)
}
}
@@ -641,21 +688,61 @@ func buildACLHandler(acl *models.AccessList, adminWhitelist string) (Handler, er
// buildCrowdSecHandler returns a placeholder CrowdSec handler. In a future
// implementation this can be replaced with a proper Caddy plugin integration
// to call into a local CrowdSec agent.
-func buildCrowdSecHandler(host *models.ProxyHost) (Handler, error) {
- // Placeholder handler to represent CrowdSec in the Caddy pipeline
- return Handler{"handler": "crowdsec"}, nil
+func buildCrowdSecHandler(host *models.ProxyHost, secCfg *models.SecurityConfig, crowdsecEnabled bool) (Handler, error) {
+ // Only add a handler when the computed runtime flag indicates CrowdSec is enabled.
+ // The computed flag incorporates runtime overrides and global Cerberus enablement.
+ if !crowdsecEnabled {
+ return nil, nil
+ }
+ // For now, the local-only mode is supported; crowdsecEnabled implies 'local'
+ h := Handler{"handler": "crowdsec"}
+ h["mode"] = "local"
+ return h, nil
}
// buildWAFHandler returns a placeholder WAF handler (Coraza) configuration.
// This is a stub; integration with a Coraza caddy plugin would be required
// for real runtime enforcement.
-func buildWAFHandler(host *models.ProxyHost) (Handler, error) {
- return Handler{"handler": "coraza"}, nil
+func buildWAFHandler(host *models.ProxyHost, rulesets []models.SecurityRuleSet, secCfg *models.SecurityConfig) (Handler, error) {
+ // Find a ruleset to associate with WAF; prefer name match by host.Application or default 'owasp-crs'
+ var selected *models.SecurityRuleSet
+ for i, r := range rulesets {
+ if r.Name == "owasp-crs" || r.Name == host.Application || (secCfg != nil && r.Name == secCfg.WAFRulesSource) {
+ selected = &rulesets[i]
+ break
+ }
+ }
+
+ h := Handler{"handler": "coraza"}
+ if selected != nil {
+ h["ruleset_name"] = selected.Name
+ h["ruleset_content"] = selected.Content
+ } else if secCfg != nil && secCfg.WAFRulesSource != "" {
+ // If there was a requested ruleset name but nothing matched, include it as a reference
+ h["ruleset_name"] = secCfg.WAFRulesSource
+ }
+ // Learning mode flag
+ if secCfg != nil && secCfg.WAFLearning {
+ h["mode"] = "monitor"
+ } else if secCfg != nil && secCfg.WAFMode == "disabled" {
+ return nil, nil
+ } else if secCfg != nil {
+ h["mode"] = secCfg.WAFMode
+ } else {
+ h["mode"] = "disabled"
+ }
+ return h, nil
}
// buildRateLimitHandler returns a placeholder for a rate-limit handler.
// Real implementation should use the relevant Caddy module/plugin when available.
-func buildRateLimitHandler(host *models.ProxyHost) (Handler, error) {
+func buildRateLimitHandler(host *models.ProxyHost, secCfg *models.SecurityConfig) (Handler, error) {
// If host has custom rate limit metadata we could parse and construct it.
- return Handler{"handler": "rate_limit"}, nil
+ h := Handler{"handler": "rate_limit"}
+ if secCfg != nil && secCfg.RateLimitRequests > 0 && secCfg.RateLimitWindowSec > 0 {
+ h["requests"] = secCfg.RateLimitRequests
+ h["window_sec"] = secCfg.RateLimitWindowSec
+ h["burst"] = secCfg.RateLimitBurst
+ }
+ return h, nil
}
diff --git a/backend/internal/caddy/config_extra_test.go b/backend/internal/caddy/config_extra_test.go
index 934aa4a6..af1cde9b 100644
--- a/backend/internal/caddy/config_extra_test.go
+++ b/backend/internal/caddy/config_extra_test.go
@@ -10,7 +10,7 @@ import (
)
func TestGenerateConfig_CatchAllFrontend(t *testing.T) {
- cfg, err := GenerateConfig([]models.ProxyHost{}, "/tmp/caddy-data", "", "/frontend/dist", "", false, false, false, false, false, "")
+ cfg, err := GenerateConfig([]models.ProxyHost{}, "/tmp/caddy-data", "", "/frontend/dist", "", false, false, false, false, false, "", nil, nil, nil)
require.NoError(t, err)
server := cfg.Apps.HTTP.Servers["charon_server"]
require.NotNil(t, server)
@@ -32,7 +32,7 @@ func TestGenerateConfig_AdvancedInvalidJSON(t *testing.T) {
},
}
- cfg, err := GenerateConfig(hosts, "/tmp/caddy-data", "", "", "", false, false, false, false, false, "")
+ cfg, err := GenerateConfig(hosts, "/tmp/caddy-data", "", "", "", false, false, false, false, false, "", nil, nil, nil)
require.NoError(t, err)
server := cfg.Apps.HTTP.Servers["charon_server"]
require.NotNil(t, server)
@@ -63,7 +63,7 @@ func TestGenerateConfig_AdvancedArrayHandler(t *testing.T) {
},
}
- cfg, err := GenerateConfig(hosts, "/tmp/caddy-data", "", "", "", false, false, false, false, false, "")
+ cfg, err := GenerateConfig(hosts, "/tmp/caddy-data", "", "", "", false, false, false, false, false, "", nil, nil, nil)
require.NoError(t, err)
server := cfg.Apps.HTTP.Servers["charon_server"]
require.NotNil(t, server)
@@ -77,7 +77,7 @@ func TestGenerateConfig_LowercaseDomains(t *testing.T) {
hosts := []models.ProxyHost{
{UUID: "d1", DomainNames: "UPPER.EXAMPLE.COM", ForwardHost: "a", ForwardPort: 80, Enabled: true},
}
- cfg, err := GenerateConfig(hosts, "/tmp/caddy-data", "", "", "", false, false, false, false, false, "")
+ cfg, err := GenerateConfig(hosts, "/tmp/caddy-data", "", "", "", false, false, false, false, false, "", nil, nil, nil)
require.NoError(t, err)
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
// Debug prints removed
@@ -93,7 +93,7 @@ func TestGenerateConfig_AdvancedObjectHandler(t *testing.T) {
Enabled: true,
AdvancedConfig: `{"handler":"headers","response":{"set":{"X-Obj":["1"]}}}`,
}
- cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, true, "")
+ cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, true, "", nil, nil, nil)
require.NoError(t, err)
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
// First handler should be headers
@@ -110,7 +110,7 @@ func TestGenerateConfig_AdvancedHeadersStringToArray(t *testing.T) {
Enabled: true,
AdvancedConfig: `{"handler":"headers","request":{"set":{"Upgrade":"websocket"}},"response":{"set":{"X-Obj":"1"}}}`,
}
- cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, true, "")
+ cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, true, "", nil, nil, nil)
require.NoError(t, err)
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
// Debug prints removed
@@ -170,7 +170,7 @@ func TestGenerateConfig_ACLWhitelistIncluded(t *testing.T) {
aclH, err := buildACLHandler(&acl, "")
require.NoError(t, err)
require.NotNil(t, aclH)
- cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, false, "")
+ cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, false, "", nil, nil, nil)
require.NoError(t, err)
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
// Accept either a subroute (ACL) or reverse_proxy as first handler
@@ -182,7 +182,7 @@ func TestGenerateConfig_ACLWhitelistIncluded(t *testing.T) {
func TestGenerateConfig_SkipsEmptyDomainEntries(t *testing.T) {
hosts := []models.ProxyHost{{UUID: "u1", DomainNames: ", test.example.com", ForwardHost: "a", ForwardPort: 80, Enabled: true}}
- cfg, err := GenerateConfig(hosts, "/tmp/caddy-data", "", "", "", false, false, false, false, false, "")
+ cfg, err := GenerateConfig(hosts, "/tmp/caddy-data", "", "", "", false, false, false, false, false, "", nil, nil, nil)
require.NoError(t, err)
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
require.Equal(t, []string{"test.example.com"}, route.Match[0].Host)
@@ -190,7 +190,7 @@ func TestGenerateConfig_SkipsEmptyDomainEntries(t *testing.T) {
func TestGenerateConfig_AdvancedNoHandlerKey(t *testing.T) {
host := models.ProxyHost{UUID: "adv3", DomainNames: "nohandler.example.com", ForwardHost: "app", ForwardPort: 8080, Enabled: true, AdvancedConfig: `{"foo":"bar"}`}
- cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, false, "")
+ cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, false, "", nil, nil, nil)
require.NoError(t, err)
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
// No headers handler appended; last handler is reverse_proxy
@@ -200,7 +200,7 @@ func TestGenerateConfig_AdvancedNoHandlerKey(t *testing.T) {
func TestGenerateConfig_AdvancedUnexpectedJSONStructure(t *testing.T) {
host := models.ProxyHost{UUID: "adv4", DomainNames: "struct.example.com", ForwardHost: "app", ForwardPort: 8080, Enabled: true, AdvancedConfig: `42`}
- cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, false, "")
+ cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, false, "", nil, nil, nil)
require.NoError(t, err)
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
// Expect main reverse proxy handler exists but no appended advanced handler
@@ -222,7 +222,8 @@ func TestGenerateConfig_SecurityPipeline_Order(t *testing.T) {
acl := models.AccessList{ID: 200, Name: "WL", Enabled: true, Type: "whitelist", IPRules: ipRules}
host := models.ProxyHost{UUID: "pipeline1", DomainNames: "pipe.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080, AccessListID: &acl.ID, AccessList: &acl, HSTSEnabled: true, BlockExploits: true}
- cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, true, true, true, true, "")
+ secCfg := &models.SecurityConfig{CrowdSecMode: "local"}
+ cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, true, true, true, true, "", nil, nil, secCfg)
require.NoError(t, err)
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
@@ -245,7 +246,7 @@ func TestGenerateConfig_SecurityPipeline_Order(t *testing.T) {
func TestGenerateConfig_SecurityPipeline_OmitWhenDisabled(t *testing.T) {
host := models.ProxyHost{UUID: "pipe2", DomainNames: "pipe2.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080}
- cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, false, "")
+ cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, false, "", nil, nil, nil)
require.NoError(t, err)
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
diff --git a/backend/internal/caddy/config_generate_additional_test.go b/backend/internal/caddy/config_generate_additional_test.go
index e2b2db32..cef0a9f5 100644
--- a/backend/internal/caddy/config_generate_additional_test.go
+++ b/backend/internal/caddy/config_generate_additional_test.go
@@ -22,7 +22,7 @@ func TestGenerateConfig_ZerosslAndBothProviders(t *testing.T) {
}
// Zerossl provider
- cfgZ, err := GenerateConfig(hosts, "/data/caddy/data", "admin@example.com", "/frontend/dist", "zerossl", false, false, false, false, false, "")
+ cfgZ, err := GenerateConfig(hosts, "/data/caddy/data", "admin@example.com", "/frontend/dist", "zerossl", false, false, false, false, false, "", nil, nil, nil)
require.NoError(t, err)
require.NotNil(t, cfgZ.Apps.TLS)
// Expect only zerossl issuer present
@@ -37,7 +37,7 @@ func TestGenerateConfig_ZerosslAndBothProviders(t *testing.T) {
require.True(t, foundZerossl)
// Default/both provider
- cfgBoth, err := GenerateConfig(hosts, "/data/caddy/data", "admin@example.com", "/frontend/dist", "", false, false, false, false, false, "")
+ cfgBoth, err := GenerateConfig(hosts, "/data/caddy/data", "admin@example.com", "/frontend/dist", "", false, false, false, false, false, "", nil, nil, nil)
require.NoError(t, err)
issuersBoth := cfgBoth.Apps.TLS.Automation.Policies[0].IssuersRaw
// We should have at least 2 issuers (acme + zerossl)
@@ -50,7 +50,8 @@ func TestGenerateConfig_SecurityPipeline_Order_Locations(t *testing.T) {
acl := models.AccessList{ID: 201, Name: "WL2", Enabled: true, Type: "whitelist", IPRules: ipRules}
host := models.ProxyHost{UUID: "pipeline2", DomainNames: "pipe-loc.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080, AccessListID: &acl.ID, AccessList: &acl, HSTSEnabled: true, BlockExploits: true, Locations: []models.Location{{Path: "/loc", ForwardHost: "app", ForwardPort: 9000}}}
- cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, true, true, true, true, "")
+ sec := &models.SecurityConfig{CrowdSecMode: "local"}
+ cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, true, true, true, true, "", nil, nil, sec)
require.NoError(t, err)
server := cfg.Apps.HTTP.Servers["charon_server"]
@@ -95,7 +96,7 @@ func TestGenerateConfig_ACLLogWarning(t *testing.T) {
acl := models.AccessList{ID: 300, Name: "BadACL", Enabled: true, Type: "blacklist", IPRules: "invalid-json"}
host := models.ProxyHost{UUID: "acl-log", DomainNames: "acl-err.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080, AccessListID: &acl.ID, AccessList: &acl}
- cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, true, "")
+ cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, true, "", nil, nil, nil)
require.NoError(t, err)
require.NotNil(t, cfg)
@@ -107,7 +108,7 @@ func TestGenerateConfig_ACLHandlerIncluded(t *testing.T) {
ipRules := `[ { "cidr": "10.0.0.0/8" } ]`
acl := models.AccessList{ID: 301, Name: "WL3", Enabled: true, Type: "whitelist", IPRules: ipRules}
host := models.ProxyHost{UUID: "acl-incl", DomainNames: "acl-incl.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080, AccessListID: &acl.ID, AccessList: &acl}
- cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, true, "")
+ cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, true, "", nil, nil, nil)
require.NoError(t, err)
server := cfg.Apps.HTTP.Servers["charon_server"]
require.NotNil(t, server)
@@ -131,8 +132,201 @@ func TestGenerateConfig_ACLHandlerIncluded(t *testing.T) {
require.True(t, found)
}
+func TestGenerateConfig_DecisionsBlockWithAdminExclusion(t *testing.T) {
+ host := models.ProxyHost{UUID: "dec1", DomainNames: "dec.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080}
+ // create a security decision to block 1.2.3.4
+ dec := models.SecurityDecision{Action: "block", IP: "1.2.3.4"}
+ cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, false, "10.0.0.1/32", nil, []models.SecurityDecision{dec}, nil)
+ require.NoError(t, err)
+ route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
+ // Expect first security handler is a subroute that includes both remote_ip and a 'not' exclusion for adminWhitelist
+ found := false
+ for _, h := range route.Handle {
+ // convert to JSON string and assert the expected fields exist
+ b, _ := json.Marshal(h)
+ s := string(b)
+ if strings.Contains(s, "\"remote_ip\"") && strings.Contains(s, "\"not\"") && strings.Contains(s, "1.2.3.4") && strings.Contains(s, "10.0.0.1/32") {
+ found = true
+ break
+ }
+ }
+ if !found {
+ // Log the route handles for debugging
+ for i, h := range route.Handle {
+ b, _ := json.MarshalIndent(h, " ", " ")
+ t.Logf("handler #%d: %s", i, string(b))
+ }
+ }
+ require.True(t, found, "expected decision subroute with admin exclusion to be present")
+}
+
+func TestGenerateConfig_WAFModeAndRulesetReference(t *testing.T) {
+ host := models.ProxyHost{UUID: "wafref", DomainNames: "wafref.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080}
+ // No rulesets provided but secCfg references a rulesource
+ sec := &models.SecurityConfig{WAFMode: "block", WAFRulesSource: "nonexistent-rs"}
+ cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, true, false, false, "", nil, nil, sec)
+ require.NoError(t, err)
+ // Since a ruleset name was requested but none exists, coraza handler should include ruleset_name but no ruleset_content
+ route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
+ found := false
+ for _, h := range route.Handle {
+ if hn, ok := h["handler"].(string); ok && hn == "coraza" {
+ if rn, ok := h["ruleset_name"].(string); ok && rn == "nonexistent-rs" {
+ if _, ok2 := h["ruleset_content"]; !ok2 {
+ found = true
+ }
+ }
+ }
+ }
+ require.True(t, found, "expected coraza handler with ruleset_name reference but without content")
+
+ // Now test learning/monitor mode mapping
+ sec2 := &models.SecurityConfig{WAFMode: "block", WAFLearning: true}
+ cfg2, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, true, false, false, "", nil, nil, sec2)
+ require.NoError(t, err)
+ route2 := cfg2.Apps.HTTP.Servers["charon_server"].Routes[0]
+ monitorFound := false
+ for _, h := range route2.Handle {
+ if hn, ok := h["handler"].(string); ok && hn == "coraza" {
+ if mode, ok := h["mode"].(string); ok && mode == "monitor" {
+ monitorFound = true
+ }
+ }
+ }
+ require.True(t, monitorFound, "expected coraza handler with mode=monitor when WAFLearning is true")
+}
+
+func TestGenerateConfig_WAFModeDisabledSkipsHandler(t *testing.T) {
+ host := models.ProxyHost{UUID: "waf-disabled", DomainNames: "wafd.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080}
+ sec := &models.SecurityConfig{WAFMode: "disabled", WAFRulesSource: "owasp-crs"}
+ cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, true, false, false, "", nil, nil, sec)
+ require.NoError(t, err)
+ route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
+ for _, h := range route.Handle {
+ if hn, ok := h["handler"].(string); ok && hn == "coraza" {
+ t.Fatalf("expected NO coraza handler when WAFMode disabled, found: %v", h)
+ }
+ }
+}
+
+func TestGenerateConfig_WAFSelectedSetsContentAndMode(t *testing.T) {
+ host := models.ProxyHost{UUID: "waf-2", DomainNames: "waf2.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080}
+ rs := models.SecurityRuleSet{Name: "owasp-crs", SourceURL: "http://example.com/owasp", Content: "rule 1"}
+ sec := &models.SecurityConfig{WAFMode: "block"}
+ cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, true, false, false, "", []models.SecurityRuleSet{rs}, nil, sec)
+ require.NoError(t, err)
+ route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
+ found := false
+ for _, h := range route.Handle {
+ if hn, ok := h["handler"].(string); ok && hn == "coraza" {
+ if rn, ok := h["ruleset_name"].(string); ok && rn == "owasp-crs" {
+ if rc, ok := h["ruleset_content"].(string); ok && rc == "rule 1" {
+ if mode, ok := h["mode"].(string); ok && mode == "block" {
+ found = true
+ }
+ }
+ }
+ }
+ }
+ require.True(t, found, "expected coraza handler with ruleset_content and mode=block to be present")
+}
+
+func TestGenerateConfig_DecisionAdminPartsEmpty(t *testing.T) {
+ host := models.ProxyHost{UUID: "dec2", DomainNames: "dec2.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080}
+ dec := models.SecurityDecision{Action: "block", IP: "2.3.4.5"}
+ // Provide an adminWhitelist with an empty segment to trigger p == ""
+ cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, false, ", 10.0.0.1/32", nil, []models.SecurityDecision{dec}, nil)
+ require.NoError(t, err)
+ route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
+ found := false
+ for _, h := range route.Handle {
+ b, _ := json.Marshal(h)
+ s := string(b)
+ if strings.Contains(s, "\"remote_ip\"") && strings.Contains(s, "\"not\"") && strings.Contains(s, "2.3.4.5") {
+ found = true
+ break
+ }
+ }
+ require.True(t, found, "expected decision subroute with admin exclusion present when adminWhitelist contains empty parts")
+}
+
+func TestNormalizeHeaderOps_PreserveStringArray(t *testing.T) {
+ // Construct a headers map where set has a []string value already
+ set := map[string]interface{}{
+ "X-Array": []string{"1", "2"},
+ }
+ headerOps := map[string]interface{}{"set": set}
+ normalizeHeaderOps(headerOps)
+ // Ensure the value remained a []string
+ if v, ok := headerOps["set"].(map[string]interface{}); ok {
+ if arr, ok := v["X-Array"].([]string); ok {
+ require.Equal(t, []string{"1", "2"}, arr)
+ return
+ }
+ }
+ t.Fatal("expected set.X-Array to remain []string")
+}
+
+func TestGenerateConfig_WAFUsesRuleSet(t *testing.T) {
+ // host + ruleset configured
+ host := models.ProxyHost{UUID: "waf-1", DomainNames: "waf.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080}
+ rs := models.SecurityRuleSet{Name: "owasp-crs", SourceURL: "http://example.com/owasp", Content: "rule 1"}
+ cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, true, false, false, "", []models.SecurityRuleSet{rs}, nil, nil)
+ require.NoError(t, err)
+ route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
+ // check coraza handler present with ruleset_name
+ found := false
+ for _, h := range route.Handle {
+ if hn, ok := h["handler"].(string); ok && hn == "coraza" {
+ if rn, ok := h["ruleset_name"].(string); ok && rn == "owasp-crs" {
+ found = true
+ break
+ }
+ }
+ }
+ require.True(t, found, "coraza handler with ruleset should be present")
+}
+
+func TestGenerateConfig_RateLimitFromSecCfg(t *testing.T) {
+ host := models.ProxyHost{UUID: "rl-1", DomainNames: "rl.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080}
+ sec := &models.SecurityConfig{RateLimitRequests: 10, RateLimitWindowSec: 60, RateLimitBurst: 5}
+ cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, true, false, "", nil, nil, sec)
+ require.NoError(t, err)
+ route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
+ found := false
+ for _, h := range route.Handle {
+ if hn, ok := h["handler"].(string); ok && hn == "rate_limit" {
+ if req, ok := h["requests"].(int); ok && req == 10 {
+ if win, ok := h["window_sec"].(int); ok && win == 60 {
+ found = true
+ break
+ }
+ }
+ }
+ }
+ require.True(t, found, "rate_limit handler with configured values should be present")
+}
+
+func TestGenerateConfig_CrowdSecHandlerFromSecCfg(t *testing.T) {
+ host := models.ProxyHost{UUID: "cs-1", DomainNames: "cs.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080}
+ sec := &models.SecurityConfig{CrowdSecMode: "local", CrowdSecAPIURL: "http://cs.local"}
+ cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, true, false, false, false, "", nil, nil, sec)
+ require.NoError(t, err)
+ route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
+ found := false
+ for _, h := range route.Handle {
+ if hn, ok := h["handler"].(string); ok && hn == "crowdsec" {
+ if mode, ok := h["mode"].(string); ok && mode == "local" {
+ found = true
+ break
+ }
+ }
+ }
+ require.True(t, found, "crowdsec handler with api_url and mode should be present")
+}
+
func TestGenerateConfig_EmptyHostsAndNoFrontend(t *testing.T) {
- cfg, err := GenerateConfig([]models.ProxyHost{}, "/data/caddy/data", "", "", "", false, false, false, false, false, "")
+ cfg, err := GenerateConfig([]models.ProxyHost{}, "/data/caddy/data", "", "", "", false, false, false, false, false, "", nil, nil, nil)
require.NoError(t, err)
// Should return base config without server routes
_, found := cfg.Apps.HTTP.Servers["charon_server"]
@@ -144,7 +338,7 @@ func TestGenerateConfig_SkipsInvalidCustomCert(t *testing.T) {
cert := models.SSLCertificate{ID: 1, UUID: "c1", Name: "CustomCert", Provider: "custom", Certificate: "cert", PrivateKey: ""}
host := models.ProxyHost{UUID: "h1", DomainNames: "a.example.com", Enabled: true, ForwardHost: "127.0.0.1", ForwardPort: 8080, Certificate: &cert, CertificateID: ptrUint(1)}
- cfg, err := GenerateConfig([]models.ProxyHost{host}, "/data/caddy/data", "", "/frontend/dist", "", false, false, false, false, true, "")
+ cfg, err := GenerateConfig([]models.ProxyHost{host}, "/data/caddy/data", "", "/frontend/dist", "", false, false, false, false, true, "", nil, nil, nil)
require.NoError(t, err)
// Custom cert missing key should not be in LoadPEM
if cfg.Apps.TLS != nil && cfg.Apps.TLS.Certificates != nil {
@@ -157,7 +351,7 @@ func TestGenerateConfig_SkipsDuplicateDomains(t *testing.T) {
// Two hosts with same domain - one newer than other should be kept only once
h1 := models.ProxyHost{UUID: "h1", DomainNames: "dup.com", Enabled: true, ForwardHost: "127.0.0.1", ForwardPort: 8080}
h2 := models.ProxyHost{UUID: "h2", DomainNames: "dup.com", Enabled: true, ForwardHost: "127.0.0.2", ForwardPort: 8081}
- cfg, err := GenerateConfig([]models.ProxyHost{h1, h2}, "/data/caddy/data", "", "/frontend/dist", "", false, false, false, false, false, "")
+ cfg, err := GenerateConfig([]models.ProxyHost{h1, h2}, "/data/caddy/data", "", "/frontend/dist", "", false, false, false, false, false, "", nil, nil, nil)
require.NoError(t, err)
server := cfg.Apps.HTTP.Servers["charon_server"]
// Expect that only one route exists for dup.com (one for the domain)
@@ -167,7 +361,7 @@ func TestGenerateConfig_SkipsDuplicateDomains(t *testing.T) {
func TestGenerateConfig_LoadPEMSetsTLSWhenNoACME(t *testing.T) {
cert := models.SSLCertificate{ID: 1, UUID: "c1", Name: "LoadPEM", Provider: "custom", Certificate: "cert", PrivateKey: "key"}
host := models.ProxyHost{UUID: "h1", DomainNames: "pem.com", Enabled: true, ForwardHost: "127.0.0.1", ForwardPort: 8080, Certificate: &cert, CertificateID: &cert.ID}
- cfg, err := GenerateConfig([]models.ProxyHost{host}, "/data/caddy/data", "", "/frontend/dist", "", false, false, false, false, true, "")
+ cfg, err := GenerateConfig([]models.ProxyHost{host}, "/data/caddy/data", "", "/frontend/dist", "", false, false, false, false, true, "", nil, nil, nil)
require.NoError(t, err)
require.NotNil(t, cfg.Apps.TLS)
require.NotNil(t, cfg.Apps.TLS.Certificates)
@@ -175,7 +369,7 @@ func TestGenerateConfig_LoadPEMSetsTLSWhenNoACME(t *testing.T) {
func TestGenerateConfig_DefaultAcmeStaging(t *testing.T) {
hosts := []models.ProxyHost{{UUID: "h1", DomainNames: "a.example.com", Enabled: true, ForwardHost: "127.0.0.1", ForwardPort: 8080}}
- cfg, err := GenerateConfig(hosts, "/data/caddy/data", "admin@example.com", "/frontend/dist", "", true, false, false, false, false, "")
+ cfg, err := GenerateConfig(hosts, "/data/caddy/data", "admin@example.com", "/frontend/dist", "", true, false, false, false, false, "", nil, nil, nil)
require.NoError(t, err)
// Should include acme issuer with CA staging URL
issuers := cfg.Apps.TLS.Automation.Policies[0].IssuersRaw
@@ -196,7 +390,7 @@ func TestGenerateConfig_ACLHandlerBuildError(t *testing.T) {
// create host with an ACL with invalid JSON to force buildACLHandler to error
acl := models.AccessList{ID: 10, Name: "BadACL", Enabled: true, Type: "blacklist", IPRules: "invalid"}
host := models.ProxyHost{UUID: "h1", DomainNames: "a.example.com", Enabled: true, ForwardHost: "127.0.0.1", ForwardPort: 8080, AccessListID: &acl.ID, AccessList: &acl}
- cfg, err := GenerateConfig([]models.ProxyHost{host}, "/data/caddy/data", "", "/frontend/dist", "", false, false, false, false, false, "")
+ cfg, err := GenerateConfig([]models.ProxyHost{host}, "/data/caddy/data", "", "/frontend/dist", "", false, false, false, false, false, "", nil, nil, nil)
require.NoError(t, err)
server := cfg.Apps.HTTP.Servers["charon_server"]
// Even if ACL handler error occurs, config should still be returned with routes
@@ -207,7 +401,7 @@ func TestGenerateConfig_ACLHandlerBuildError(t *testing.T) {
func TestGenerateConfig_SkipHostDomainEmptyAndDisabled(t *testing.T) {
disabled := models.ProxyHost{UUID: "h1", Enabled: false, DomainNames: "skip.com", ForwardHost: "127.0.0.1", ForwardPort: 8080}
emptyDomain := models.ProxyHost{UUID: "h2", Enabled: true, DomainNames: "", ForwardHost: "127.0.0.1", ForwardPort: 8080}
- cfg, err := GenerateConfig([]models.ProxyHost{disabled, emptyDomain}, "/data/caddy/data", "", "/frontend/dist", "", false, false, false, false, false, "")
+ cfg, err := GenerateConfig([]models.ProxyHost{disabled, emptyDomain}, "/data/caddy/data", "", "/frontend/dist", "", false, false, false, false, false, "", nil, nil, nil)
require.NoError(t, err)
server := cfg.Apps.HTTP.Servers["charon_server"]
// Both hosts should be skipped; only routes from no hosts should be only catch-all if frontend provided
diff --git a/backend/internal/caddy/config_generate_test.go b/backend/internal/caddy/config_generate_test.go
index 740a999f..1bd1ee0e 100644
--- a/backend/internal/caddy/config_generate_test.go
+++ b/backend/internal/caddy/config_generate_test.go
@@ -24,7 +24,7 @@ func TestGenerateConfig_CustomCertsAndTLS(t *testing.T) {
Locations: []models.Location{{Path: "/app", ForwardHost: "127.0.0.1", ForwardPort: 8081}},
},
}
- cfg, err := GenerateConfig(hosts, "/data/caddy/data", "admin@example.com", "/frontend/dist", "letsencrypt", true, false, false, false, false, "")
+ cfg, err := GenerateConfig(hosts, "/data/caddy/data", "admin@example.com", "/frontend/dist", "letsencrypt", true, false, false, false, false, "", nil, nil, nil)
require.NoError(t, err)
require.NotNil(t, cfg)
// TLS should be configured
diff --git a/backend/internal/caddy/config_test.go b/backend/internal/caddy/config_test.go
index d3cadd49..4e67d5ea 100644
--- a/backend/internal/caddy/config_test.go
+++ b/backend/internal/caddy/config_test.go
@@ -10,8 +10,10 @@ import (
)
func TestGenerateConfig_Empty(t *testing.T) {
- config, err := GenerateConfig([]models.ProxyHost{}, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, true, "")
+ config, err := GenerateConfig([]models.ProxyHost{}, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, true, "", nil, nil, nil)
require.NoError(t, err)
+ require.NotNil(t, config.Apps.HTTP)
+ require.Empty(t, config.Apps.HTTP.Servers)
require.NotNil(t, config)
require.NotNil(t, config.Apps.HTTP)
require.Empty(t, config.Apps.HTTP.Servers)
@@ -32,8 +34,10 @@ func TestGenerateConfig_SingleHost(t *testing.T) {
},
}
- config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, true, "")
+ config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, true, "", nil, nil, nil)
require.NoError(t, err)
+ require.NotNil(t, config.Apps.HTTP)
+ require.Len(t, config.Apps.HTTP.Servers, 1)
require.NotNil(t, config)
require.NotNil(t, config.Apps.HTTP)
require.Len(t, config.Apps.HTTP.Servers, 1)
@@ -72,9 +76,10 @@ func TestGenerateConfig_MultipleHosts(t *testing.T) {
},
}
- config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, true, "")
+ config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, true, "", nil, nil, nil)
require.NoError(t, err)
require.Len(t, config.Apps.HTTP.Servers["charon_server"].Routes, 2)
+ require.Len(t, config.Apps.HTTP.Servers["charon_server"].Routes, 2)
}
func TestGenerateConfig_WebSocketEnabled(t *testing.T) {
@@ -88,8 +93,9 @@ func TestGenerateConfig_WebSocketEnabled(t *testing.T) {
Enabled: true,
},
}
- config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, true, "")
+ config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, true, "", nil, nil, nil)
require.NoError(t, err)
+ require.NotNil(t, config.Apps.HTTP)
route := config.Apps.HTTP.Servers["charon_server"].Routes[0]
handler := route.Handle[0]
@@ -109,16 +115,18 @@ func TestGenerateConfig_EmptyDomain(t *testing.T) {
},
}
- config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, false, "")
+ config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, false, "", nil, nil, nil)
require.NoError(t, err)
+ require.Empty(t, config.Apps.HTTP.Servers["charon_server"].Routes)
// Should produce empty routes (or just catch-all if frontendDir was set, but it's empty here)
require.Empty(t, config.Apps.HTTP.Servers["charon_server"].Routes)
}
func TestGenerateConfig_Logging(t *testing.T) {
hosts := []models.ProxyHost{}
- config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, false, "")
+ config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, false, "", nil, nil, nil)
require.NoError(t, err)
+ require.NotNil(t, config.Logging)
// Verify logging configuration
require.NotNil(t, config.Logging)
@@ -155,9 +163,10 @@ func TestGenerateConfig_Advanced(t *testing.T) {
},
}
- config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, false, "")
+ config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, false, "", nil, nil, nil)
require.NoError(t, err)
require.NotNil(t, config)
+ require.NotNil(t, config)
server := config.Apps.HTTP.Servers["charon_server"]
require.NotNil(t, server)
@@ -202,9 +211,10 @@ func TestGenerateConfig_ACMEStaging(t *testing.T) {
}
// Test with staging enabled
- config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "letsencrypt", true, false, false, false, true, "")
+ config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "letsencrypt", true, false, false, false, true, "", nil, nil, nil)
require.NoError(t, err)
require.NotNil(t, config.Apps.TLS)
+ require.NotNil(t, config.Apps.TLS)
require.NotNil(t, config.Apps.TLS.Automation)
require.Len(t, config.Apps.TLS.Automation.Policies, 1)
@@ -217,7 +227,7 @@ func TestGenerateConfig_ACMEStaging(t *testing.T) {
require.Equal(t, "https://acme-staging-v02.api.letsencrypt.org/directory", acmeIssuer["ca"])
// Test with staging disabled (production)
- config, err = GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "letsencrypt", false, false, false, false, false, "")
+ config, err = GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "letsencrypt", false, false, false, false, false, "", nil, nil, nil)
require.NoError(t, err)
require.NotNil(t, config.Apps.TLS)
require.NotNil(t, config.Apps.TLS.Automation)
diff --git a/backend/internal/caddy/manager.go b/backend/internal/caddy/manager.go
index f4b121bf..c5cf3ef2 100644
--- a/backend/internal/caddy/manager.go
+++ b/backend/internal/caddy/manager.go
@@ -87,13 +87,26 @@ func (m *Manager) ApplyConfig(ctx context.Context) error {
}
}
+ // Load ruleset metadata (WAF/Coraza) for config generation
+ var rulesets []models.SecurityRuleSet
+ if err := m.db.Find(&rulesets).Error; err != nil {
+ // non-fatal: just log the error and continue with empty rules
+ logger.Log().WithError(err).Warn("failed to load rulesets for generate config")
+ }
+
+ // Load recent security decisions so they can be injected into the generated config
+ var decisions []models.SecurityDecision
+ if err := m.db.Order("created_at desc").Find(&decisions).Error; err != nil {
+ logger.Log().WithError(err).Warn("failed to load security decisions for generate config")
+ }
+
// Generate Caddy config
// Read admin whitelist for config generation so handlers can exclude admin IPs
var adminWhitelist string
if secCfg.AdminWhitelist != "" {
adminWhitelist = secCfg.AdminWhitelist
}
- config, err := generateConfigFunc(hosts, filepath.Join(m.configDir, "data"), acmeEmail, m.frontendDir, sslProvider, m.acmeStaging, crowdsecEnabled, wafEnabled, rateLimitEnabled, aclEnabled, adminWhitelist)
+ config, err := generateConfigFunc(hosts, filepath.Join(m.configDir, "data"), acmeEmail, m.frontendDir, sslProvider, m.acmeStaging, crowdsecEnabled, wafEnabled, rateLimitEnabled, aclEnabled, adminWhitelist, rulesets, decisions, &secCfg)
if err != nil {
return fmt.Errorf("generate config: %w", err)
}
@@ -264,7 +277,8 @@ func (m *Manager) computeEffectiveFlags(ctx context.Context) (cerbEnabled bool,
cerbEnabled = m.securityCfg.CerberusEnabled
wafEnabled = m.securityCfg.WAFMode == "enabled"
rateLimitEnabled = m.securityCfg.RateLimitMode == "enabled"
- crowdsecEnabled = m.securityCfg.CrowdSecMode == "local" || m.securityCfg.CrowdSecMode == "remote" || m.securityCfg.CrowdSecMode == "enabled"
+ // CrowdSec only supports 'local' mode; treat other values as disabled
+ crowdsecEnabled = m.securityCfg.CrowdSecMode == "local"
aclEnabled = m.securityCfg.ACLMode == "enabled"
if m.db != nil {
@@ -286,10 +300,8 @@ func (m *Manager) computeEffectiveFlags(ctx context.Context) (cerbEnabled bool,
// runtime override for crowdsec mode (mode value determines whether it's local/remote/enabled)
var cm struct{ Value string }
if err := m.db.Raw("SELECT value FROM settings WHERE key = ? LIMIT 1", "security.crowdsec.mode").Scan(&cm).Error; err == nil && cm.Value != "" {
- // If crowdsec mode is external, we mark it disabled for our plugin
- if cm.Value == "external" {
- crowdsecEnabled = false
- } else if cm.Value == "local" || cm.Value == "remote" || cm.Value == "enabled" {
+ // Only 'local' runtime mode enables CrowdSec; all other values are disabled
+ if cm.Value == "local" {
crowdsecEnabled = true
} else {
crowdsecEnabled = false
diff --git a/backend/internal/caddy/manager_additional_test.go b/backend/internal/caddy/manager_additional_test.go
index caf4b7bb..16a232ac 100644
--- a/backend/internal/caddy/manager_additional_test.go
+++ b/backend/internal/caddy/manager_additional_test.go
@@ -419,7 +419,7 @@ func TestManager_ApplyConfig_GenerateConfigFails(t *testing.T) {
// stub generateConfigFunc to always return error
orig := generateConfigFunc
- generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string) (*Config, error) {
+ generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, decisions []models.SecurityDecision, secCfg *models.SecurityConfig) (*Config, error) {
return nil, fmt.Errorf("generate fail")
}
defer func() { generateConfigFunc = orig }()
@@ -581,7 +581,7 @@ func TestManager_ApplyConfig_PassesAdminWhitelistToGenerateConfig(t *testing.T)
// Stub generateConfigFunc to capture adminWhitelist
var capturedAdmin string
orig := generateConfigFunc
- generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string) (*Config, error) {
+ generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, decisions []models.SecurityDecision, secCfg *models.SecurityConfig) (*Config, error) {
capturedAdmin = adminWhitelist
// return minimal config
return &Config{Apps: Apps{HTTP: &HTTPApp{Servers: map[string]*Server{}}}}, nil
@@ -594,6 +594,57 @@ func TestManager_ApplyConfig_PassesAdminWhitelistToGenerateConfig(t *testing.T)
assert.Equal(t, "10.0.0.1/32", capturedAdmin)
}
+func TestManager_ApplyConfig_PassesRuleSetsToGenerateConfig(t *testing.T) {
+ tmp := t.TempDir()
+ dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()+"rulesets")
+ db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
+ assert.NoError(t, err)
+ assert.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}, &models.SecurityConfig{}, &models.SecurityRuleSet{}))
+
+ // Create a host so ApplyConfig would try to generate config
+ h := models.ProxyHost{DomainNames: "ruleset.example.com", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true}
+ db.Create(&h)
+
+ // Insert ruleset
+ rs := models.SecurityRuleSet{Name: "owasp-crs", Content: "rules"}
+ assert.NoError(t, db.Create(&rs).Error)
+
+ // Insert SecurityConfig with WAF enabled and rulesource set
+ sec := models.SecurityConfig{Name: "default", Enabled: true, AdminWhitelist: "10.0.0.1/32", WAFMode: "block", WAFRulesSource: "owasp-crs"}
+ assert.NoError(t, db.Create(&sec).Error)
+
+ // Setup caddy server stub
+ caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == "/load" && r.Method == http.MethodPost {
+ w.WriteHeader(http.StatusOK)
+ return
+ }
+ if r.URL.Path == "/config/" && r.Method == http.MethodGet {
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte("{" + "\"apps\":{\"http\":{}}}"))
+ return
+ }
+ w.WriteHeader(http.StatusNotFound)
+ }))
+ defer caddyServer.Close()
+
+ client := NewClient(caddyServer.URL)
+
+ var capturedRules []models.SecurityRuleSet
+ orig := generateConfigFunc
+ generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, decisions []models.SecurityDecision, secCfg *models.SecurityConfig) (*Config, error) {
+ capturedRules = rulesets
+ return &Config{Apps: Apps{HTTP: &HTTPApp{Servers: map[string]*Server{}}}}, nil
+ }
+ defer func() { generateConfigFunc = orig }()
+
+ manager := NewManager(client, db, tmp, "", false, config.SecurityConfig{})
+ err = manager.ApplyConfig(context.Background())
+ assert.NoError(t, err)
+ assert.GreaterOrEqual(t, len(capturedRules), 1)
+ assert.Equal(t, "owasp-crs", capturedRules[0].Name)
+}
+
func TestManager_ApplyConfig_ReappliesOnFlagChange(t *testing.T) {
// Capture /load payloads
loadCh := make(chan []byte, 10)
diff --git a/backend/internal/caddy/manager_test.go b/backend/internal/caddy/manager_test.go
index 8a02019f..22e5c5eb 100644
--- a/backend/internal/caddy/manager_test.go
+++ b/backend/internal/caddy/manager_test.go
@@ -354,8 +354,8 @@ func TestComputeEffectiveFlags_DefaultsNoDB(t *testing.T) {
require.False(t, rl)
require.False(t, cs)
- // CrowdSec external mode should disable CrowdSec in computed flags
- secCfg = config.SecurityConfig{CerberusEnabled: true, ACLMode: "enabled", WAFMode: "enabled", RateLimitMode: "enabled", CrowdSecMode: "external"}
+ // Unknown/unrecognized CrowdSec mode should disable CrowdSec in computed flags
+ secCfg = config.SecurityConfig{CerberusEnabled: true, ACLMode: "enabled", WAFMode: "enabled", RateLimitMode: "enabled", CrowdSecMode: "unknown"}
manager = NewManager(nil, nil, "", "", false, secCfg)
cerb, acl, waf, rl, cs = manager.computeEffectiveFlags(context.Background())
require.True(t, cerb)
@@ -400,7 +400,7 @@ func TestComputeEffectiveFlags_DB_CrowdSecExternal(t *testing.T) {
secCfg := config.SecurityConfig{CerberusEnabled: true, ACLMode: "enabled", WAFMode: "enabled", RateLimitMode: "enabled", CrowdSecMode: "local"}
manager := NewManager(nil, db, "", "", false, secCfg)
- res := db.Create(&models.Setting{Key: "security.crowdsec.mode", Value: "external"})
+ res := db.Create(&models.Setting{Key: "security.crowdsec.mode", Value: "unknown"})
require.NoError(t, res.Error)
_, _, _, _, cs := manager.computeEffectiveFlags(context.Background())
diff --git a/backend/internal/caddy/validator_test.go b/backend/internal/caddy/validator_test.go
index d33db677..2fc2416b 100644
--- a/backend/internal/caddy/validator_test.go
+++ b/backend/internal/caddy/validator_test.go
@@ -25,7 +25,7 @@ func TestValidate_ValidConfig(t *testing.T) {
},
}
- config, _ := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, false, "")
+ config, _ := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, false, "", nil, nil, nil)
err := Validate(config)
require.NoError(t, err)
}
diff --git a/backend/internal/cerberus/cerberus.go b/backend/internal/cerberus/cerberus.go
index 1b957327..8ad60705 100644
--- a/backend/internal/cerberus/cerberus.go
+++ b/backend/internal/cerberus/cerberus.go
@@ -36,7 +36,7 @@ func (c *Cerberus) IsEnabled() bool {
// If any of the security modes are explicitly enabled, consider Cerberus enabled.
// Treat empty values as disabled to avoid treating zero-values ("") as enabled.
- if c.cfg.CrowdSecMode == "local" || c.cfg.CrowdSecMode == "remote" || c.cfg.CrowdSecMode == "enabled" {
+ if c.cfg.CrowdSecMode == "local" {
return true
}
if c.cfg.WAFMode == "enabled" || c.cfg.RateLimitMode == "enabled" || c.cfg.ACLMode == "enabled" {
diff --git a/backend/internal/models/security_config.go b/backend/internal/models/security_config.go
index 763d2766..d882cd38 100644
--- a/backend/internal/models/security_config.go
+++ b/backend/internal/models/security_config.go
@@ -13,7 +13,7 @@ type SecurityConfig struct {
Enabled bool `json:"enabled"`
AdminWhitelist string `json:"admin_whitelist" gorm:"type:text"` // JSON array or comma-separated CIDRs
BreakGlassHash string `json:"-" gorm:"column:break_glass_hash"`
- CrowdSecMode string `json:"crowdsec_mode"` // "disabled", "monitor", "block"; also supports "local"/"external"
+ CrowdSecMode string `json:"crowdsec_mode"` // "disabled" or "local"
CrowdSecAPIURL string `json:"crowdsec_api_url" gorm:"type:text"`
WAFMode string `json:"waf_mode"` // "disabled", "monitor", "block"
WAFRulesSource string `json:"waf_rules_source" gorm:"type:text"` // URL or name of ruleset
diff --git a/backend/internal/services/security_service.go b/backend/internal/services/security_service.go
index 3f1666af..f2fda298 100644
--- a/backend/internal/services/security_service.go
+++ b/backend/internal/services/security_service.go
@@ -4,6 +4,7 @@ import (
"crypto/rand"
"encoding/hex"
"errors"
+ "fmt"
"strings"
"net"
"time"
@@ -62,6 +63,11 @@ func (s *SecurityService) Upsert(cfg *models.SecurityConfig) error {
// If a breakglass token is present in BreakGlassHash as empty string,
// do not overwrite it here. Token generation should be done explicitly.
+ // Validate CrowdSec mode on input prior to any DB operations: only 'local' or 'disabled' supported
+ if cfg.CrowdSecMode != "" && cfg.CrowdSecMode != "local" && cfg.CrowdSecMode != "disabled" {
+ return fmt.Errorf("invalid crowdsec mode: %s", cfg.CrowdSecMode)
+ }
+
// Upsert behaviour: try to find existing record
var existing models.SecurityConfig
if err := s.db.Where("name = ?", cfg.Name).First(&existing).Error; err != nil {
@@ -78,6 +84,10 @@ func (s *SecurityService) Upsert(cfg *models.SecurityConfig) error {
}
existing.Enabled = cfg.Enabled
existing.AdminWhitelist = cfg.AdminWhitelist
+ // Validate CrowdSec mode: only 'local' or 'disabled' supported. Reject external/remote values.
+ if cfg.CrowdSecMode != "" && cfg.CrowdSecMode != "local" && cfg.CrowdSecMode != "disabled" {
+ return fmt.Errorf("invalid crowdsec mode: %s", cfg.CrowdSecMode)
+ }
existing.CrowdSecMode = cfg.CrowdSecMode
existing.WAFMode = cfg.WAFMode
existing.RateLimitEnable = cfg.RateLimitEnable
diff --git a/backend/internal/services/security_service_test.go b/backend/internal/services/security_service_test.go
index a9610e82..24cb3935 100644
--- a/backend/internal/services/security_service_test.go
+++ b/backend/internal/services/security_service_test.go
@@ -92,3 +92,23 @@ func TestSecurityService_UpsertRuleSet(t *testing.T) {
assert.GreaterOrEqual(t, len(list), 1)
assert.Equal(t, "owasp-crs", list[0].Name)
}
+
+func TestSecurityService_Upsert_RejectExternalMode(t *testing.T) {
+ db := setupSecurityTestDB(t)
+ svc := NewSecurityService(db)
+
+ // External mode should be rejected by validation
+ cfg := &models.SecurityConfig{Name: "default", Enabled: true, CrowdSecMode: "external"}
+ err := svc.Upsert(cfg)
+ assert.Error(t, err)
+
+ // Unknown mode should also be rejected
+ cfg.CrowdSecMode = "unknown"
+ err = svc.Upsert(cfg)
+ assert.Error(t, err)
+
+ // Local mode should be accepted
+ cfg.CrowdSecMode = "local"
+ err = svc.Upsert(cfg)
+ assert.NoError(t, err)
+}
diff --git a/docs/security.md b/docs/security.md
index 6f471f94..5a9f1425 100644
--- a/docs/security.md
+++ b/docs/security.md
@@ -10,8 +10,7 @@ Charon includes the optional Cerberus security suite — a collection of high-va
**Modes:**
* **Local**: Installs the CrowdSec agent *inside* the Charon container. Useful for single-container setups.
* *Note*: Increases container startup time and resource usage.
-* **External**: Connects to an existing CrowdSec agent running elsewhere (e.g., on the host or another container).
- * *Recommended* for production or multi-server setups.
+* **External**: (Deprecated) connections to external CrowdSec agents are no longer supported.
### 2. WAF (Web Application Firewall)
Uses [Coraza](https://coraza.io/), a Go-native WAF, with the **OWASP Core Rule Set (CRS)** to protect against common web attacks (SQL Injection, XSS, etc.).
@@ -48,9 +47,7 @@ You can enable or disable Cerberus at runtime via the web UI `System Settings` o
| :--- | :--- | :--- |
| `CERBERUS_SECURITY_CROWDSEC_MODE` | `disabled` | (Default) CrowdSec is turned off. (CERBERUS_ preferred; CHARON_/CPM_ still supported) |
| | `local` | Installs and runs CrowdSec agent inside the container. |
-| | `external` | Connects to an external CrowdSec agent. |
-| `CERBERUS_SECURITY_CROWDSEC_API_URL` | URL | (Required for `external`) e.g., `http://crowdsec:8080` |
-| `CERBERUS_SECURITY_CROWDSEC_API_KEY` | String | (Required for `external`) Your CrowdSec bouncer API key. |
+| | `local` | Installs and runs CrowdSec agent inside the container. |
**Example (Local Mode):**
```yaml
diff --git a/frontend/src/api/security.ts b/frontend/src/api/security.ts
index 13dbc02f..aac768b5 100644
--- a/frontend/src/api/security.ts
+++ b/frontend/src/api/security.ts
@@ -3,7 +3,7 @@ import client from './client'
export interface SecurityStatus {
cerberus?: { enabled: boolean }
crowdsec: {
- mode: 'disabled' | 'local' | 'external'
+ mode: 'disabled' | 'local'
api_url: string
enabled: boolean
}
diff --git a/frontend/src/pages/CrowdSecConfig.tsx b/frontend/src/pages/CrowdSecConfig.tsx
index 9d7f7cba..85d7c1f4 100644
--- a/frontend/src/pages/CrowdSecConfig.tsx
+++ b/frontend/src/pages/CrowdSecConfig.tsx
@@ -79,11 +79,7 @@ export default function CrowdSecConfig() {
const handleModeChange = async (mode: string) => {
updateModeMutation.mutate(mode)
- if (mode === 'external') {
- toast.error('External CrowdSec mode is not supported in this release')
- } else {
- toast.success('CrowdSec mode saved (restart may be required)')
- }
+ toast.success('CrowdSec mode saved (restart may be required)')
}
if (!status) return Loading...
@@ -101,12 +97,11 @@ export default function CrowdSecConfig() {
{status.crowdsec.mode === 'disabled' && (
- Note: External CrowdSec mode is not supported in this build.
+ CrowdSec is disabled
)}
diff --git a/frontend/src/pages/Security.tsx b/frontend/src/pages/Security.tsx
index f2f42b85..4836dc22 100644
--- a/frontend/src/pages/Security.tsx
+++ b/frontend/src/pages/Security.tsx
@@ -184,11 +184,6 @@ export default function Security() {
disabled={!status.cerberus?.enabled}
onChange={(e) => {
console.log('crowdsec onChange', e.target.checked)
- // pre-validate if enabling external CrowdSec without API URL
- if (e.target.checked && status.crowdsec?.mode === 'external') {
- toast.error('External CrowdSec mode is not supported in this release')
- return
- }
toggleServiceMutation.mutate({ key: 'security.crowdsec.enabled', enabled: e.target.checked })
}}
data-testid="toggle-crowdsec"