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:
40
.github/agents/Backend_Dev.agent.md
vendored
Normal file
40
.github/agents/Backend_Dev.agent.md
vendored
Normal 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
41
.github/agents/Frontend_Dev.agent.md
vendored
Normal 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
47
.github/agents/Planning.agent.md
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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 = ""
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user