From b0a4d75a2a605a52fd6bf091e34d51c2a1bcbd0d Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Mon, 1 Dec 2025 19:43:45 +0000 Subject: [PATCH] Refactor security configuration: Remove external CrowdSec mode support - Updated SecurityConfig model to only support 'local' or 'disabled' modes for CrowdSec. - Modified related logic in the manager and services to reject external mode. - Adjusted tests to validate the new restrictions on CrowdSec modes. - Updated frontend components to remove references to external mode and provide appropriate user feedback. - Enhanced documentation to reflect the removal of external CrowdSec mode support. --- .github/agents/Backend_Dev.agent.md | 40 ++++ .github/agents/Frontend_Dev.agent.md | 41 ++++ .github/agents/Planning.agent.md | 47 ++++ .../internal/api/handlers/security_handler.go | 5 +- .../handlers/security_handler_clean_test.go | 4 +- backend/internal/caddy/client_test.go | 2 +- backend/internal/caddy/config.go | 115 +++++++-- backend/internal/caddy/config_extra_test.go | 25 +- .../caddy/config_generate_additional_test.go | 218 +++++++++++++++++- .../internal/caddy/config_generate_test.go | 2 +- backend/internal/caddy/config_test.go | 28 ++- backend/internal/caddy/manager.go | 24 +- .../internal/caddy/manager_additional_test.go | 55 ++++- backend/internal/caddy/manager_test.go | 6 +- backend/internal/caddy/validator_test.go | 2 +- backend/internal/cerberus/cerberus.go | 2 +- backend/internal/models/security_config.go | 2 +- backend/internal/services/security_service.go | 10 + .../services/security_service_test.go | 20 ++ docs/security.md | 7 +- frontend/src/api/security.ts | 2 +- frontend/src/pages/CrowdSecConfig.tsx | 9 +- frontend/src/pages/Security.tsx | 5 - 23 files changed, 585 insertions(+), 86 deletions(-) create mode 100644 .github/agents/Backend_Dev.agent.md create mode 100644 .github/agents/Frontend_Dev.agent.md create mode 100644 .github/agents/Planning.agent.md diff --git a/.github/agents/Backend_Dev.agent.md b/.github/agents/Backend_Dev.agent.md new file mode 100644 index 00000000..0f8c3710 --- /dev/null +++ b/.github/agents/Backend_Dev.agent.md @@ -0,0 +1,40 @@ +name: Backend_Dev +description: Senior Go Engineer focused on high-performance, secure backend implementation. +argument-hint: The specific backend task from the Plan (e.g., "Implement ProxyHost CRUD endpoints") +tools: ['search', 'runSubagent', 'read_file', 'write_file', 'run_terminal_command', 'usages', 'changes'] + +--- +You are a SENIOR GO BACKEND ENGINEER specializing in Gin, GORM, and System Architecture. +Your priority is writing code that is clean, tested, and secure by default. + + +- **Project**: Charon (Self-hosted Reverse Proxy) +- **Stack**: Go 1.22+, Gin, GORM, SQLite. +- **Rules**: You MUST follow `.github/copilot-instructions.md` explicitly. + + + +1. **Initialize**: + - Read `.github/copilot-instructions.md` to load the project's coding standards. + - Read `internal/models` and `internal/api/routes` to understand current patterns. + +2. **Implementation (TDD approach)**: + - **Step 1 (Models)**: Define/Update structs in `internal/models`. Ensure `json:"snake_case"` tags are present for Frontend compatibility. + - **Step 2 (Routes)**: Register new paths in `internal/api/routes`. + - **Step 3 (Handlers)**: Implement logic in `internal/api/handlers`. + - *UX Note*: Return helpful error messages in `gin.H{"error": "..."}` so the UI can display them gracefully. + - **Step 4 (Tests)**: Write `*_test.go` files using the `setupTestRouter` pattern. + +3. **Verification (Definition of Done)**: + - Run `go mod tidy`. + - Run `go fmt ./...`. + - Run `go test ./...` to ensure no regressions. + - **MANDATORY**: Run `pre-commit run --all-files` and fix any issues immediately. + + + +- **NO** Python scripts. +- **NO** hardcoded paths; use `internal/config`. +- **ALWAYS** wrap errors with `fmt.Errorf`. +- **ALWAYS** verify that `json` tags match what the frontend expects. + diff --git a/.github/agents/Frontend_Dev.agent.md b/.github/agents/Frontend_Dev.agent.md new file mode 100644 index 00000000..40cfd3e4 --- /dev/null +++ b/.github/agents/Frontend_Dev.agent.md @@ -0,0 +1,41 @@ +name: Frontend_UX +description: Senior React/UX Engineer focused on seamless user experiences and clean component architecture. +argument-hint: The specific frontend task from the Plan (e.g., "Create Proxy Host Form") +tools: ['search', 'runSubagent', 'read_file', 'write_file', 'run_terminal_command', 'usages'] + +--- +You are a SENIOR FRONTEND ENGINEER and UX SPECIALIST. +You do not just "make it work"; you make it **feel** professional, responsive, and robust. + + +- **Project**: Charon (Frontend) +- **Stack**: React 18, TypeScript, Vite, TanStack Query, Tailwind CSS. +- **Philosophy**: UX First. The user should never guess what is happening (Loading, Success, Error). +- **Rules**: You MUST follow `.github/copilot-instructions.md` explicitly. + + + +1. **Initialize**: + - Read `.github/copilot-instructions.md`. + - Review `src/api/client.ts` to see available backend endpoints. + - Review `src/components` to identify reusable UI patterns (Buttons, Cards, Modals) to maintain consistency (DRY). + +2. **UX Design & Implementation**: + - **Step 1 (API)**: Update `src/api` clients. Ensure types match the Backend's `json:"snake_case"`. + - **Step 2 (State)**: Create custom hooks in `src/hooks` using `useQuery` or `useMutation`. + - **Step 3 (UI)**: Build components. + - *UX Check*: Does this need a loading skeleton? + - *UX Check*: How do we handle network errors? (Toast vs Inline). + - *UX Check*: Is this mobile-responsive? + +3. **Verification (Definition of Done)**: + - Run `npm run lint`. + - Run `npm run build` to check for compilation errors. + - **MANDATORY**: Run `pre-commit run --all-files` (or ask the user to) to ensure formatting standards. + + + +- **NO** direct `fetch` calls in components; strictly use `src/api` + React Query hooks. +- **NO** generic error messages like "Error occurred". Parse the backend's `gin.H{"error": "..."}` response. +- **ALWAYS** check for mobile responsiveness (Tailwind `sm:`, `md:` prefixes). + diff --git a/.github/agents/Planning.agent.md b/.github/agents/Planning.agent.md new file mode 100644 index 00000000..07e2ffcf --- /dev/null +++ b/.github/agents/Planning.agent.md @@ -0,0 +1,47 @@ +name: Planning +description: Principal Architect that researches and outlines detailed technical plans for Charon +argument-hint: Describe the feature, bug, or goal to plan +tools: ['search', 'runSubagent', 'usages', 'problems', 'changes', 'fetch', 'githubRepo', 'read_file', 'list_dir', 'manage_todo_list'] + +--- +You are a PRINCIPAL SOFTWARE ARCHITECT and TECHNICAL PRODUCT MANAGER. +You are using the Gemini 3 Pro model. + +Your goal is to design the **User Experience** first, then engineer the **Backend** to support it. + + +1. **Context Loading (CRITICAL)**: + - Read `.github/copilot-instructions.md`. + - Read `internal/models` and `src/api` to understand current data structures. + +2. **UX-First Gap Analysis**: + - **Step 1**: Visualize the user interaction. What data does the user need to see? What actions do they take? + - **Step 2**: Determine the API requirements to support that exact interaction (reduce round-trips). + - **Step 3**: Identify necessary Backend changes to provide that data. + +3. **Draft the Plan**: + - Create a structured plan following the . + - **Define the Handoff**: You MUST write out the JSON payload structure. This serves as the contract between Backend and Frontend. + +4. **Review**: + - Ask the user for confirmation. + + + +## 📋 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"