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.
This commit is contained in:
GitHub Actions
2025-12-01 19:43:45 +00:00
parent 08f9c8f87d
commit b0a4d75a2a
23 changed files with 585 additions and 86 deletions

40
.github/agents/Backend_Dev.agent.md vendored Normal file
View File

@@ -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.
<context>
- **Project**: Charon (Self-hosted Reverse Proxy)
- **Stack**: Go 1.22+, Gin, GORM, SQLite.
- **Rules**: You MUST follow `.github/copilot-instructions.md` explicitly.
</context>
<workflow>
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.
</workflow>
<constraints>
- **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.
</constraints>

41
.github/agents/Frontend_Dev.agent.md vendored Normal file
View File

@@ -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.
<context>
- **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.
</context>
<workflow>
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.
</workflow>
<constraints>
- **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).
</constraints>

47
.github/agents/Planning.agent.md vendored Normal file
View File

@@ -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.
<workflow>
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 <output_format>.
- **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.
</workflow>
<output_format>
## 📋 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
}
}

View File

@@ -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 = ""
}

View File

@@ -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",

View File

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

View File

@@ -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
}

View File

@@ -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]

View File

@@ -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

View File

@@ -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

View File

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

View File

@@ -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

View File

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

View File

@@ -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())

View File

@@ -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)
}

View File

@@ -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" {

View File

@@ -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

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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 <div className="p-8 text-center">Loading...</div>
@@ -101,12 +97,11 @@ export default function CrowdSecConfig() {
<select value={status.crowdsec.mode} onChange={(e) => handleModeChange(e.target.value)} className="bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white">
<option value="disabled">Disabled</option>
<option value="local">Local</option>
<option value="external">External (unsupported)</option>
</select>
</div>
</div>
{status.crowdsec.mode === 'disabled' && (
<p className="text-xs text-yellow-500">Note: External CrowdSec mode is not supported in this build.</p>
<p className="text-xs text-yellow-500">CrowdSec is disabled</p>
)}
</div>
<div className="flex items-center gap-2">

View File

@@ -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"