diff --git a/backend/caddy.html b/backend/caddy.html index 5e6f89de..33613bd9 100644 --- a/backend/caddy.html +++ b/backend/caddy.html @@ -55,17 +55,17 @@
@@ -100,6 +100,9 @@ import ( "time" ) +// Test hook for json marshalling to allow simulating failures in tests +var jsonMarshalClient = json.Marshal + // Client wraps the Caddy admin API. type Client struct { baseURL string @@ -107,7 +110,7 @@ type Client struct { } // NewClient creates a Caddy API client. -func NewClient(adminAPIURL string) *Client { +func NewClient(adminAPIURL string) *Client { return &Client{ baseURL: adminAPIURL, httpClient: &http.Client{ @@ -118,30 +121,30 @@ func NewClient(adminAPIURL string) *Client { // Load atomically replaces Caddy's entire configuration. // This is the primary method for applying configuration changes. -func (c *Client) Load(ctx context.Context, config *Config) error { - body, err := json.Marshal(config) - if err != nil { +func (c *Client) Load(ctx context.Context, config *Config) error { + body, err := jsonMarshalClient(config) + if err != nil { return fmt.Errorf("marshal config: %w", err) } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/load", bytes.NewReader(body)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/load", bytes.NewReader(body)) if err != nil { return fmt.Errorf("create request: %w", err) } - req.Header.Set("Content-Type", "application/json") + req.Header.Set("Content-Type", "application/json") resp, err := c.httpClient.Do(req) if err != nil { return fmt.Errorf("execute request: %w", err) } - defer resp.Body.Close() + defer resp.Body.Close() if resp.StatusCode != http.StatusOK { bodyBytes, _ := io.ReadAll(resp.Body) return fmt.Errorf("caddy returned status %d: %s", resp.StatusCode, string(bodyBytes)) } - return nil + return nil } // GetConfig retrieves the current running configuration from Caddy. @@ -151,7 +154,7 @@ func (c *Client) GetConfig(ctx context.Context) (*Config, error) - resp, err := c.httpClient.Do(req) + resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("execute request: %w", err) } @@ -162,7 +165,7 @@ func (c *Client) GetConfig(ctx context.Context) (*Config, error) - var config Config + var config Config if err := json.NewDecoder(resp.Body).Decode(&config); err != nil { return nil, fmt.Errorf("decode response: %w", err) } @@ -171,14 +174,14 @@ func (c *Client) GetConfig(ctx context.Context) (*Config, error) { +func (c *Client) Ping(ctx context.Context) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/config/", nil) if err != nil { return fmt.Errorf("create request: %w", err) } - resp, err := c.httpClient.Do(req) - if err != nil { + resp, err := c.httpClient.Do(req) + if err != nil { return fmt.Errorf("caddy unreachable: %w", err) } defer resp.Body.Close() @@ -199,12 +202,14 @@ import ( "path/filepath" "strings" + "github.com/Wikid82/charon/backend/internal/logger" + "github.com/Wikid82/charon/backend/internal/models" ) // 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) (*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, rulesetPaths map[string]string, 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 @@ -244,7 +249,7 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir string, acmeEmail strin }, } - if acmeEmail != "" { + if acmeEmail != "" { var issuers []interface{} // Configure issuers based on provider preference @@ -276,7 +281,7 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir string, acmeEmail strin }) } - config.Apps.TLS = &TLSApp{ + config.Apps.TLS = &TLSApp{ Automation: &AutomationConfig{ Policies: []*AutomationPolicy{ { @@ -289,8 +294,8 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir string, acmeEmail strin // Collect CUSTOM certificates only (not Let's Encrypt - those are managed by ACME) // Only custom/uploaded certificates should be loaded via LoadPEM - customCerts := make(map[uint]models.SSLCertificate) - for _, host := range hosts { + customCerts := make(map[uint]models.SSLCertificate) + for _, host := range hosts { if host.CertificateID != nil && host.Certificate != nil { // Only include custom certificates, not ACME-managed ones if host.Certificate.Provider == "custom" { @@ -299,12 +304,12 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir string, acmeEmail strin } } - if len(customCerts) > 0 { + if len(customCerts) > 0 { var loadPEM []LoadPEMConfig for _, cert := range customCerts { // Validate that custom cert has both certificate and key if cert.Certificate == "" || cert.PrivateKey == "" { - fmt.Printf("Warning: Custom certificate %s missing certificate or key, skipping\n", cert.Name) + logger.Log().WithField("cert", cert.Name).Warn("Custom certificate missing certificate or key, skipping") continue } loadPEM = append(loadPEM, LoadPEMConfig{ @@ -324,12 +329,12 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir string, acmeEmail strin } } - if len(hosts) == 0 && frontendDir == "" { + if len(hosts) == 0 && frontendDir == "" { return config, nil } // Initialize routes slice - routes := make([]*Route, 0) + routes := make([]*Route, 0) // Track processed domains to prevent duplicates (Ghost Host fix) processedDomains := make(map[string]bool) @@ -353,72 +358,144 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir string, acmeEmail strin // So we should process hosts from newest to oldest, and skip duplicates. // Let's iterate in reverse order (assuming input is ID ASC) - for i := len(hosts) - 1; i >= 0; i-- { + for i := len(hosts) - 1; i >= 0; i-- { host := hosts[i] if !host.Enabled { continue } - if host.DomainNames == "" { + if host.DomainNames == "" { // Log warning? continue } // Parse comma-separated domains - rawDomains := strings.Split(host.DomainNames, ",") + rawDomains := strings.Split(host.DomainNames, ",") var uniqueDomains []string - for _, d := range rawDomains { + for _, d := range rawDomains { d = strings.TrimSpace(d) d = strings.ToLower(d) // Normalize to lowercase if d == "" { continue } - if processedDomains[d] { - fmt.Printf("Warning: Skipping duplicate domain %s for host %s (Ghost Host detection)\n", d, host.UUID) + if processedDomains[d] { + logger.Log().WithField("domain", d).WithField("host", host.UUID).Warn("Skipping duplicate domain for host (Ghost Host detection)") continue } - processedDomains[d] = true + processedDomains[d] = true uniqueDomains = append(uniqueDomains, d) } - if len(uniqueDomains) == 0 { + if len(uniqueDomains) == 0 { continue } // Build handlers for this host - handlers := make([]Handler, 0) + handlers := make([]Handler, 0) - // Add Access Control List (ACL) handler if configured - if host.AccessListID != nil && host.AccessList != nil && host.AccessList.Enabled { - aclHandler, err := buildACLHandler(host.AccessList) + // Build security pre-handlers for this host, in pipeline order. + securityHandlers := make([]Handler, 0) + + // 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) — add according to runtime flag + if wafH, err := buildWAFHandler(&host, rulesets, rulesetPaths, secCfg, wafEnabled); err == nil && wafH != nil { + securityHandlers = append(securityHandlers, wafH) + } + + // Rate Limit handler (placeholder) + if rateLimitEnabled { + if rlH, err := buildRateLimitHandler(&host, secCfg); err == nil && rlH != nil { + securityHandlers = append(securityHandlers, rlH) + } + } + + // Add Access Control List (ACL) handler if configured and global ACL is enabled + if aclEnabled && host.AccessListID != nil && host.AccessList != nil && host.AccessList.Enabled { + aclHandler, err := buildACLHandler(host.AccessList, adminWhitelist) if err != nil { - fmt.Printf("Warning: Failed to build ACL handler for host %s: %v\n", host.UUID, err) - } else if aclHandler != nil { - handlers = append(handlers, aclHandler) + logger.Log().WithField("host", host.UUID).WithError(err).Warn("Failed to build ACL handler for host") + } else if aclHandler != nil { + securityHandlers = append(securityHandlers, aclHandler) } } // Add HSTS header if enabled - if host.HSTSEnabled { + if host.HSTSEnabled { hstsValue := "max-age=31536000" if host.HSTSSubdomains { hstsValue += "; includeSubDomains" } - handlers = append(handlers, HeaderHandler(map[string][]string{ + handlers = append(handlers, HeaderHandler(map[string][]string{ "Strict-Transport-Security": {hstsValue}, })) } // Add exploit blocking if enabled - if host.BlockExploits { + if host.BlockExploits { handlers = append(handlers, BlockExploitsHandler()) } // Handle custom locations first (more specific routes) - for _, loc := range host.Locations { + for _, loc := range host.Locations { dial := fmt.Sprintf("%s:%d", loc.ForwardHost, loc.ForwardPort) + // For each location, we want the same security pre-handlers before proxy + locHandlers := append(append([]Handler{}, securityHandlers...), handlers...) + locHandlers = append(locHandlers, ReverseProxyHandler(dial, host.WebsocketSupport, host.Application)) locRoute := &Route{ Match: []Match{ { @@ -426,46 +503,71 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir string, acmeEmail strin Path: []string{loc.Path, loc.Path + "/*"}, }, }, - Handle: []Handler{ - ReverseProxyHandler(dial, host.WebsocketSupport, host.Application), - }, + Handle: locHandlers, Terminal: true, } routes = append(routes, locRoute) } // Main proxy handler - dial := fmt.Sprintf("%s:%d", host.ForwardHost, host.ForwardPort) + dial := fmt.Sprintf("%s:%d", host.ForwardHost, host.ForwardPort) // Insert user advanced config (if present) as headers or handlers before the reverse proxy // so user-specified headers/handlers are applied prior to proxying. - if host.AdvancedConfig != "" { + if host.AdvancedConfig != "" { var parsed interface{} if err := json.Unmarshal([]byte(host.AdvancedConfig), &parsed); err != nil { - fmt.Printf("Warning: Failed to parse advanced_config for host %s: %v\n", host.UUID, err) - } else { + logger.Log().WithField("host", host.UUID).WithError(err).Warn("Failed to parse advanced_config for host") + } else { switch v := parsed.(type) { - case map[string]interface{}: + case map[string]interface{}: // Append as a handler // Ensure it has a "handler" key - if _, ok := v["handler"]; ok { - handlers = append(handlers, Handler(v)) - } else { - fmt.Printf("Warning: advanced_config for host %s is not a handler object\n", host.UUID) + if _, ok := v["handler"]; ok { + // Capture ruleset_name if present, remove it from advanced_config, + // and set up 'include' array for coraza-caddy plugin. + if rn, has := v["ruleset_name"]; has { + if rnStr, ok := rn.(string); ok && rnStr != "" { + // Set 'include' array with the ruleset file path for coraza-caddy + if rulesetPaths != nil { + if p, ok := rulesetPaths[rnStr]; ok && p != "" { + v["include"] = []string{p} + } + } + } + delete(v, "ruleset_name") + } + normalizeHandlerHeaders(v) + handlers = append(handlers, Handler(v)) + } else { + logger.Log().WithField("host", host.UUID).Warn("advanced_config for host is not a handler object") } - case []interface{}: - for _, it := range v { - if m, ok := it.(map[string]interface{}); ok { - if _, ok2 := m["handler"]; ok2 { + case []interface{}: + for _, it := range v { + if m, ok := it.(map[string]interface{}); ok { + if rn, has := m["ruleset_name"]; has { + if rnStr, ok := rn.(string); ok && rnStr != "" { + if rulesetPaths != nil { + if p, ok := rulesetPaths[rnStr]; ok && p != "" { + m["include"] = []string{p} + } + } + } + delete(m, "ruleset_name") + } + normalizeHandlerHeaders(m) + if _, ok2 := m["handler"]; ok2 { handlers = append(handlers, Handler(m)) } } } default: - fmt.Printf("Warning: advanced_config for host %s has unexpected JSON structure\n", host.UUID) + logger.Log().WithField("host", host.UUID).Warn("advanced_config for host has unexpected JSON structure") } } } - mainHandlers := append(handlers, ReverseProxyHandler(dial, host.WebsocketSupport, host.Application)) + // Build main handlers: security pre-handlers, other host-level handlers, then reverse proxy + mainHandlers := append(append([]Handler{}, securityHandlers...), handlers...) + mainHandlers = append(mainHandlers, ReverseProxyHandler(dial, host.WebsocketSupport, host.Application)) route := &Route{ Match: []Match{ @@ -480,7 +582,7 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir string, acmeEmail strin // Add catch-all 404 handler // This matches any request that wasn't handled by previous routes - if frontendDir != "" { + if frontendDir != "" { catchAllRoute := &Route{ Handle: []Handler{ RewriteHandler("/unknown.html"), @@ -491,7 +593,7 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir string, acmeEmail strin routes = append(routes, catchAllRoute) } - config.Apps.HTTP.Servers["charon_server"] = &Server{ + config.Apps.HTTP.Servers["charon_server"] = &Server{ Listen: []string{":80", ":443"}, Routes: routes, AutoHTTPS: &AutoHTTPSConfig{ @@ -506,39 +608,139 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir string, acmeEmail strin return config, nil } +// normalizeHandlerHeaders ensures header values in handlers are arrays of strings +// Caddy's JSON schema expects header values to be an array of strings (e.g. ["websocket"]) rather than a single string. +func normalizeHandlerHeaders(h map[string]interface{}) { + // normalize top-level headers key + if headersRaw, ok := h["headers"].(map[string]interface{}); ok { + normalizeHeaderOps(headersRaw) + } + // also normalize in nested request/response if present explicitly + for _, side := range []string{"request", "response"} { + if sideRaw, ok := h[side].(map[string]interface{}); ok { + normalizeHeaderOps(sideRaw) + } + } +} + +func normalizeHeaderOps(headerOps map[string]interface{}) { + if setRaw, ok := headerOps["set"].(map[string]interface{}); ok { + for k, v := range setRaw { + switch vv := v.(type) { + case string: + setRaw[k] = []string{vv} + case []interface{}: + // convert to []string + arr := make([]string, 0, len(vv)) + for _, it := range vv { + arr = append(arr, fmt.Sprintf("%v", it)) + } + setRaw[k] = arr + case []string: + // nothing to do + default: + // coerce anything else to string slice + setRaw[k] = []string{fmt.Sprintf("%v", vv)} + } + } + headerOps["set"] = setRaw + } +} + +// NormalizeAdvancedConfig traverses a parsed JSON advanced config (map or array) +// and normalizes any headers blocks so that header values are arrays of strings. +// It returns the modified config object which can be JSON marshaled again. +func NormalizeAdvancedConfig(parsed interface{}) interface{} { + switch v := parsed.(type) { + case map[string]interface{}: + // This might be a handler object + normalizeHandlerHeaders(v) + // Also inspect nested 'handle' or 'routes' arrays for nested handlers + if handles, ok := v["handle"].([]interface{}); ok { + for _, it := range handles { + if m, ok := it.(map[string]interface{}); ok { + NormalizeAdvancedConfig(m) + } + } + } + if routes, ok := v["routes"].([]interface{}); ok { + for _, rit := range routes { + if rm, ok := rit.(map[string]interface{}); ok { + if handles, ok := rm["handle"].([]interface{}); ok { + for _, it := range handles { + if m, ok := it.(map[string]interface{}); ok { + NormalizeAdvancedConfig(m) + } + } + } + } + } + } + return v + case []interface{}: + for _, it := range v { + if m, ok := it.(map[string]interface{}); ok { + NormalizeAdvancedConfig(m) + } + } + return v + default: + return parsed + } +} + // buildACLHandler creates access control handlers based on the AccessList configuration -func buildACLHandler(acl *models.AccessList) (Handler, error) { +func buildACLHandler(acl *models.AccessList, adminWhitelist string) (Handler, error) { // For geo-blocking, we use CEL (Common Expression Language) matcher with caddy-geoip2 placeholders // For IP-based ACLs, we use Caddy's native remote_ip matcher - if strings.HasPrefix(acl.Type, "geo_") { + if strings.HasPrefix(acl.Type, "geo_") { // Geo-blocking using caddy-geoip2 countryCodes := strings.Split(acl.CountryCodes, ",") var trimmedCodes []string - for _, code := range countryCodes { + for _, code := range countryCodes { trimmedCodes = append(trimmedCodes, `"`+strings.TrimSpace(code)+`"`) } - var expression string - if acl.Type == "geo_whitelist" { - // Allow only these countries + var expression string + if acl.Type == "geo_whitelist" { + // Allow only these countries, so block when not in the whitelist expression = fmt.Sprintf("{geoip2.country_code} in [%s]", strings.Join(trimmedCodes, ", ")) - } else { - // geo_blacklist: Block these countries - expression = fmt.Sprintf("{geoip2.country_code} not_in [%s]", strings.Join(trimmedCodes, ", ")) + // For whitelist, block when NOT in the list + return Handler{ + "handler": "subroute", + "routes": []map[string]interface{}{ + { + "match": []map[string]interface{}{ + { + "not": []map[string]interface{}{ + { + "expression": expression, + }, + }, + }, + }, + "handle": []map[string]interface{}{ + { + "handler": "static_response", + "status_code": 403, + "body": "Access denied: Geographic restriction", + }, + }, + "terminal": true, + }, + }, + }, nil } - - return Handler{ + // geo_blacklist: Block these countries directly + expression = fmt.Sprintf("{geoip2.country_code} in [%s]", strings.Join(trimmedCodes, ", ")) + return Handler{ "handler": "subroute", "routes": []map[string]interface{}{ { "match": []map[string]interface{}{ { - "not": []map[string]interface{}{ - { - "expression": expression, - }, - }, + "expression": expression, }, }, "handle": []map[string]interface{}{ @@ -555,7 +757,7 @@ func buildACLHandler(acl *models.AccessList) (Handler, error) if acl.LocalNetworkOnly { + if acl.LocalNetworkOnly { // Allow only RFC1918 private networks return Handler{ "handler": "subroute", @@ -595,28 +797,39 @@ func buildACLHandler(acl *models.AccessList) (Handler, error) // Parse IP rules - if acl.IPRules == "" { + if acl.IPRules == "" { return nil, nil } - var rules []models.AccessListRule + var rules []models.AccessListRule if err := json.Unmarshal([]byte(acl.IPRules), &rules); err != nil { return nil, fmt.Errorf("invalid IP rules JSON: %w", err) } - if len(rules) == 0 { + if len(rules) == 0 { return nil, nil } // Extract CIDR ranges - var cidrs []string - for _, rule := range rules { + var cidrs []string + for _, rule := range rules { cidrs = append(cidrs, rule.CIDR) } - if acl.Type == "whitelist" { + if acl.Type == "whitelist" { // Allow only these IPs (block everything else) - return Handler{ + // Merge adminWhitelist into allowed cidrs so that admins always bypass whitelist checks + if adminWhitelist != "" { + adminParts := strings.Split(adminWhitelist, ",") + for _, p := range adminParts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + cidrs = append(cidrs, p) + } + } + return Handler{ "handler": "subroute", "routes": []map[string]interface{}{ { @@ -641,22 +854,38 @@ func buildACLHandler(acl *models.AccessList) (Handler, error) + }, nil + } - if acl.Type == "blacklist" { + if acl.Type == "blacklist" { // Block these IPs (allow everything else) - return Handler{ + // For blacklist, add an explicit 'not' clause excluding adminWhitelist ranges from the match + var adminExclusion interface{} + 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 { + adminExclusion = map[string]interface{}{"not": []map[string]interface{}{{"remote_ip": map[string]interface{}{"ranges": trims}}}} + } + } + // Build matcher parts + matchParts := []map[string]interface{}{} + matchParts = append(matchParts, map[string]interface{}{"remote_ip": map[string]interface{}{"ranges": cidrs}}) + if adminExclusion != nil { + matchParts = append(matchParts, adminExclusion.(map[string]interface{})) + } + return Handler{ "handler": "subroute", "routes": []map[string]interface{}{ { - "match": []map[string]interface{}{ - { - "remote_ip": map[string]interface{}{ - "ranges": cidrs, - }, - }, - }, + "match": matchParts, "handle": []map[string]interface{}{ { "handler": "static_response", @@ -667,11 +896,91 @@ func buildACLHandler(acl *models.AccessList) (Handler, error) + }, nil + } return nil, nil } + +// 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, 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, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, secCfg *models.SecurityConfig, wafEnabled bool) (Handler, error) { + // If the host provided an advanced_config containing a 'ruleset_name', prefer that value + var hostRulesetName string + if host != nil && host.AdvancedConfig != "" { + var ac map[string]interface{} + if err := json.Unmarshal([]byte(host.AdvancedConfig), &ac); err == nil { + if rn, ok := ac["ruleset_name"]; ok { + if rnStr, ok2 := rn.(string); ok2 && rnStr != "" { + hostRulesetName = rnStr + } + } + } + } + + // Find a ruleset to associate with WAF; prefer name match by host.Application, host.AdvancedConfig ruleset_name or default 'owasp-crs' + var selected *models.SecurityRuleSet + for i, r := range rulesets { + if r.Name == "owasp-crs" || (host != nil && r.Name == host.Application) || (hostRulesetName != "" && r.Name == hostRulesetName) || (secCfg != nil && r.Name == secCfg.WAFRulesSource) { + selected = &rulesets[i] + break + } + } + + if !wafEnabled { + return nil, nil + } + h := Handler{"handler": "waf"} + if selected != nil { + if rulesetPaths != nil { + if p, ok := rulesetPaths[selected.Name]; ok && p != "" { + h["include"] = []string{p} + } + } + } else if secCfg != nil && secCfg.WAFRulesSource != "" { + // If there was a requested ruleset name but nothing matched, include path if known + if rulesetPaths != nil { + if p, ok := rulesetPaths[secCfg.WAFRulesSource]; ok && p != "" { + h["include"] = []string{p} + } + } + } + // WAF enablement is handled by the caller. Don't add a 'mode' field + // here because the module expects a specific configuration schema. + if secCfg != nil && secCfg.WAFMode == "disabled" { + return nil, nil + } + 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, secCfg *models.SecurityConfig) (Handler, error) { + // If host has custom rate limit metadata we could parse and construct it. + 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 +} @@ -1032,21 +1363,27 @@ import ( "os" "path/filepath" "sort" + "strings" "time" "gorm.io/gorm" + "github.com/Wikid82/charon/backend/internal/config" + "github.com/Wikid82/charon/backend/internal/logger" "github.com/Wikid82/charon/backend/internal/models" ) // Test hooks to allow overriding OS and JSON functions var ( - writeFileFunc = os.WriteFile - readFileFunc = os.ReadFile - removeFileFunc = os.Remove - readDirFunc = os.ReadDir - statFunc = os.Stat + writeFileFunc = os.WriteFile + readFileFunc = os.ReadFile + removeFileFunc = os.Remove + readDirFunc = os.ReadDir + statFunc = os.Stat jsonMarshalFunc = json.MarshalIndent + // Test hooks for bandaging validation/generation flows + generateConfigFunc = GenerateConfig + validateConfigFunc = Validate ) // Manager orchestrates Caddy configuration lifecycle: generate, validate, apply, rollback. @@ -1056,21 +1393,23 @@ type Manager struct { configDir string frontendDir string acmeStaging bool + securityCfg config.SecurityConfig } // NewManager creates a configuration manager. -func NewManager(client *Client, db *gorm.DB, configDir string, frontendDir string, acmeStaging bool) *Manager { +func NewManager(client *Client, db *gorm.DB, configDir string, frontendDir string, acmeStaging bool, securityCfg config.SecurityConfig) *Manager { return &Manager{ client: client, db: db, configDir: configDir, frontendDir: frontendDir, acmeStaging: acmeStaging, + securityCfg: securityCfg, } } // ApplyConfig generates configuration from database, validates it, applies to Caddy with rollback on failure. -func (m *Manager) ApplyConfig(ctx context.Context) error { +func (m *Manager) ApplyConfig(ctx context.Context) error { // Fetch all proxy hosts from database var hosts []models.ProxyHost if err := m.db.Preload("Locations").Preload("Certificate").Preload("AccessList").Find(&hosts).Error; err != nil { @@ -1078,38 +1417,107 @@ func (m *Manager) ApplyConfig(ctx context.Context) error // Fetch ACME email setting - var acmeEmailSetting models.Setting + var acmeEmailSetting models.Setting var acmeEmail string if err := m.db.Where("key = ?", "caddy.acme_email").First(&acmeEmailSetting).Error; err == nil { acmeEmail = acmeEmailSetting.Value } // Fetch SSL Provider setting - var sslProviderSetting models.Setting + var sslProviderSetting models.Setting var sslProvider string if err := m.db.Where("key = ?", "caddy.ssl_provider").First(&sslProviderSetting).Error; err == nil { sslProvider = sslProviderSetting.Value } + // Compute effective security flags (re-read runtime overrides) + _, aclEnabled, wafEnabled, rateLimitEnabled, crowdsecEnabled := m.computeEffectiveFlags(ctx) + + // Safety check: if Cerberus is enabled in DB and no admin whitelist configured, + // block applying changes to avoid accidental self-lockout. + var secCfg models.SecurityConfig + if err := m.db.Where("name = ?", "default").First(&secCfg).Error; err == nil { + if secCfg.Enabled && strings.TrimSpace(secCfg.AdminWhitelist) == "" { + return fmt.Errorf("refusing to apply config: Cerberus is enabled but admin_whitelist is empty; add an admin whitelist entry or generate a break-glass token") + } + } + + // 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 - config, err := GenerateConfig(hosts, filepath.Join(m.configDir, "data"), acmeEmail, m.frontendDir, sslProvider, m.acmeStaging) - if err != nil { + // Read admin whitelist for config generation so handlers can exclude admin IPs + var adminWhitelist string + if secCfg.AdminWhitelist != "" { + adminWhitelist = secCfg.AdminWhitelist + } + // Ensure ruleset files exist on disk and build a map of their paths for GenerateConfig + rulesetPaths := make(map[string]string) + if len(rulesets) > 0 { + corazaDir := filepath.Join(m.configDir, "coraza", "rulesets") + if err := os.MkdirAll(corazaDir, 0755); err != nil { + logger.Log().WithError(err).Warn("failed to create coraza rulesets dir") + } + for _, rs := range rulesets { + // sanitize name to a safe filename + safeName := strings.ReplaceAll(strings.ToLower(rs.Name), " ", "-") + safeName = strings.ReplaceAll(safeName, "/", "-") + filePath := filepath.Join(corazaDir, safeName+".conf") + // Prepend required Coraza directives if not already present. + // These are essential for the WAF to actually enforce rules: + // - SecRuleEngine On: enables blocking mode (default is DetectionOnly) + // - SecRequestBodyAccess On: allows inspecting POST body content + content := rs.Content + if !strings.Contains(strings.ToLower(content), "secruleengine") { + content = "SecRuleEngine On\nSecRequestBodyAccess On\n\n" + content + } + // Write ruleset file with world-readable permissions so the Caddy + // process (which may run as an unprivileged user) can read it. + if err := writeFileFunc(filePath, []byte(content), 0644); err != nil { + logger.Log().WithError(err).WithField("ruleset", rs.Name).Warn("failed to write coraza ruleset file") + } else { + // Log a short fingerprint for debugging and confirm path + rulesetPaths[rs.Name] = filePath + logger.Log().WithField("ruleset", rs.Name).WithField("path", filePath).Info("wrote coraza ruleset file") + } + } + } + + config, err := generateConfigFunc(hosts, filepath.Join(m.configDir, "data"), acmeEmail, m.frontendDir, sslProvider, m.acmeStaging, crowdsecEnabled, wafEnabled, rateLimitEnabled, aclEnabled, adminWhitelist, rulesets, rulesetPaths, decisions, &secCfg) + if err != nil { return fmt.Errorf("generate config: %w", err) } + // Log generated config size and a compact JSON snippet for debugging when in debug mode + if cfgJSON, jerr := json.Marshal(config); jerr == nil { + logger.Log().WithField("config_json_len", len(cfgJSON)).Debug("generated Caddy config JSON") + } else { + logger.Log().WithError(jerr).Warn("failed to marshal generated config for debug logging") + } + // Validate before applying - if err := Validate(config); err != nil { + if err := validateConfigFunc(config); err != nil { return fmt.Errorf("validation failed: %w", err) } // Save snapshot for rollback - snapshotPath, err := m.saveSnapshot(config) + snapshotPath, err := m.saveSnapshot(config) if err != nil { return fmt.Errorf("save snapshot: %w", err) } // Calculate config hash for audit trail - configJSON, _ := json.Marshal(config) + configJSON, _ := json.Marshal(config) configHash := fmt.Sprintf("%x", sha256.Sum256(configJSON)) // Apply to Caddy @@ -1130,19 +1538,19 @@ func (m *Manager) ApplyConfig(ctx context.Context) error m.recordConfigChange(configHash, true, "") + m.recordConfigChange(configHash, true, "") // Cleanup old snapshots (keep last 10) - if err := m.rotateSnapshots(10); err != nil { + if err := m.rotateSnapshots(10); err != nil { // Non-fatal - log but don't fail - fmt.Printf("warning: snapshot rotation failed: %v\n", err) + logger.Log().WithError(err).Warn("warning: snapshot rotation failed") } - return nil + return nil } // saveSnapshot stores the config to disk with timestamp. -func (m *Manager) saveSnapshot(config *Config) (string, error) { +func (m *Manager) saveSnapshot(config *Config) (string, error) { timestamp := time.Now().Unix() filename := fmt.Sprintf("config-%d.json", timestamp) path := filepath.Join(m.configDir, filename) @@ -1152,24 +1560,24 @@ func (m *Manager) saveSnapshot(config *Config) (string, error) - if err := writeFileFunc(path, configJSON, 0644); err != nil { + if err := writeFileFunc(path, configJSON, 0644); err != nil { return "", fmt.Errorf("write snapshot: %w", err) } - return path, nil + return path, nil } // rollback loads the most recent snapshot from disk. -func (m *Manager) rollback(ctx context.Context) error { +func (m *Manager) rollback(ctx context.Context) error { snapshots, err := m.listSnapshots() if err != nil || len(snapshots) == 0 { return fmt.Errorf("no snapshots available for rollback") } // Load most recent snapshot - latestSnapshot := snapshots[len(snapshots)-1] + latestSnapshot := snapshots[len(snapshots)-1] configJSON, err := readFileFunc(latestSnapshot) - if err != nil { + if err != nil { return fmt.Errorf("read snapshot: %w", err) } @@ -1187,44 +1595,44 @@ func (m *Manager) rollback(ctx context.Context) error { +func (m *Manager) listSnapshots() ([]string, error) { entries, err := readDirFunc(m.configDir) - if err != nil { + if err != nil { return nil, fmt.Errorf("read config dir: %w", err) } - var snapshots []string - for _, entry := range entries { - if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { + var snapshots []string + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { continue } - snapshots = append(snapshots, filepath.Join(m.configDir, entry.Name())) + snapshots = append(snapshots, filepath.Join(m.configDir, entry.Name())) } // Sort by modification time - sort.Slice(snapshots, func(i, j int) bool { + sort.Slice(snapshots, func(i, j int) bool { infoI, _ := statFunc(snapshots[i]) infoJ, _ := statFunc(snapshots[j]) return infoI.ModTime().Before(infoJ.ModTime()) }) - return snapshots, nil + return snapshots, nil } // rotateSnapshots keeps only the N most recent snapshots. -func (m *Manager) rotateSnapshots(keep int) error { +func (m *Manager) rotateSnapshots(keep int) error { snapshots, err := m.listSnapshots() - if err != nil { + if err != nil { return err } - if len(snapshots) <= keep { + if len(snapshots) <= keep { return nil } // Delete oldest snapshots toDelete := snapshots[:len(snapshots)-keep] - for _, path := range toDelete { + for _, path := range toDelete { if err := removeFileFunc(path); err != nil { return fmt.Errorf("delete snapshot %s: %w", path, err) } @@ -1234,7 +1642,7 @@ func (m *Manager) rotateSnapshots(keep int) error { } // recordConfigChange stores an audit record in the database. -func (m *Manager) recordConfigChange(configHash string, success bool, errorMsg string) { +func (m *Manager) recordConfigChange(configHash string, success bool, errorMsg string) { record := models.CaddyConfig{ ConfigHash: configHash, AppliedAt: time.Now(), @@ -1255,6 +1663,57 @@ func (m *Manager) Ping(ctx context.Context) error { func (m *Manager) GetCurrentConfig(ctx context.Context) (*Config, error) { return m.client.GetConfig(ctx) } + +// computeEffectiveFlags reads runtime settings to determine whether Cerberus +// suite and each sub-component (ACL, WAF, RateLimit, CrowdSec) are effectively enabled. +func (m *Manager) computeEffectiveFlags(ctx context.Context) (cerbEnabled bool, aclEnabled bool, wafEnabled bool, rateLimitEnabled bool, crowdsecEnabled bool) { + // Base flags from static config + cerbEnabled = m.securityCfg.CerberusEnabled + // WAF is enabled if explicitly set and not 'disabled' (supports 'monitor'/'block') + wafEnabled = m.securityCfg.WAFMode != "" && m.securityCfg.WAFMode != "disabled" + rateLimitEnabled = m.securityCfg.RateLimitMode == "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 { + var s models.Setting + // runtime override for cerberus enabled + if err := m.db.Where("key = ?", "security.cerberus.enabled").First(&s).Error; err == nil { + cerbEnabled = strings.EqualFold(s.Value, "true") + } + + // runtime override for ACL enabled + if err := m.db.Where("key = ?", "security.acl.enabled").First(&s).Error; err == nil { + if strings.EqualFold(s.Value, "true") { + aclEnabled = true + } else if strings.EqualFold(s.Value, "false") { + aclEnabled = false + } + } + + // 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 != "" { + // Only 'local' runtime mode enables CrowdSec; all other values are disabled + if cm.Value == "local" { + crowdsecEnabled = true + } else { + crowdsecEnabled = false + } + } + } + + // ACL, WAF, RateLimit and CrowdSec should only be considered enabled if Cerberus is enabled. + if !cerbEnabled { + aclEnabled = false + wafEnabled = false + rateLimitEnabled = false + crowdsecEnabled = false + } + + return cerbEnabled, aclEnabled, wafEnabled, rateLimitEnabled, crowdsecEnabled +} diff --git a/backend/internal/caddy/config.go b/backend/internal/caddy/config.go index b8cb5f21..8cbb6122 100644 --- a/backend/internal/caddy/config.go +++ b/backend/internal/caddy/config.go @@ -328,13 +328,13 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir string, acmeEmail strin // Ensure it has a "handler" key if _, ok := v["handler"]; ok { // Capture ruleset_name if present, remove it from advanced_config, - // and convert it to rules_files if this is a waf handler. + // and set up 'include' array for coraza-caddy plugin. if rn, has := v["ruleset_name"]; has { if rnStr, ok := rn.(string); ok && rnStr != "" { - // Only add rules_files if we map the name to a path + // Set 'include' array with the ruleset file path for coraza-caddy if rulesetPaths != nil { if p, ok := rulesetPaths[rnStr]; ok && p != "" { - v["rules_file"] = p + v["include"] = []string{p} } } } @@ -352,7 +352,7 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir string, acmeEmail strin if rnStr, ok := rn.(string); ok && rnStr != "" { if rulesetPaths != nil { if p, ok := rulesetPaths[rnStr]; ok && p != "" { - m["rules_file"] = p + m["include"] = []string{p} } } } @@ -753,15 +753,15 @@ func buildWAFHandler(host *models.ProxyHost, rulesets []models.SecurityRuleSet, h := Handler{"handler": "waf"} if selected != nil { if rulesetPaths != nil { - if p, ok := rulesetPaths[selected.Name]; ok && p != "" { - h["rules_file"] = p + if p, ok := rulesetPaths[selected.Name]; ok && p != "" { + h["include"] = []string{p} } } } else if secCfg != nil && secCfg.WAFRulesSource != "" { - // If there was a requested ruleset name but nothing matched, include a rules_files entry if path known + // If there was a requested ruleset name but nothing matched, include path if known if rulesetPaths != nil { - if p, ok := rulesetPaths[secCfg.WAFRulesSource]; ok && p != "" { - h["rules_file"] = p + if p, ok := rulesetPaths[secCfg.WAFRulesSource]; ok && p != "" { + h["include"] = []string{p} } } } diff --git a/backend/internal/caddy/config_generate_additional_test.go b/backend/internal/caddy/config_generate_additional_test.go index 78a60652..abe337fe 100644 --- a/backend/internal/caddy/config_generate_additional_test.go +++ b/backend/internal/caddy/config_generate_additional_test.go @@ -168,17 +168,17 @@ func TestGenerateConfig_WAFModeAndRulesetReference(t *testing.T) { sec := &models.SecurityConfig{WAFMode: "block", WAFRulesSource: "nonexistent-rs"} cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, true, false, false, "", nil, nil, nil, sec) require.NoError(t, err) - // Since a ruleset name was requested but none exists, waf handler should include a reference but no rules_files + // Since a ruleset name was requested but none exists, waf handler should include a reference but no include array route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0] found := false for _, h := range route.Handle { if hn, ok := h["handler"].(string); ok && hn == "waf" { - if _, ok := h["rules_file"]; !ok { + if _, ok := h["include"]; !ok { found = true } } } - require.True(t, found, "expected waf handler without rules_files when referenced ruleset does not exist") + require.True(t, found, "expected waf handler without include array when referenced ruleset does not exist") // Now test learning/monitor mode mapping sec2 := &models.SecurityConfig{WAFMode: "block", WAFLearning: true} @@ -218,13 +218,13 @@ func TestGenerateConfig_WAFSelectedSetsContentAndMode(t *testing.T) { found := false for _, h := range route.Handle { if hn, ok := h["handler"].(string); ok && hn == "waf" { - if rf, ok := h["rules_file"].(string); ok && rf != "" { + if incl, ok := h["include"].([]string); ok && len(incl) > 0 { found = true break } } } - require.True(t, found, "expected waf handler with rules_files to be present") + require.True(t, found, "expected waf handler with include array to be present") } func TestGenerateConfig_DecisionAdminPartsEmpty(t *testing.T) { @@ -271,11 +271,11 @@ func TestGenerateConfig_WAFUsesRuleSet(t *testing.T) { cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, true, false, false, "", []models.SecurityRuleSet{rs}, rulesetPaths, nil, nil) require.NoError(t, err) route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0] - // check waf handler present with rules_files + // check waf handler present with include array found := false for _, h := range route.Handle { if hn, ok := h["handler"].(string); ok && hn == "waf" { - if rf, ok := h["rules_file"].(string); ok && rf != "" { + if incl, ok := h["include"].([]string); ok && len(incl) > 0 { found = true break } @@ -283,7 +283,7 @@ func TestGenerateConfig_WAFUsesRuleSet(t *testing.T) { } if !found { b2, _ := json.MarshalIndent(route.Handle, "", " ") - t.Fatalf("waf handler with rules_file should be present; handlers: %s", string(b2)) + t.Fatalf("waf handler with include array should be present; handlers: %s", string(b2)) } } @@ -295,17 +295,17 @@ func TestGenerateConfig_WAFUsesRuleSetFromAdvancedConfig(t *testing.T) { cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, true, false, false, "", []models.SecurityRuleSet{rs}, rulesetPaths, nil, nil) require.NoError(t, err) route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0] - // check waf handler present with rules_files coming from host AdvancedConfig + // check waf handler present with include array coming from host AdvancedConfig found := false for _, h := range route.Handle { if hn, ok := h["handler"].(string); ok && hn == "waf" { - if rf, ok := h["rules_file"].(string); ok && rf == "/tmp/host-rs.conf" { + if incl, ok := h["include"].([]string); ok && len(incl) > 0 && incl[0] == "/tmp/host-rs.conf" { found = true break } } } - require.True(t, found, "waf handler with rules_files should include host advanced_config ruleset path") + require.True(t, found, "waf handler with include array should include host advanced_config ruleset path") } func TestGenerateConfig_WAFUsesRuleSetFromAdvancedConfig_Array(t *testing.T) { @@ -316,17 +316,17 @@ func TestGenerateConfig_WAFUsesRuleSetFromAdvancedConfig_Array(t *testing.T) { cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, true, false, false, "", []models.SecurityRuleSet{rs}, rulesetPaths, nil, nil) require.NoError(t, err) route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0] - // check waf handler present with rules_file coming from host AdvancedConfig array + // check waf handler present with include array coming from host AdvancedConfig array found := false for _, h := range route.Handle { if hn, ok := h["handler"].(string); ok && hn == "waf" { - if rf, ok := h["rules_file"].(string); ok && rf == "/tmp/host-rs-array.conf" { + if incl, ok := h["include"].([]string); ok && len(incl) > 0 && incl[0] == "/tmp/host-rs-array.conf" { found = true break } } } - require.True(t, found, "waf handler with rules_file should include host advanced_config array ruleset path") + require.True(t, found, "waf handler with include array should include host advanced_config array ruleset path") } func TestGenerateConfig_WAFUsesRulesetFromSecCfgFallback(t *testing.T) { @@ -336,18 +336,18 @@ func TestGenerateConfig_WAFUsesRulesetFromSecCfgFallback(t *testing.T) { rulesetPaths := map[string]string{"owasp-crs": "/tmp/owasp-fallback.conf"} cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, true, false, false, "", nil, rulesetPaths, nil, sec) require.NoError(t, err) - // since secCfg requested owasp-crs and we have a path, the wf handler should include rules_file + // since secCfg requested owasp-crs and we have a path, the waf handler should include the path in include array route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0] found := false for _, h := range route.Handle { if hn, ok := h["handler"].(string); ok && hn == "waf" { - if rf, ok := h["rules_file"].(string); ok && rf == "/tmp/owasp-fallback.conf" { + if incl, ok := h["include"].([]string); ok && len(incl) > 0 && incl[0] == "/tmp/owasp-fallback.conf" { found = true break } } } - require.True(t, found, "waf handler with rules_file should include fallback secCfg ruleset path") + require.True(t, found, "waf handler with include array should include fallback secCfg ruleset path") } func TestGenerateConfig_RateLimitFromSecCfg(t *testing.T) { diff --git a/backend/internal/caddy/manager.go b/backend/internal/caddy/manager.go index 1b17d704..3e10fbeb 100644 --- a/backend/internal/caddy/manager.go +++ b/backend/internal/caddy/manager.go @@ -20,12 +20,13 @@ import ( // Test hooks to allow overriding OS and JSON functions var ( - writeFileFunc = os.WriteFile - readFileFunc = os.ReadFile - removeFileFunc = os.Remove - readDirFunc = os.ReadDir - statFunc = os.Stat - jsonMarshalFunc = json.MarshalIndent + writeFileFunc = os.WriteFile + readFileFunc = os.ReadFile + removeFileFunc = os.Remove + readDirFunc = os.ReadDir + statFunc = os.Stat + jsonMarshalFunc = json.MarshalIndent + jsonMarshalDebugFunc = json.Marshal // For debug logging, separate hook for testing // Test hooks for bandaging validation/generation flows generateConfigFunc = GenerateConfig validateConfigFunc = Validate @@ -118,10 +119,22 @@ func (m *Manager) ApplyConfig(ctx context.Context) error { safeName := strings.ReplaceAll(strings.ToLower(rs.Name), " ", "-") safeName = strings.ReplaceAll(safeName, "/", "-") filePath := filepath.Join(corazaDir, safeName+".conf") - if err := writeFileFunc(filePath, []byte(rs.Content), 0600); err != nil { + // Prepend required Coraza directives if not already present. + // These are essential for the WAF to actually enforce rules: + // - SecRuleEngine On: enables blocking mode (default is DetectionOnly) + // - SecRequestBodyAccess On: allows inspecting POST body content + content := rs.Content + if !strings.Contains(strings.ToLower(content), "secruleengine") { + content = "SecRuleEngine On\nSecRequestBodyAccess On\n\n" + content + } + // Write ruleset file with world-readable permissions so the Caddy + // process (which may run as an unprivileged user) can read it. + if err := writeFileFunc(filePath, []byte(content), 0644); err != nil { logger.Log().WithError(err).WithField("ruleset", rs.Name).Warn("failed to write coraza ruleset file") } else { + // Log a short fingerprint for debugging and confirm path rulesetPaths[rs.Name] = filePath + logger.Log().WithField("ruleset", rs.Name).WithField("path", filePath).Info("wrote coraza ruleset file") } } } @@ -131,6 +144,13 @@ func (m *Manager) ApplyConfig(ctx context.Context) error { return fmt.Errorf("generate config: %w", err) } + // Log generated config size and a compact JSON snippet for debugging when in debug mode + if cfgJSON, jerr := jsonMarshalDebugFunc(config); jerr == nil { + logger.Log().WithField("config_json_len", len(cfgJSON)).Debug("generated Caddy config JSON") + } else { + logger.Log().WithError(jerr).Warn("failed to marshal generated config for debug logging") + } + // Validate before applying if err := validateConfigFunc(config); err != nil { return fmt.Errorf("validation failed: %w", err) diff --git a/backend/internal/caddy/manager_additional_test.go b/backend/internal/caddy/manager_additional_test.go index f4902220..3e2b46ce 100644 --- a/backend/internal/caddy/manager_additional_test.go +++ b/backend/internal/caddy/manager_additional_test.go @@ -718,12 +718,15 @@ func TestManager_ApplyConfig_IncludesWAFHandlerWithRuleset(t *testing.T) { if h == "ruleset.example.com" { for _, handle := range r.Handle { if handlerName, ok := handle["handler"].(string); ok && handlerName == "waf" { - // Validate rules_file or inline ruleset_content presence - if rf, ok := handle["rules_file"].(string); ok && rf != "" { - // Ensure file exists and contains our content - b, err := os.ReadFile(rf) - if err == nil && string(b) == "test-rule-content" { - found = true + // Validate include array (coraza-caddy schema) or inline ruleset_content presence + if incl, ok := handle["include"].([]interface{}); ok && len(incl) > 0 { + if rf, ok := incl[0].(string); ok && rf != "" { + // Ensure file exists and contains our content + // Note: manager prepends SecRuleEngine On directives, so we check Contains + b, err := os.ReadFile(rf) + if err == nil && strings.Contains(string(b), "test-rule-content") { + found = true + } } } // Inline content may also exist as a fallback @@ -990,3 +993,158 @@ func TestManager_ApplyConfig_ReappliesOnFlagChange(t *testing.T) { } assert.True(t, hasCrowdsec, "CrowdSec handler should be present when enabled via DB") } + +func TestManager_ApplyConfig_PrependsSecRuleEngineDirectives(t *testing.T) { + tmp := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()+"secruleengine") + 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 host and ruleset without SecRuleEngine directive + h := models.ProxyHost{DomainNames: "prepend.example.com", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true} + db.Create(&h) + // Ruleset content without SecRuleEngine - should be prepended + ruleContent := `SecRule REQUEST_BODY "