- Updated the GenerateConfig function to replace 'rules_file' with 'include' for WAF handlers, aligning with the coraza-caddy plugin requirements. - Modified related tests to check for the presence of 'include' instead of 'rules_file'. - Enhanced the ApplyConfig method to prepend necessary Coraza directives to ruleset files if not already present. - Added tests to verify that the SecRuleEngine directives are correctly prepended and that existing directives are not duplicated. - Implemented debug logging for generated config size and content.
2121 lines
110 KiB
HTML
2121 lines
110 KiB
HTML
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
|
<title>caddy: Go Coverage Report</title>
|
|
<style>
|
|
body {
|
|
background: black;
|
|
color: rgb(80, 80, 80);
|
|
}
|
|
body, pre, #legend span {
|
|
font-family: Menlo, monospace;
|
|
font-weight: bold;
|
|
}
|
|
#topbar {
|
|
background: black;
|
|
position: fixed;
|
|
top: 0; left: 0; right: 0;
|
|
height: 42px;
|
|
border-bottom: 1px solid rgb(80, 80, 80);
|
|
}
|
|
#content {
|
|
margin-top: 50px;
|
|
}
|
|
#nav, #legend {
|
|
float: left;
|
|
margin-left: 10px;
|
|
}
|
|
#legend {
|
|
margin-top: 12px;
|
|
}
|
|
#nav {
|
|
margin-top: 10px;
|
|
}
|
|
#legend span {
|
|
margin: 0 5px;
|
|
}
|
|
.cov0 { color: rgb(192, 0, 0) }
|
|
.cov1 { color: rgb(128, 128, 128) }
|
|
.cov2 { color: rgb(116, 140, 131) }
|
|
.cov3 { color: rgb(104, 152, 134) }
|
|
.cov4 { color: rgb(92, 164, 137) }
|
|
.cov5 { color: rgb(80, 176, 140) }
|
|
.cov6 { color: rgb(68, 188, 143) }
|
|
.cov7 { color: rgb(56, 200, 146) }
|
|
.cov8 { color: rgb(44, 212, 149) }
|
|
.cov9 { color: rgb(32, 224, 152) }
|
|
.cov10 { color: rgb(20, 236, 155) }
|
|
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="topbar">
|
|
<div id="nav">
|
|
<select id="files">
|
|
|
|
<option value="file0">github.com/Wikid82/charon/backend/internal/caddy/client.go (100.0%)</option>
|
|
|
|
<option value="file1">github.com/Wikid82/charon/backend/internal/caddy/config.go (100.0%)</option>
|
|
|
|
<option value="file2">github.com/Wikid82/charon/backend/internal/caddy/importer.go (100.0%)</option>
|
|
|
|
<option value="file3">github.com/Wikid82/charon/backend/internal/caddy/manager.go (99.3%)</option>
|
|
|
|
<option value="file4">github.com/Wikid82/charon/backend/internal/caddy/types.go (100.0%)</option>
|
|
|
|
<option value="file5">github.com/Wikid82/charon/backend/internal/caddy/validator.go (100.0%)</option>
|
|
|
|
</select>
|
|
</div>
|
|
<div id="legend">
|
|
<span>not tracked</span>
|
|
|
|
<span class="cov0">no coverage</span>
|
|
<span class="cov1">low coverage</span>
|
|
<span class="cov2">*</span>
|
|
<span class="cov3">*</span>
|
|
<span class="cov4">*</span>
|
|
<span class="cov5">*</span>
|
|
<span class="cov6">*</span>
|
|
<span class="cov7">*</span>
|
|
<span class="cov8">*</span>
|
|
<span class="cov9">*</span>
|
|
<span class="cov10">high coverage</span>
|
|
|
|
</div>
|
|
</div>
|
|
<div id="content">
|
|
|
|
<pre class="file" id="file0" style="display: none">package caddy
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"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
|
|
httpClient *http.Client
|
|
}
|
|
|
|
// NewClient creates a Caddy API client.
|
|
func NewClient(adminAPIURL string) *Client <span class="cov10" title="40">{
|
|
return &Client{
|
|
baseURL: adminAPIURL,
|
|
httpClient: &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
},
|
|
}
|
|
}</span>
|
|
|
|
// 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 <span class="cov9" title="30">{
|
|
body, err := jsonMarshalClient(config)
|
|
if err != nil </span><span class="cov1" title="1">{
|
|
return fmt.Errorf("marshal config: %w", err)
|
|
}</span>
|
|
|
|
<span class="cov9" title="29">req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/load", bytes.NewReader(body))
|
|
if err != nil </span><span class="cov2" title="2">{
|
|
return fmt.Errorf("create request: %w", err)
|
|
}</span>
|
|
<span class="cov9" title="27">req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil </span><span class="cov1" title="1">{
|
|
return fmt.Errorf("execute request: %w", err)
|
|
}</span>
|
|
<span class="cov8" title="26">defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK </span><span class="cov6" title="8">{
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("caddy returned status %d: %s", resp.StatusCode, string(bodyBytes))
|
|
}</span>
|
|
|
|
<span class="cov8" title="18">return nil</span>
|
|
}
|
|
|
|
// GetConfig retrieves the current running configuration from Caddy.
|
|
func (c *Client) GetConfig(ctx context.Context) (*Config, error) <span class="cov5" title="6">{
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/config/", nil)
|
|
if err != nil </span><span class="cov1" title="1">{
|
|
return nil, fmt.Errorf("create request: %w", err)
|
|
}</span>
|
|
|
|
<span class="cov4" title="5">resp, err := c.httpClient.Do(req)
|
|
if err != nil </span><span class="cov1" title="1">{
|
|
return nil, fmt.Errorf("execute request: %w", err)
|
|
}</span>
|
|
<span class="cov4" title="4">defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK </span><span class="cov1" title="1">{
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
return nil, fmt.Errorf("caddy returned status %d: %s", resp.StatusCode, string(bodyBytes))
|
|
}</span>
|
|
|
|
<span class="cov3" title="3">var config Config
|
|
if err := json.NewDecoder(resp.Body).Decode(&config); err != nil </span><span class="cov1" title="1">{
|
|
return nil, fmt.Errorf("decode response: %w", err)
|
|
}</span>
|
|
|
|
<span class="cov2" title="2">return &config, nil</span>
|
|
}
|
|
|
|
// Ping checks if Caddy admin API is reachable.
|
|
func (c *Client) Ping(ctx context.Context) error <span class="cov5" title="7">{
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/config/", nil)
|
|
if err != nil </span><span class="cov2" title="2">{
|
|
return fmt.Errorf("create request: %w", err)
|
|
}</span>
|
|
|
|
<span class="cov4" title="5">resp, err := c.httpClient.Do(req)
|
|
if err != nil </span><span class="cov1" title="1">{
|
|
return fmt.Errorf("caddy unreachable: %w", err)
|
|
}</span>
|
|
<span class="cov4" title="4">defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK </span><span class="cov2" title="2">{
|
|
return fmt.Errorf("caddy returned status %d", resp.StatusCode)
|
|
}</span>
|
|
|
|
<span class="cov2" title="2">return nil</span>
|
|
}
|
|
</pre>
|
|
|
|
<pre class="file" id="file1" style="display: none">package caddy
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"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, 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) <span class="cov10" title="70">{
|
|
// Define log file paths
|
|
// We assume storageDir is like ".../data/caddy/data", so we go up to ".../data/logs"
|
|
// storageDir is .../data/caddy/data
|
|
// Dir -> .../data/caddy
|
|
// Dir -> .../data
|
|
logDir := filepath.Join(filepath.Dir(filepath.Dir(storageDir)), "logs")
|
|
logFile := filepath.Join(logDir, "access.log")
|
|
|
|
config := &Config{
|
|
Logging: &LoggingConfig{
|
|
Logs: map[string]*LogConfig{
|
|
"access": {
|
|
Level: "INFO",
|
|
Writer: &WriterConfig{
|
|
Output: "file",
|
|
Filename: logFile,
|
|
Roll: true,
|
|
RollSize: 10, // 10 MB
|
|
RollKeep: 5, // Keep 5 files
|
|
RollKeepDays: 7, // Keep for 7 days
|
|
},
|
|
Encoder: &EncoderConfig{
|
|
Format: "json",
|
|
},
|
|
Include: []string{"http.log.access.access_log"},
|
|
},
|
|
},
|
|
},
|
|
Apps: Apps{
|
|
HTTP: &HTTPApp{
|
|
Servers: map[string]*Server{},
|
|
},
|
|
},
|
|
Storage: Storage{
|
|
System: "file_system",
|
|
Root: storageDir,
|
|
},
|
|
}
|
|
|
|
if acmeEmail != "" </span><span class="cov6" title="16">{
|
|
var issuers []interface{}
|
|
|
|
// Configure issuers based on provider preference
|
|
switch sslProvider </span>{
|
|
case "letsencrypt":<span class="cov3" title="3">
|
|
acmeIssuer := map[string]interface{}{
|
|
"module": "acme",
|
|
"email": acmeEmail,
|
|
}
|
|
if acmeStaging </span><span class="cov2" title="2">{
|
|
acmeIssuer["ca"] = "https://acme-staging-v02.api.letsencrypt.org/directory"
|
|
}</span>
|
|
<span class="cov3" title="3">issuers = append(issuers, acmeIssuer)</span>
|
|
case "zerossl":<span class="cov2" title="2">
|
|
issuers = append(issuers, map[string]interface{}{
|
|
"module": "zerossl",
|
|
})</span>
|
|
default:<span class="cov6" title="11"> // "both" or empty
|
|
acmeIssuer := map[string]interface{}{
|
|
"module": "acme",
|
|
"email": acmeEmail,
|
|
}
|
|
if acmeStaging </span><span class="cov1" title="1">{
|
|
acmeIssuer["ca"] = "https://acme-staging-v02.api.letsencrypt.org/directory"
|
|
}</span>
|
|
<span class="cov6" title="11">issuers = append(issuers, acmeIssuer)
|
|
issuers = append(issuers, map[string]interface{}{
|
|
"module": "zerossl",
|
|
})</span>
|
|
}
|
|
|
|
<span class="cov6" title="16">config.Apps.TLS = &TLSApp{
|
|
Automation: &AutomationConfig{
|
|
Policies: []*AutomationPolicy{
|
|
{
|
|
IssuersRaw: issuers,
|
|
},
|
|
},
|
|
},
|
|
}</span>
|
|
}
|
|
|
|
// Collect CUSTOM certificates only (not Let's Encrypt - those are managed by ACME)
|
|
// Only custom/uploaded certificates should be loaded via LoadPEM
|
|
<span class="cov10" title="70">customCerts := make(map[uint]models.SSLCertificate)
|
|
for _, host := range hosts </span><span class="cov9" title="68">{
|
|
if host.CertificateID != nil && host.Certificate != nil </span><span class="cov3" title="3">{
|
|
// Only include custom certificates, not ACME-managed ones
|
|
if host.Certificate.Provider == "custom" </span><span class="cov3" title="3">{
|
|
customCerts[*host.CertificateID] = *host.Certificate
|
|
}</span>
|
|
}
|
|
}
|
|
|
|
<span class="cov10" title="70">if len(customCerts) > 0 </span><span class="cov3" title="3">{
|
|
var loadPEM []LoadPEMConfig
|
|
for _, cert := range customCerts </span><span class="cov3" title="3">{
|
|
// Validate that custom cert has both certificate and key
|
|
if cert.Certificate == "" || cert.PrivateKey == "" </span><span class="cov1" title="1">{
|
|
logger.Log().WithField("cert", cert.Name).Warn("Custom certificate missing certificate or key, skipping")
|
|
continue</span>
|
|
}
|
|
<span class="cov2" title="2">loadPEM = append(loadPEM, LoadPEMConfig{
|
|
Certificate: cert.Certificate,
|
|
Key: cert.PrivateKey,
|
|
Tags: []string{cert.UUID},
|
|
})</span>
|
|
}
|
|
|
|
<span class="cov3" title="3">if len(loadPEM) > 0 </span><span class="cov2" title="2">{
|
|
if config.Apps.TLS == nil </span><span class="cov1" title="1">{
|
|
config.Apps.TLS = &TLSApp{}
|
|
}</span>
|
|
<span class="cov2" title="2">config.Apps.TLS.Certificates = &CertificatesConfig{
|
|
LoadPEM: loadPEM,
|
|
}</span>
|
|
}
|
|
}
|
|
|
|
<span class="cov10" title="70">if len(hosts) == 0 && frontendDir == "" </span><span class="cov4" title="5">{
|
|
return config, nil
|
|
}</span>
|
|
|
|
// Initialize routes slice
|
|
<span class="cov9" title="65">routes := make([]*Route, 0)
|
|
|
|
// Track processed domains to prevent duplicates (Ghost Host fix)
|
|
processedDomains := make(map[string]bool)
|
|
|
|
// Sort hosts by UpdatedAt desc to prefer newer configs in case of duplicates
|
|
// Note: This assumes the input slice is already sorted or we don't care about order beyond duplicates
|
|
// The caller (ApplyConfig) fetches all hosts. We should probably sort them here or there.
|
|
// For now, we'll just process them. If we encounter a duplicate domain, we skip it.
|
|
// To ensure we keep the *latest* one, we should iterate in reverse or sort.
|
|
// But ApplyConfig uses db.Find(&hosts), which usually returns by ID asc.
|
|
// So later IDs (newer) come last.
|
|
// We want to keep the NEWER one.
|
|
// So we should iterate backwards? Or just overwrite?
|
|
// Caddy config structure is a list of servers/routes.
|
|
// If we have multiple routes matching the same host, Caddy uses the first one?
|
|
// Actually, Caddy matches routes in order.
|
|
// If we emit two routes for "example.com", the first one will catch it.
|
|
// So we want the NEWEST one to be FIRST in the list?
|
|
// Or we want to only emit ONE route for "example.com".
|
|
// If we emit only one, it should be the newest one.
|
|
// 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-- </span><span class="cov9" title="68">{
|
|
host := hosts[i]
|
|
|
|
if !host.Enabled </span><span class="cov1" title="1">{
|
|
continue</span>
|
|
}
|
|
|
|
<span class="cov9" title="67">if host.DomainNames == "" </span><span class="cov2" title="2">{
|
|
// Log warning?
|
|
continue</span>
|
|
}
|
|
|
|
// Parse comma-separated domains
|
|
<span class="cov9" title="65">rawDomains := strings.Split(host.DomainNames, ",")
|
|
var uniqueDomains []string
|
|
|
|
for _, d := range rawDomains </span><span class="cov9" title="66">{
|
|
d = strings.TrimSpace(d)
|
|
d = strings.ToLower(d) // Normalize to lowercase
|
|
if d == "" </span><span class="cov1" title="1">{
|
|
continue</span>
|
|
}
|
|
<span class="cov9" title="65">if processedDomains[d] </span><span class="cov1" title="1">{
|
|
logger.Log().WithField("domain", d).WithField("host", host.UUID).Warn("Skipping duplicate domain for host (Ghost Host detection)")
|
|
continue</span>
|
|
}
|
|
<span class="cov9" title="64">processedDomains[d] = true
|
|
uniqueDomains = append(uniqueDomains, d)</span>
|
|
}
|
|
|
|
<span class="cov9" title="65">if len(uniqueDomains) == 0 </span><span class="cov1" title="1">{
|
|
continue</span>
|
|
}
|
|
|
|
// Build handlers for this host
|
|
<span class="cov9" title="64">handlers := make([]Handler, 0)
|
|
|
|
// 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 </span><span class="cov2" title="2">{
|
|
if d.Action == "block" && d.IP != "" </span><span class="cov2" title="2">{
|
|
decisionIPs = append(decisionIPs, d.IP)
|
|
}</span>
|
|
}
|
|
<span class="cov9" title="64">if len(decisionIPs) > 0 </span><span class="cov2" title="2">{
|
|
// 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 != "" </span><span class="cov2" title="2">{
|
|
adminParts := strings.Split(adminWhitelist, ",")
|
|
trims := make([]string, 0)
|
|
for _, p := range adminParts </span><span class="cov3" title="3">{
|
|
p = strings.TrimSpace(p)
|
|
if p == "" </span><span class="cov1" title="1">{
|
|
continue</span>
|
|
}
|
|
<span class="cov2" title="2">trims = append(trims, p)</span>
|
|
}
|
|
<span class="cov2" title="2">if len(trims) > 0 </span><span class="cov2" title="2">{
|
|
matchParts = append(matchParts, map[string]interface{}{"not": []map[string]interface{}{{"remote_ip": map[string]interface{}{"ranges": trims}}}})
|
|
}</span>
|
|
}
|
|
<span class="cov2" title="2">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)</span>
|
|
}
|
|
|
|
// 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.
|
|
<span class="cov9" title="64">if csH, err := buildCrowdSecHandler(&host, secCfg, crowdsecEnabled); err == nil && csH != nil </span><span class="cov3" title="4">{
|
|
securityHandlers = append(securityHandlers, csH)
|
|
}</span>
|
|
|
|
// WAF handler (placeholder) — add according to runtime flag
|
|
<span class="cov9" title="64">if wafH, err := buildWAFHandler(&host, rulesets, rulesetPaths, secCfg, wafEnabled); err == nil && wafH != nil </span><span class="cov6" title="15">{
|
|
securityHandlers = append(securityHandlers, wafH)
|
|
}</span>
|
|
|
|
// Rate Limit handler (placeholder)
|
|
<span class="cov9" title="64">if rateLimitEnabled </span><span class="cov3" title="3">{
|
|
if rlH, err := buildRateLimitHandler(&host, secCfg); err == nil && rlH != nil </span><span class="cov3" title="3">{
|
|
securityHandlers = append(securityHandlers, rlH)
|
|
}</span>
|
|
}
|
|
|
|
// Add Access Control List (ACL) handler if configured and global ACL is enabled
|
|
<span class="cov9" title="64">if aclEnabled && host.AccessListID != nil && host.AccessList != nil && host.AccessList.Enabled </span><span class="cov4" title="6">{
|
|
aclHandler, err := buildACLHandler(host.AccessList, adminWhitelist)
|
|
if err != nil </span><span class="cov1" title="1">{
|
|
logger.Log().WithField("host", host.UUID).WithError(err).Warn("Failed to build ACL handler for host")
|
|
}</span> else<span class="cov4" title="5"> if aclHandler != nil </span><span class="cov4" title="5">{
|
|
securityHandlers = append(securityHandlers, aclHandler)
|
|
}</span>
|
|
}
|
|
|
|
// Add HSTS header if enabled
|
|
<span class="cov9" title="64">if host.HSTSEnabled </span><span class="cov3" title="4">{
|
|
hstsValue := "max-age=31536000"
|
|
if host.HSTSSubdomains </span><span class="cov2" title="2">{
|
|
hstsValue += "; includeSubDomains"
|
|
}</span>
|
|
<span class="cov3" title="4">handlers = append(handlers, HeaderHandler(map[string][]string{
|
|
"Strict-Transport-Security": {hstsValue},
|
|
}))</span>
|
|
}
|
|
|
|
// Add exploit blocking if enabled
|
|
<span class="cov9" title="64">if host.BlockExploits </span><span class="cov7" title="25">{
|
|
handlers = append(handlers, BlockExploitsHandler())
|
|
}</span>
|
|
|
|
// Handle custom locations first (more specific routes)
|
|
<span class="cov9" title="64">for _, loc := range host.Locations </span><span class="cov3" title="3">{
|
|
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{
|
|
{
|
|
Host: uniqueDomains,
|
|
Path: []string{loc.Path, loc.Path + "/*"},
|
|
},
|
|
},
|
|
Handle: locHandlers,
|
|
Terminal: true,
|
|
}
|
|
routes = append(routes, locRoute)
|
|
}</span>
|
|
|
|
// Main proxy handler
|
|
<span class="cov9" title="64">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 != "" </span><span class="cov5" title="8">{
|
|
var parsed interface{}
|
|
if err := json.Unmarshal([]byte(host.AdvancedConfig), &parsed); err != nil </span><span class="cov1" title="1">{
|
|
logger.Log().WithField("host", host.UUID).WithError(err).Warn("Failed to parse advanced_config for host")
|
|
}</span> else<span class="cov5" title="7"> {
|
|
switch v := parsed.(type) </span>{
|
|
case map[string]interface{}:<span class="cov3" title="4">
|
|
// Append as a handler
|
|
// Ensure it has a "handler" key
|
|
if _, ok := v["handler"]; ok </span><span class="cov3" title="3">{
|
|
// 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 </span><span class="cov1" title="1">{
|
|
if rnStr, ok := rn.(string); ok && rnStr != "" </span><span class="cov1" title="1">{
|
|
// Set 'include' array with the ruleset file path for coraza-caddy
|
|
if rulesetPaths != nil </span><span class="cov1" title="1">{
|
|
if p, ok := rulesetPaths[rnStr]; ok && p != "" </span><span class="cov1" title="1">{
|
|
v["include"] = []string{p}
|
|
}</span>
|
|
}
|
|
}
|
|
<span class="cov1" title="1">delete(v, "ruleset_name")</span>
|
|
}
|
|
<span class="cov3" title="3">normalizeHandlerHeaders(v)
|
|
handlers = append(handlers, Handler(v))</span>
|
|
} else<span class="cov1" title="1"> {
|
|
logger.Log().WithField("host", host.UUID).Warn("advanced_config for host is not a handler object")
|
|
}</span>
|
|
case []interface{}:<span class="cov2" title="2">
|
|
for _, it := range v </span><span class="cov2" title="2">{
|
|
if m, ok := it.(map[string]interface{}); ok </span><span class="cov2" title="2">{
|
|
if rn, has := m["ruleset_name"]; has </span><span class="cov1" title="1">{
|
|
if rnStr, ok := rn.(string); ok && rnStr != "" </span><span class="cov1" title="1">{
|
|
if rulesetPaths != nil </span><span class="cov1" title="1">{
|
|
if p, ok := rulesetPaths[rnStr]; ok && p != "" </span><span class="cov1" title="1">{
|
|
m["include"] = []string{p}
|
|
}</span>
|
|
}
|
|
}
|
|
<span class="cov1" title="1">delete(m, "ruleset_name")</span>
|
|
}
|
|
<span class="cov2" title="2">normalizeHandlerHeaders(m)
|
|
if _, ok2 := m["handler"]; ok2 </span><span class="cov2" title="2">{
|
|
handlers = append(handlers, Handler(m))
|
|
}</span>
|
|
}
|
|
}
|
|
default:<span class="cov1" title="1">
|
|
logger.Log().WithField("host", host.UUID).Warn("advanced_config for host has unexpected JSON structure")</span>
|
|
}
|
|
}
|
|
}
|
|
// Build main handlers: security pre-handlers, other host-level handlers, then reverse proxy
|
|
<span class="cov9" title="64">mainHandlers := append(append([]Handler{}, securityHandlers...), handlers...)
|
|
mainHandlers = append(mainHandlers, ReverseProxyHandler(dial, host.WebsocketSupport, host.Application))
|
|
|
|
route := &Route{
|
|
Match: []Match{
|
|
{Host: uniqueDomains},
|
|
},
|
|
Handle: mainHandlers,
|
|
Terminal: true,
|
|
}
|
|
|
|
routes = append(routes, route)</span>
|
|
}
|
|
|
|
// Add catch-all 404 handler
|
|
// This matches any request that wasn't handled by previous routes
|
|
<span class="cov9" title="65">if frontendDir != "" </span><span class="cov5" title="10">{
|
|
catchAllRoute := &Route{
|
|
Handle: []Handler{
|
|
RewriteHandler("/unknown.html"),
|
|
FileServerHandler(frontendDir),
|
|
},
|
|
Terminal: true,
|
|
}
|
|
routes = append(routes, catchAllRoute)
|
|
}</span>
|
|
|
|
<span class="cov9" title="65">config.Apps.HTTP.Servers["charon_server"] = &Server{
|
|
Listen: []string{":80", ":443"},
|
|
Routes: routes,
|
|
AutoHTTPS: &AutoHTTPSConfig{
|
|
Disable: false,
|
|
DisableRedir: false,
|
|
},
|
|
Logs: &ServerLogs{
|
|
DefaultLoggerName: "access_log",
|
|
},
|
|
}
|
|
|
|
return config, nil</span>
|
|
}
|
|
|
|
// 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{}) <span class="cov6" title="14">{
|
|
// normalize top-level headers key
|
|
if headersRaw, ok := h["headers"].(map[string]interface{}); ok </span><span class="cov2" title="2">{
|
|
normalizeHeaderOps(headersRaw)
|
|
}</span>
|
|
// also normalize in nested request/response if present explicitly
|
|
<span class="cov6" title="14">for _, side := range []string{"request", "response"} </span><span class="cov8" title="28">{
|
|
if sideRaw, ok := h[side].(map[string]interface{}); ok </span><span class="cov5" title="10">{
|
|
normalizeHeaderOps(sideRaw)
|
|
}</span>
|
|
}
|
|
}
|
|
|
|
func normalizeHeaderOps(headerOps map[string]interface{}) <span class="cov6" title="13">{
|
|
if setRaw, ok := headerOps["set"].(map[string]interface{}); ok </span><span class="cov6" title="13">{
|
|
for k, v := range setRaw </span><span class="cov6" title="13">{
|
|
switch vv := v.(type) </span>{
|
|
case string:<span class="cov5" title="7">
|
|
setRaw[k] = []string{vv}</span>
|
|
case []interface{}:<span class="cov3" title="3">
|
|
// convert to []string
|
|
arr := make([]string, 0, len(vv))
|
|
for _, it := range vv </span><span class="cov3" title="3">{
|
|
arr = append(arr, fmt.Sprintf("%v", it))
|
|
}</span>
|
|
<span class="cov3" title="3">setRaw[k] = arr</span>
|
|
case []string:<span class="cov2" title="2"></span>
|
|
// nothing to do
|
|
default:<span class="cov1" title="1">
|
|
// coerce anything else to string slice
|
|
setRaw[k] = []string{fmt.Sprintf("%v", vv)}</span>
|
|
}
|
|
}
|
|
<span class="cov6" title="13">headerOps["set"] = setRaw</span>
|
|
}
|
|
}
|
|
|
|
// 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{} <span class="cov6" title="12">{
|
|
switch v := parsed.(type) </span>{
|
|
case map[string]interface{}:<span class="cov5" title="9">
|
|
// 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 </span><span class="cov1" title="1">{
|
|
for _, it := range handles </span><span class="cov1" title="1">{
|
|
if m, ok := it.(map[string]interface{}); ok </span><span class="cov1" title="1">{
|
|
NormalizeAdvancedConfig(m)
|
|
}</span>
|
|
}
|
|
}
|
|
<span class="cov5" title="9">if routes, ok := v["routes"].([]interface{}); ok </span><span class="cov1" title="1">{
|
|
for _, rit := range routes </span><span class="cov1" title="1">{
|
|
if rm, ok := rit.(map[string]interface{}); ok </span><span class="cov1" title="1">{
|
|
if handles, ok := rm["handle"].([]interface{}); ok </span><span class="cov1" title="1">{
|
|
for _, it := range handles </span><span class="cov1" title="1">{
|
|
if m, ok := it.(map[string]interface{}); ok </span><span class="cov1" title="1">{
|
|
NormalizeAdvancedConfig(m)
|
|
}</span>
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
<span class="cov5" title="9">return v</span>
|
|
case []interface{}:<span class="cov1" title="1">
|
|
for _, it := range v </span><span class="cov1" title="1">{
|
|
if m, ok := it.(map[string]interface{}); ok </span><span class="cov1" title="1">{
|
|
NormalizeAdvancedConfig(m)
|
|
}</span>
|
|
}
|
|
<span class="cov1" title="1">return v</span>
|
|
default:<span class="cov2" title="2">
|
|
return parsed</span>
|
|
}
|
|
}
|
|
|
|
// buildACLHandler creates access control handlers based on the AccessList configuration
|
|
func buildACLHandler(acl *models.AccessList, adminWhitelist string) (Handler, error) <span class="cov7" title="23">{
|
|
// 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_") </span><span class="cov3" title="4">{
|
|
// Geo-blocking using caddy-geoip2
|
|
countryCodes := strings.Split(acl.CountryCodes, ",")
|
|
var trimmedCodes []string
|
|
for _, code := range countryCodes </span><span class="cov5" title="7">{
|
|
trimmedCodes = append(trimmedCodes, `"`+strings.TrimSpace(code)+`"`)
|
|
}</span>
|
|
|
|
<span class="cov3" title="4">var expression string
|
|
if acl.Type == "geo_whitelist" </span><span class="cov2" title="2">{
|
|
// Allow only these countries, so block when not in the whitelist
|
|
expression = fmt.Sprintf("{geoip2.country_code} 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
|
|
}</span>
|
|
// geo_blacklist: Block these countries directly
|
|
<span class="cov2" title="2">expression = fmt.Sprintf("{geoip2.country_code} in [%s]", strings.Join(trimmedCodes, ", "))
|
|
return Handler{
|
|
"handler": "subroute",
|
|
"routes": []map[string]interface{}{
|
|
{
|
|
"match": []map[string]interface{}{
|
|
{
|
|
"expression": expression,
|
|
},
|
|
},
|
|
"handle": []map[string]interface{}{
|
|
{
|
|
"handler": "static_response",
|
|
"status_code": 403,
|
|
"body": "Access denied: Geographic restriction",
|
|
},
|
|
},
|
|
"terminal": true,
|
|
},
|
|
},
|
|
}, nil</span>
|
|
}
|
|
|
|
// IP/CIDR-based ACLs using Caddy's native remote_ip matcher
|
|
<span class="cov7" title="19">if acl.LocalNetworkOnly </span><span class="cov2" title="2">{
|
|
// Allow only RFC1918 private networks
|
|
return Handler{
|
|
"handler": "subroute",
|
|
"routes": []map[string]interface{}{
|
|
{
|
|
"match": []map[string]interface{}{
|
|
{
|
|
"not": []map[string]interface{}{
|
|
{
|
|
"remote_ip": map[string]interface{}{
|
|
"ranges": []string{
|
|
"10.0.0.0/8",
|
|
"172.16.0.0/12",
|
|
"192.168.0.0/16",
|
|
"127.0.0.0/8",
|
|
"169.254.0.0/16",
|
|
"fc00::/7",
|
|
"fe80::/10",
|
|
"::1/128",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
"handle": []map[string]interface{}{
|
|
{
|
|
"handler": "static_response",
|
|
"status_code": 403,
|
|
"body": "Access denied: Not a local network IP",
|
|
},
|
|
},
|
|
"terminal": true,
|
|
},
|
|
},
|
|
}, nil
|
|
}</span>
|
|
|
|
// Parse IP rules
|
|
<span class="cov7" title="17">if acl.IPRules == "" </span><span class="cov1" title="1">{
|
|
return nil, nil
|
|
}</span>
|
|
|
|
<span class="cov6" title="16">var rules []models.AccessListRule
|
|
if err := json.Unmarshal([]byte(acl.IPRules), &rules); err != nil </span><span class="cov2" title="2">{
|
|
return nil, fmt.Errorf("invalid IP rules JSON: %w", err)
|
|
}</span>
|
|
|
|
<span class="cov6" title="14">if len(rules) == 0 </span><span class="cov1" title="1">{
|
|
return nil, nil
|
|
}</span>
|
|
|
|
// Extract CIDR ranges
|
|
<span class="cov6" title="13">var cidrs []string
|
|
for _, rule := range rules </span><span class="cov6" title="13">{
|
|
cidrs = append(cidrs, rule.CIDR)
|
|
}</span>
|
|
|
|
<span class="cov6" title="13">if acl.Type == "whitelist" </span><span class="cov5" title="9">{
|
|
// Allow only these IPs (block everything else)
|
|
// Merge adminWhitelist into allowed cidrs so that admins always bypass whitelist checks
|
|
if adminWhitelist != "" </span><span class="cov2" title="2">{
|
|
adminParts := strings.Split(adminWhitelist, ",")
|
|
for _, p := range adminParts </span><span class="cov4" title="5">{
|
|
p = strings.TrimSpace(p)
|
|
if p == "" </span><span class="cov2" title="2">{
|
|
continue</span>
|
|
}
|
|
<span class="cov3" title="3">cidrs = append(cidrs, p)</span>
|
|
}
|
|
}
|
|
<span class="cov5" title="9">return Handler{
|
|
"handler": "subroute",
|
|
"routes": []map[string]interface{}{
|
|
{
|
|
"match": []map[string]interface{}{
|
|
{
|
|
"not": []map[string]interface{}{
|
|
{
|
|
"remote_ip": map[string]interface{}{
|
|
"ranges": cidrs,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
"handle": []map[string]interface{}{
|
|
{
|
|
"handler": "static_response",
|
|
"status_code": 403,
|
|
"body": "Access denied: IP not in whitelist",
|
|
},
|
|
},
|
|
"terminal": true,
|
|
},
|
|
},
|
|
}, nil</span>
|
|
}
|
|
|
|
<span class="cov3" title="4">if acl.Type == "blacklist" </span><span class="cov3" title="3">{
|
|
// Block these IPs (allow everything else)
|
|
// For blacklist, add an explicit 'not' clause excluding adminWhitelist ranges from the match
|
|
var adminExclusion interface{}
|
|
if adminWhitelist != "" </span><span class="cov2" title="2">{
|
|
adminParts := strings.Split(adminWhitelist, ",")
|
|
trims := make([]string, 0)
|
|
for _, p := range adminParts </span><span class="cov3" title="4">{
|
|
p = strings.TrimSpace(p)
|
|
if p == "" </span><span class="cov2" title="2">{
|
|
continue</span>
|
|
}
|
|
<span class="cov2" title="2">trims = append(trims, p)</span>
|
|
}
|
|
<span class="cov2" title="2">if len(trims) > 0 </span><span class="cov2" title="2">{
|
|
adminExclusion = map[string]interface{}{"not": []map[string]interface{}{{"remote_ip": map[string]interface{}{"ranges": trims}}}}
|
|
}</span>
|
|
}
|
|
// Build matcher parts
|
|
<span class="cov3" title="3">matchParts := []map[string]interface{}{}
|
|
matchParts = append(matchParts, map[string]interface{}{"remote_ip": map[string]interface{}{"ranges": cidrs}})
|
|
if adminExclusion != nil </span><span class="cov2" title="2">{
|
|
matchParts = append(matchParts, adminExclusion.(map[string]interface{}))
|
|
}</span>
|
|
<span class="cov3" title="3">return Handler{
|
|
"handler": "subroute",
|
|
"routes": []map[string]interface{}{
|
|
{
|
|
"match": matchParts,
|
|
"handle": []map[string]interface{}{
|
|
{
|
|
"handler": "static_response",
|
|
"status_code": 403,
|
|
"body": "Access denied: IP blacklisted",
|
|
},
|
|
},
|
|
"terminal": true,
|
|
},
|
|
},
|
|
}, nil</span>
|
|
}
|
|
|
|
<span class="cov1" title="1">return nil, nil</span>
|
|
}
|
|
|
|
// 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) <span class="cov9" title="64">{
|
|
// 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 </span><span class="cov9" title="60">{
|
|
return nil, nil
|
|
}</span>
|
|
// For now, the local-only mode is supported; crowdsecEnabled implies 'local'
|
|
<span class="cov3" title="4">h := Handler{"handler": "crowdsec"}
|
|
h["mode"] = "local"
|
|
return h, nil</span>
|
|
}
|
|
|
|
// 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) <span class="cov9" title="64">{
|
|
// If the host provided an advanced_config containing a 'ruleset_name', prefer that value
|
|
var hostRulesetName string
|
|
if host != nil && host.AdvancedConfig != "" </span><span class="cov5" title="8">{
|
|
var ac map[string]interface{}
|
|
if err := json.Unmarshal([]byte(host.AdvancedConfig), &ac); err == nil </span><span class="cov3" title="4">{
|
|
if rn, ok := ac["ruleset_name"]; ok </span><span class="cov1" title="1">{
|
|
if rnStr, ok2 := rn.(string); ok2 && rnStr != "" </span><span class="cov1" title="1">{
|
|
hostRulesetName = rnStr
|
|
}</span>
|
|
}
|
|
}
|
|
}
|
|
|
|
// Find a ruleset to associate with WAF; prefer name match by host.Application, host.AdvancedConfig ruleset_name or default 'owasp-crs'
|
|
<span class="cov9" title="64">var selected *models.SecurityRuleSet
|
|
for i, r := range rulesets </span><span class="cov5" title="9">{
|
|
if r.Name == "owasp-crs" || (host != nil && r.Name == host.Application) || (hostRulesetName != "" && r.Name == hostRulesetName) || (secCfg != nil && r.Name == secCfg.WAFRulesSource) </span><span class="cov5" title="8">{
|
|
selected = &rulesets[i]
|
|
break</span>
|
|
}
|
|
}
|
|
|
|
<span class="cov9" title="64">if !wafEnabled </span><span class="cov9" title="48">{
|
|
return nil, nil
|
|
}</span>
|
|
<span class="cov6" title="16">h := Handler{"handler": "waf"}
|
|
if selected != nil </span><span class="cov5" title="8">{
|
|
if rulesetPaths != nil </span><span class="cov5" title="8">{
|
|
if p, ok := rulesetPaths[selected.Name]; ok && p != "" </span><span class="cov4" title="6">{
|
|
h["include"] = []string{p}
|
|
}</span>
|
|
}
|
|
} else<span class="cov5" title="8"> if secCfg != nil && secCfg.WAFRulesSource != "" </span><span class="cov3" title="3">{
|
|
// If there was a requested ruleset name but nothing matched, include path if known
|
|
if rulesetPaths != nil </span><span class="cov1" title="1">{
|
|
if p, ok := rulesetPaths[secCfg.WAFRulesSource]; ok && p != "" </span><span class="cov1" title="1">{
|
|
h["include"] = []string{p}
|
|
}</span>
|
|
}
|
|
}
|
|
// WAF enablement is handled by the caller. Don't add a 'mode' field
|
|
// here because the module expects a specific configuration schema.
|
|
<span class="cov6" title="16">if secCfg != nil && secCfg.WAFMode == "disabled" </span><span class="cov1" title="1">{
|
|
return nil, nil
|
|
}</span>
|
|
<span class="cov6" title="15">return h, nil</span>
|
|
}
|
|
|
|
// 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) <span class="cov3" title="3">{
|
|
// 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 </span><span class="cov1" title="1">{
|
|
h["requests"] = secCfg.RateLimitRequests
|
|
h["window_sec"] = secCfg.RateLimitWindowSec
|
|
h["burst"] = secCfg.RateLimitBurst
|
|
}</span>
|
|
<span class="cov3" title="3">return h, nil</span>
|
|
}
|
|
</pre>
|
|
|
|
<pre class="file" id="file2" style="display: none">package caddy
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/Wikid82/charon/backend/internal/models"
|
|
)
|
|
|
|
// Executor defines an interface for executing shell commands.
|
|
type Executor interface {
|
|
Execute(name string, args ...string) ([]byte, error)
|
|
}
|
|
|
|
// DefaultExecutor implements Executor using os/exec.
|
|
type DefaultExecutor struct{}
|
|
|
|
func (e *DefaultExecutor) Execute(name string, args ...string) ([]byte, error) <span class="cov1" title="1">{
|
|
return exec.Command(name, args...).Output()
|
|
}</span>
|
|
|
|
// CaddyConfig represents the root structure of Caddy's JSON config.
|
|
type CaddyConfig struct {
|
|
Apps *CaddyApps `json:"apps,omitempty"`
|
|
}
|
|
|
|
// CaddyApps contains application-specific configurations.
|
|
type CaddyApps struct {
|
|
HTTP *CaddyHTTP `json:"http,omitempty"`
|
|
}
|
|
|
|
// CaddyHTTP represents the HTTP app configuration.
|
|
type CaddyHTTP struct {
|
|
Servers map[string]*CaddyServer `json:"servers,omitempty"`
|
|
}
|
|
|
|
// CaddyServer represents a single server configuration.
|
|
type CaddyServer struct {
|
|
Listen []string `json:"listen,omitempty"`
|
|
Routes []*CaddyRoute `json:"routes,omitempty"`
|
|
TLSConnectionPolicies interface{} `json:"tls_connection_policies,omitempty"`
|
|
}
|
|
|
|
// CaddyRoute represents a single route with matchers and handlers.
|
|
type CaddyRoute struct {
|
|
Match []*CaddyMatcher `json:"match,omitempty"`
|
|
Handle []*CaddyHandler `json:"handle,omitempty"`
|
|
}
|
|
|
|
// CaddyMatcher represents route matching criteria.
|
|
type CaddyMatcher struct {
|
|
Host []string `json:"host,omitempty"`
|
|
}
|
|
|
|
// CaddyHandler represents a handler in the route.
|
|
type CaddyHandler struct {
|
|
Handler string `json:"handler"`
|
|
Upstreams interface{} `json:"upstreams,omitempty"`
|
|
Headers interface{} `json:"headers,omitempty"`
|
|
Routes interface{} `json:"routes,omitempty"` // For subroute handlers
|
|
}
|
|
|
|
// ParsedHost represents a single host detected during Caddyfile import.
|
|
type ParsedHost struct {
|
|
DomainNames string `json:"domain_names"`
|
|
ForwardScheme string `json:"forward_scheme"`
|
|
ForwardHost string `json:"forward_host"`
|
|
ForwardPort int `json:"forward_port"`
|
|
SSLForced bool `json:"ssl_forced"`
|
|
WebsocketSupport bool `json:"websocket_support"`
|
|
RawJSON string `json:"raw_json"` // Original Caddy JSON for this route
|
|
Warnings []string `json:"warnings"` // Unsupported features
|
|
}
|
|
|
|
// ImportResult contains parsed hosts and detected conflicts.
|
|
type ImportResult struct {
|
|
Hosts []ParsedHost `json:"hosts"`
|
|
Conflicts []string `json:"conflicts"`
|
|
Errors []string `json:"errors"`
|
|
}
|
|
|
|
// Importer handles Caddyfile parsing and conversion to Charon models.
|
|
type Importer struct {
|
|
caddyBinaryPath string
|
|
executor Executor
|
|
}
|
|
|
|
// NewImporter creates a new Caddyfile importer.
|
|
func NewImporter(binaryPath string) *Importer <span class="cov10" title="30">{
|
|
if binaryPath == "" </span><span class="cov8" title="18">{
|
|
binaryPath = "caddy" // Default to PATH
|
|
}</span>
|
|
<span class="cov10" title="30">return &Importer{
|
|
caddyBinaryPath: binaryPath,
|
|
executor: &DefaultExecutor{},
|
|
}</span>
|
|
}
|
|
|
|
// forceSplitFallback used in tests to exercise the fallback branch
|
|
var forceSplitFallback bool
|
|
|
|
// ParseCaddyfile reads a Caddyfile and converts it to Caddy JSON.
|
|
func (i *Importer) ParseCaddyfile(caddyfilePath string) ([]byte, error) <span class="cov6" title="9">{
|
|
// Sanitize the incoming path to detect forbidden traversal sequences.
|
|
clean := filepath.Clean(caddyfilePath)
|
|
if clean == "" || clean == "." </span><span class="cov2" title="2">{
|
|
return nil, fmt.Errorf("invalid caddyfile path")
|
|
}</span>
|
|
<span class="cov6" title="7">if strings.Contains(clean, ".."+string(os.PathSeparator)) || strings.HasPrefix(clean, "..") </span><span class="cov1" title="1">{
|
|
return nil, fmt.Errorf("invalid caddyfile path")
|
|
}</span>
|
|
<span class="cov5" title="6">if _, err := os.Stat(clean); os.IsNotExist(err) </span><span class="cov1" title="1">{
|
|
return nil, fmt.Errorf("caddyfile not found: %s", clean)
|
|
}</span>
|
|
|
|
<span class="cov5" title="5">output, err := i.executor.Execute(i.caddyBinaryPath, "adapt", "--config", clean, "--adapter", "caddyfile")
|
|
if err != nil </span><span class="cov2" title="2">{
|
|
return nil, fmt.Errorf("caddy adapt failed: %w (output: %s)", err, string(output))
|
|
}</span>
|
|
|
|
<span class="cov3" title="3">return output, nil</span>
|
|
}
|
|
|
|
// extractHandlers recursively extracts handlers from a list, flattening subroutes.
|
|
func (i *Importer) extractHandlers(handles []*CaddyHandler) []*CaddyHandler <span class="cov9" title="27">{
|
|
var result []*CaddyHandler
|
|
|
|
for _, handler := range handles </span><span class="cov10" title="30">{
|
|
// If this is a subroute, extract handlers from its first route
|
|
if handler.Handler == "subroute" </span><span class="cov5" title="6">{
|
|
if routes, ok := handler.Routes.([]interface{}); ok && len(routes) > 0 </span><span class="cov5" title="5">{
|
|
if subroute, ok := routes[0].(map[string]interface{}); ok </span><span class="cov4" title="4">{
|
|
if subhandles, ok := subroute["handle"].([]interface{}); ok </span><span class="cov3" title="3">{
|
|
// Convert the subhandles to CaddyHandler objects
|
|
for _, sh := range subhandles </span><span class="cov5" title="5">{
|
|
if shMap, ok := sh.(map[string]interface{}); ok </span><span class="cov4" title="4">{
|
|
subHandler := &CaddyHandler{}
|
|
if handlerType, ok := shMap["handler"].(string); ok </span><span class="cov4" title="4">{
|
|
subHandler.Handler = handlerType
|
|
}</span>
|
|
<span class="cov4" title="4">if upstreams, ok := shMap["upstreams"]; ok </span><span class="cov2" title="2">{
|
|
subHandler.Upstreams = upstreams
|
|
}</span>
|
|
<span class="cov4" title="4">if headers, ok := shMap["headers"]; ok </span><span class="cov1" title="1">{
|
|
subHandler.Headers = headers
|
|
}</span>
|
|
<span class="cov4" title="4">result = append(result, subHandler)</span>
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else<span class="cov9" title="24"> {
|
|
// Regular handler, add it directly
|
|
result = append(result, handler)
|
|
}</span>
|
|
}
|
|
|
|
<span class="cov9" title="27">return result</span>
|
|
}
|
|
|
|
// ExtractHosts parses Caddy JSON and extracts proxy host information.
|
|
func (i *Importer) ExtractHosts(caddyJSON []byte) (*ImportResult, error) <span class="cov9" title="22">{
|
|
var config CaddyConfig
|
|
if err := json.Unmarshal(caddyJSON, &config); err != nil </span><span class="cov2" title="2">{
|
|
return nil, fmt.Errorf("parsing caddy json: %w", err)
|
|
}</span>
|
|
|
|
<span class="cov8" title="20">result := &ImportResult{
|
|
Hosts: []ParsedHost{},
|
|
Conflicts: []string{},
|
|
Errors: []string{},
|
|
}
|
|
|
|
if config.Apps == nil || config.Apps.HTTP == nil || config.Apps.HTTP.Servers == nil </span><span class="cov1" title="1">{
|
|
return result, nil // Empty config
|
|
}</span>
|
|
|
|
<span class="cov8" title="19">seenDomains := make(map[string]bool)
|
|
|
|
for serverName, server := range config.Apps.HTTP.Servers </span><span class="cov8" title="20">{
|
|
// Detect if this server uses SSL based on listen address or TLS policies
|
|
serverUsesSSL := server.TLSConnectionPolicies != nil
|
|
for _, listenAddr := range server.Listen </span><span class="cov7" title="14">{
|
|
// Check if listening on :443 or any HTTPS port indicator
|
|
if strings.Contains(listenAddr, ":443") || strings.HasSuffix(listenAddr, "443") </span><span class="cov2" title="2">{
|
|
serverUsesSSL = true
|
|
break</span>
|
|
}
|
|
}
|
|
|
|
<span class="cov8" title="20">for routeIdx, route := range server.Routes </span><span class="cov9" title="22">{
|
|
for _, match := range route.Match </span><span class="cov9" title="22">{
|
|
for _, hostMatcher := range match.Host </span><span class="cov9" title="23">{
|
|
domain := hostMatcher
|
|
|
|
// Check for duplicate domains (report domain names only)
|
|
if seenDomains[domain] </span><span class="cov2" title="2">{
|
|
result.Conflicts = append(result.Conflicts, domain)
|
|
continue</span>
|
|
}
|
|
<span class="cov9" title="21">seenDomains[domain] = true
|
|
|
|
// Extract reverse proxy handler
|
|
host := ParsedHost{
|
|
DomainNames: domain,
|
|
SSLForced: strings.HasPrefix(domain, "https") || serverUsesSSL,
|
|
}
|
|
|
|
// Find reverse_proxy handler (may be nested in subroute)
|
|
handlers := i.extractHandlers(route.Handle)
|
|
|
|
for _, handler := range handlers </span><span class="cov9" title="24">{
|
|
if handler.Handler == "reverse_proxy" </span><span class="cov8" title="20">{
|
|
upstreams, _ := handler.Upstreams.([]interface{})
|
|
if len(upstreams) > 0 </span><span class="cov8" title="18">{
|
|
if upstream, ok := upstreams[0].(map[string]interface{}); ok </span><span class="cov8" title="17">{
|
|
dial, _ := upstream["dial"].(string)
|
|
if dial != "" </span><span class="cov8" title="17">{
|
|
hostStr, portStr, err := net.SplitHostPort(dial)
|
|
if err == nil && !forceSplitFallback </span><span class="cov7" title="13">{
|
|
host.ForwardHost = hostStr
|
|
if _, err := fmt.Sscanf(portStr, "%d", &host.ForwardPort); err != nil </span><span class="cov3" title="3">{
|
|
host.ForwardPort = 80
|
|
}</span>
|
|
} else<span class="cov4" title="4"> {
|
|
// Fallback: assume dial is just the host or has some other format
|
|
// Try to handle simple "host:port" manually if net.SplitHostPort failed for some reason
|
|
// or assume it's just a host
|
|
parts := strings.Split(dial, ":")
|
|
if len(parts) == 2 </span><span class="cov2" title="2">{
|
|
host.ForwardHost = parts[0]
|
|
if _, err := fmt.Sscanf(parts[1], "%d", &host.ForwardPort); err != nil </span><span class="cov1" title="1">{
|
|
host.ForwardPort = 80
|
|
}</span>
|
|
} else<span class="cov2" title="2"> {
|
|
host.ForwardHost = dial
|
|
host.ForwardPort = 80
|
|
}</span>
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check for websocket support
|
|
<span class="cov8" title="20">if headers, ok := handler.Headers.(map[string]interface{}); ok </span><span class="cov2" title="2">{
|
|
if upgrade, ok := headers["Upgrade"].([]interface{}); ok </span><span class="cov2" title="2">{
|
|
for _, v := range upgrade </span><span class="cov2" title="2">{
|
|
if v == "websocket" </span><span class="cov2" title="2">{
|
|
host.WebsocketSupport = true
|
|
break</span>
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Default scheme
|
|
<span class="cov8" title="20">host.ForwardScheme = "http"
|
|
if host.SSLForced </span><span class="cov4" title="4">{
|
|
host.ForwardScheme = "https"
|
|
}</span>
|
|
}
|
|
|
|
// Detect unsupported features
|
|
<span class="cov9" title="24">if handler.Handler == "rewrite" </span><span class="cov2" title="2">{
|
|
host.Warnings = append(host.Warnings, "Rewrite rules not supported - manual configuration required")
|
|
}</span>
|
|
<span class="cov9" title="24">if handler.Handler == "file_server" </span><span class="cov2" title="2">{
|
|
host.Warnings = append(host.Warnings, "File server directives not supported")
|
|
}</span>
|
|
}
|
|
|
|
// Store raw JSON for this route
|
|
<span class="cov9" title="21">routeJSON, _ := json.Marshal(map[string]interface{}{
|
|
"server": serverName,
|
|
"route": routeIdx,
|
|
"data": route,
|
|
})
|
|
host.RawJSON = string(routeJSON)
|
|
|
|
result.Hosts = append(result.Hosts, host)</span>
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
<span class="cov8" title="19">return result, nil</span>
|
|
}
|
|
|
|
// ImportFile performs complete import: parse Caddyfile and extract hosts.
|
|
func (i *Importer) ImportFile(caddyfilePath string) (*ImportResult, error) <span class="cov3" title="3">{
|
|
caddyJSON, err := i.ParseCaddyfile(caddyfilePath)
|
|
if err != nil </span><span class="cov1" title="1">{
|
|
return nil, err
|
|
}</span>
|
|
|
|
<span class="cov2" title="2">return i.ExtractHosts(caddyJSON)</span>
|
|
}
|
|
|
|
// ConvertToProxyHosts converts parsed hosts to ProxyHost models.
|
|
func ConvertToProxyHosts(parsedHosts []ParsedHost) []models.ProxyHost <span class="cov1" title="1">{
|
|
hosts := make([]models.ProxyHost, 0, len(parsedHosts))
|
|
|
|
for _, parsed := range parsedHosts </span><span class="cov2" title="2">{
|
|
if parsed.ForwardHost == "" || parsed.ForwardPort == 0 </span><span class="cov1" title="1">{
|
|
continue</span> // Skip invalid entries
|
|
}
|
|
|
|
<span class="cov1" title="1">hosts = append(hosts, models.ProxyHost{
|
|
Name: parsed.DomainNames, // Can be customized by user during review
|
|
DomainNames: parsed.DomainNames,
|
|
ForwardScheme: parsed.ForwardScheme,
|
|
ForwardHost: parsed.ForwardHost,
|
|
ForwardPort: parsed.ForwardPort,
|
|
SSLForced: parsed.SSLForced,
|
|
WebsocketSupport: parsed.WebsocketSupport,
|
|
})</span>
|
|
}
|
|
|
|
<span class="cov1" title="1">return hosts</span>
|
|
}
|
|
|
|
// ValidateCaddyBinary checks if the Caddy binary is available.
|
|
func (i *Importer) ValidateCaddyBinary() error <span class="cov2" title="2">{
|
|
_, err := i.executor.Execute(i.caddyBinaryPath, "version")
|
|
if err != nil </span><span class="cov1" title="1">{
|
|
return errors.New("caddy binary not found or not executable")
|
|
}</span>
|
|
<span class="cov1" title="1">return nil</span>
|
|
}
|
|
|
|
// BackupCaddyfile creates a timestamped backup of the original Caddyfile.
|
|
func BackupCaddyfile(originalPath, backupDir string) (string, error) <span class="cov6" title="9">{
|
|
if err := os.MkdirAll(backupDir, 0755); err != nil </span><span class="cov1" title="1">{
|
|
return "", fmt.Errorf("creating backup directory: %w", err)
|
|
}</span>
|
|
|
|
<span class="cov6" title="8">timestamp := fmt.Sprintf("%d", os.Getpid()) // Simple timestamp placeholder
|
|
// Ensure the backup path is contained within backupDir to prevent path traversal
|
|
backupFile := fmt.Sprintf("Caddyfile.%s.backup", timestamp)
|
|
// Create a safe join with backupDir
|
|
backupPath := filepath.Join(backupDir, backupFile)
|
|
|
|
// Validate the original path: avoid traversal elements pointing outside backupDir
|
|
clean := filepath.Clean(originalPath)
|
|
if clean == "" || clean == "." </span><span class="cov1" title="1">{
|
|
return "", fmt.Errorf("invalid original path")
|
|
}</span>
|
|
<span class="cov6" title="7">if strings.Contains(clean, ".."+string(os.PathSeparator)) || strings.HasPrefix(clean, "..") </span><span class="cov1" title="1">{
|
|
return "", fmt.Errorf("invalid original path")
|
|
}</span>
|
|
<span class="cov5" title="6">input, err := os.ReadFile(clean)
|
|
if err != nil </span><span class="cov2" title="2">{
|
|
return "", fmt.Errorf("reading original file: %w", err)
|
|
}</span>
|
|
|
|
<span class="cov4" title="4">if err := os.WriteFile(backupPath, input, 0644); err != nil </span><span class="cov1" title="1">{
|
|
return "", fmt.Errorf("writing backup: %w", err)
|
|
}</span>
|
|
|
|
<span class="cov3" title="3">return backupPath, nil</span>
|
|
}
|
|
</pre>
|
|
|
|
<pre class="file" id="file3" style="display: none">package caddy
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"fmt"
|
|
"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
|
|
jsonMarshalFunc = json.MarshalIndent
|
|
// Test hooks for bandaging validation/generation flows
|
|
generateConfigFunc = GenerateConfig
|
|
validateConfigFunc = Validate
|
|
)
|
|
|
|
// Manager orchestrates Caddy configuration lifecycle: generate, validate, apply, rollback.
|
|
type Manager struct {
|
|
client *Client
|
|
db *gorm.DB
|
|
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, securityCfg config.SecurityConfig) *Manager <span class="cov8" title="46">{
|
|
return &Manager{
|
|
client: client,
|
|
db: db,
|
|
configDir: configDir,
|
|
frontendDir: frontendDir,
|
|
acmeStaging: acmeStaging,
|
|
securityCfg: securityCfg,
|
|
}
|
|
}</span>
|
|
|
|
// ApplyConfig generates configuration from database, validates it, applies to Caddy with rollback on failure.
|
|
func (m *Manager) ApplyConfig(ctx context.Context) error <span class="cov7" title="27">{
|
|
// 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 </span><span class="cov1" title="1">{
|
|
return fmt.Errorf("fetch proxy hosts: %w", err)
|
|
}</span>
|
|
|
|
// Fetch ACME email setting
|
|
<span class="cov7" title="26">var acmeEmailSetting models.Setting
|
|
var acmeEmail string
|
|
if err := m.db.Where("key = ?", "caddy.acme_email").First(&acmeEmailSetting).Error; err == nil </span><span class="cov1" title="1">{
|
|
acmeEmail = acmeEmailSetting.Value
|
|
}</span>
|
|
|
|
// Fetch SSL Provider setting
|
|
<span class="cov7" title="26">var sslProviderSetting models.Setting
|
|
var sslProvider string
|
|
if err := m.db.Where("key = ?", "caddy.ssl_provider").First(&sslProviderSetting).Error; err == nil </span><span class="cov1" title="1">{
|
|
sslProvider = sslProviderSetting.Value
|
|
}</span>
|
|
|
|
// Compute effective security flags (re-read runtime overrides)
|
|
<span class="cov7" title="26">_, 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 </span><span class="cov5" title="8">{
|
|
if secCfg.Enabled && strings.TrimSpace(secCfg.AdminWhitelist) == "" </span><span class="cov1" title="1">{
|
|
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")
|
|
}</span>
|
|
}
|
|
|
|
// Load ruleset metadata (WAF/Coraza) for config generation
|
|
<span class="cov7" title="25">var rulesets []models.SecurityRuleSet
|
|
if err := m.db.Find(&rulesets).Error; err != nil </span><span class="cov6" title="19">{
|
|
// non-fatal: just log the error and continue with empty rules
|
|
logger.Log().WithError(err).Warn("failed to load rulesets for generate config")
|
|
}</span>
|
|
|
|
// Load recent security decisions so they can be injected into the generated config
|
|
<span class="cov7" title="25">var decisions []models.SecurityDecision
|
|
if err := m.db.Order("created_at desc").Find(&decisions).Error; err != nil </span><span class="cov7" title="25">{
|
|
logger.Log().WithError(err).Warn("failed to load security decisions for generate config")
|
|
}</span>
|
|
|
|
// Generate Caddy config
|
|
// Read admin whitelist for config generation so handlers can exclude admin IPs
|
|
<span class="cov7" title="25">var adminWhitelist string
|
|
if secCfg.AdminWhitelist != "" </span><span class="cov4" title="7">{
|
|
adminWhitelist = secCfg.AdminWhitelist
|
|
}</span>
|
|
// Ensure ruleset files exist on disk and build a map of their paths for GenerateConfig
|
|
<span class="cov7" title="25">rulesetPaths := make(map[string]string)
|
|
if len(rulesets) > 0 </span><span class="cov4" title="6">{
|
|
corazaDir := filepath.Join(m.configDir, "coraza", "rulesets")
|
|
if err := os.MkdirAll(corazaDir, 0755); err != nil </span><span class="cov1" title="1">{
|
|
logger.Log().WithError(err).Warn("failed to create coraza rulesets dir")
|
|
}</span>
|
|
<span class="cov4" title="6">for _, rs := range rulesets </span><span class="cov4" title="6">{
|
|
// 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") </span><span class="cov4" title="5">{
|
|
content = "SecRuleEngine On\nSecRequestBodyAccess On\n\n" + content
|
|
}</span>
|
|
// Write ruleset file with world-readable permissions so the Caddy
|
|
// process (which may run as an unprivileged user) can read it.
|
|
<span class="cov4" title="6">if err := writeFileFunc(filePath, []byte(content), 0644); err != nil </span><span class="cov2" title="2">{
|
|
logger.Log().WithError(err).WithField("ruleset", rs.Name).Warn("failed to write coraza ruleset file")
|
|
}</span> else<span class="cov3" title="4"> {
|
|
// 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")
|
|
}</span>
|
|
}
|
|
}
|
|
|
|
<span class="cov7" title="25">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 </span><span class="cov1" title="1">{
|
|
return fmt.Errorf("generate config: %w", err)
|
|
}</span>
|
|
|
|
// Log generated config size and a compact JSON snippet for debugging when in debug mode
|
|
<span class="cov7" title="24">if cfgJSON, jerr := json.Marshal(config); jerr == nil </span><span class="cov7" title="24">{
|
|
logger.Log().WithField("config_json_len", len(cfgJSON)).Debug("generated Caddy config JSON")
|
|
}</span> else<span class="cov0" title="0"> {
|
|
logger.Log().WithError(jerr).Warn("failed to marshal generated config for debug logging")
|
|
}</span>
|
|
|
|
// Validate before applying
|
|
<span class="cov7" title="24">if err := validateConfigFunc(config); err != nil </span><span class="cov1" title="1">{
|
|
return fmt.Errorf("validation failed: %w", err)
|
|
}</span>
|
|
|
|
// Save snapshot for rollback
|
|
<span class="cov7" title="23">snapshotPath, err := m.saveSnapshot(config)
|
|
if err != nil </span><span class="cov2" title="2">{
|
|
return fmt.Errorf("save snapshot: %w", err)
|
|
}</span>
|
|
|
|
// Calculate config hash for audit trail
|
|
<span class="cov7" title="21">configJSON, _ := json.Marshal(config)
|
|
configHash := fmt.Sprintf("%x", sha256.Sum256(configJSON))
|
|
|
|
// Apply to Caddy
|
|
if err := m.client.Load(ctx, config); err != nil </span><span class="cov4" title="5">{
|
|
// Remove the failed snapshot so rollback uses the previous one
|
|
_ = removeFileFunc(snapshotPath)
|
|
|
|
// Rollback on failure
|
|
if rollbackErr := m.rollback(ctx); rollbackErr != nil </span><span class="cov3" title="4">{
|
|
// If rollback fails, we still want to record the failure
|
|
m.recordConfigChange(configHash, false, err.Error())
|
|
return fmt.Errorf("apply failed: %w, rollback also failed: %v", err, rollbackErr)
|
|
}</span>
|
|
|
|
// Record failed attempt
|
|
<span class="cov1" title="1">m.recordConfigChange(configHash, false, err.Error())
|
|
return fmt.Errorf("apply failed (rolled back): %w", err)</span>
|
|
}
|
|
|
|
// Record successful application
|
|
<span class="cov6" title="16">m.recordConfigChange(configHash, true, "")
|
|
|
|
// Cleanup old snapshots (keep last 10)
|
|
if err := m.rotateSnapshots(10); err != nil </span><span class="cov1" title="1">{
|
|
// Non-fatal - log but don't fail
|
|
logger.Log().WithError(err).Warn("warning: snapshot rotation failed")
|
|
}</span>
|
|
|
|
<span class="cov6" title="16">return nil</span>
|
|
}
|
|
|
|
// saveSnapshot stores the config to disk with timestamp.
|
|
func (m *Manager) saveSnapshot(config *Config) (string, error) <span class="cov7" title="26">{
|
|
timestamp := time.Now().Unix()
|
|
filename := fmt.Sprintf("config-%d.json", timestamp)
|
|
path := filepath.Join(m.configDir, filename)
|
|
|
|
configJSON, err := jsonMarshalFunc(config, "", " ")
|
|
if err != nil </span><span class="cov1" title="1">{
|
|
return "", fmt.Errorf("marshal config: %w", err)
|
|
}</span>
|
|
|
|
<span class="cov7" title="25">if err := writeFileFunc(path, configJSON, 0644); err != nil </span><span class="cov3" title="3">{
|
|
return "", fmt.Errorf("write snapshot: %w", err)
|
|
}</span>
|
|
|
|
<span class="cov7" title="22">return path, nil</span>
|
|
}
|
|
|
|
// rollback loads the most recent snapshot from disk.
|
|
func (m *Manager) rollback(ctx context.Context) error <span class="cov5" title="9">{
|
|
snapshots, err := m.listSnapshots()
|
|
if err != nil || len(snapshots) == 0 </span><span class="cov3" title="4">{
|
|
return fmt.Errorf("no snapshots available for rollback")
|
|
}</span>
|
|
|
|
// Load most recent snapshot
|
|
<span class="cov4" title="5">latestSnapshot := snapshots[len(snapshots)-1]
|
|
configJSON, err := readFileFunc(latestSnapshot)
|
|
if err != nil </span><span class="cov1" title="1">{
|
|
return fmt.Errorf("read snapshot: %w", err)
|
|
}</span>
|
|
|
|
<span class="cov3" title="4">var config Config
|
|
if err := json.Unmarshal(configJSON, &config); err != nil </span><span class="cov1" title="1">{
|
|
return fmt.Errorf("unmarshal snapshot: %w", err)
|
|
}</span>
|
|
|
|
// Apply the snapshot
|
|
<span class="cov3" title="3">if err := m.client.Load(ctx, &config); err != nil </span><span class="cov2" title="2">{
|
|
return fmt.Errorf("load snapshot: %w", err)
|
|
}</span>
|
|
|
|
<span class="cov1" title="1">return nil</span>
|
|
}
|
|
|
|
// listSnapshots returns all snapshot file paths sorted by modification time.
|
|
func (m *Manager) listSnapshots() ([]string, error) <span class="cov7" title="32">{
|
|
entries, err := readDirFunc(m.configDir)
|
|
if err != nil </span><span class="cov3" title="3">{
|
|
return nil, fmt.Errorf("read config dir: %w", err)
|
|
}</span>
|
|
|
|
<span class="cov7" title="29">var snapshots []string
|
|
for _, entry := range entries </span><span class="cov9" title="62">{
|
|
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" </span><span class="cov4" title="7">{
|
|
continue</span>
|
|
}
|
|
<span class="cov8" title="55">snapshots = append(snapshots, filepath.Join(m.configDir, entry.Name()))</span>
|
|
}
|
|
|
|
// Sort by modification time
|
|
<span class="cov7" title="29">sort.Slice(snapshots, func(i, j int) bool </span><span class="cov10" title="96">{
|
|
infoI, _ := statFunc(snapshots[i])
|
|
infoJ, _ := statFunc(snapshots[j])
|
|
return infoI.ModTime().Before(infoJ.ModTime())
|
|
}</span>)
|
|
|
|
<span class="cov7" title="29">return snapshots, nil</span>
|
|
}
|
|
|
|
// rotateSnapshots keeps only the N most recent snapshots.
|
|
func (m *Manager) rotateSnapshots(keep int) error <span class="cov6" title="20">{
|
|
snapshots, err := m.listSnapshots()
|
|
if err != nil </span><span class="cov2" title="2">{
|
|
return err
|
|
}</span>
|
|
|
|
<span class="cov6" title="18">if len(snapshots) <= keep </span><span class="cov6" title="14">{
|
|
return nil
|
|
}</span>
|
|
|
|
// Delete oldest snapshots
|
|
<span class="cov3" title="4">toDelete := snapshots[:len(snapshots)-keep]
|
|
for _, path := range toDelete </span><span class="cov5" title="11">{
|
|
if err := removeFileFunc(path); err != nil </span><span class="cov1" title="1">{
|
|
return fmt.Errorf("delete snapshot %s: %w", path, err)
|
|
}</span>
|
|
}
|
|
|
|
<span class="cov3" title="3">return nil</span>
|
|
}
|
|
|
|
// recordConfigChange stores an audit record in the database.
|
|
func (m *Manager) recordConfigChange(configHash string, success bool, errorMsg string) <span class="cov7" title="21">{
|
|
record := models.CaddyConfig{
|
|
ConfigHash: configHash,
|
|
AppliedAt: time.Now(),
|
|
Success: success,
|
|
ErrorMsg: errorMsg,
|
|
}
|
|
|
|
// Best effort - don't fail if audit logging fails
|
|
m.db.Create(&record)
|
|
}</span>
|
|
|
|
// Ping checks if Caddy is reachable.
|
|
func (m *Manager) Ping(ctx context.Context) error <span class="cov1" title="1">{
|
|
return m.client.Ping(ctx)
|
|
}</span>
|
|
|
|
// GetCurrentConfig retrieves the running config from Caddy.
|
|
func (m *Manager) GetCurrentConfig(ctx context.Context) (*Config, error) <span class="cov1" title="1">{
|
|
return m.client.GetConfig(ctx)
|
|
}</span>
|
|
|
|
// 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) <span class="cov8" title="35">{
|
|
// 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 </span><span class="cov7" title="32">{
|
|
var s models.Setting
|
|
// runtime override for cerberus enabled
|
|
if err := m.db.Where("key = ?", "security.cerberus.enabled").First(&s).Error; err == nil </span><span class="cov1" title="1">{
|
|
cerbEnabled = strings.EqualFold(s.Value, "true")
|
|
}</span>
|
|
|
|
// runtime override for ACL enabled
|
|
<span class="cov7" title="32">if err := m.db.Where("key = ?", "security.acl.enabled").First(&s).Error; err == nil </span><span class="cov3" title="4">{
|
|
if strings.EqualFold(s.Value, "true") </span><span class="cov3" title="3">{
|
|
aclEnabled = true
|
|
}</span> else<span class="cov1" title="1"> if strings.EqualFold(s.Value, "false") </span><span class="cov1" title="1">{
|
|
aclEnabled = false
|
|
}</span>
|
|
}
|
|
|
|
// runtime override for crowdsec mode (mode value determines whether it's local/remote/enabled)
|
|
<span class="cov7" title="32">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 != "" </span><span class="cov3" title="4">{
|
|
// Only 'local' runtime mode enables CrowdSec; all other values are disabled
|
|
if cm.Value == "local" </span><span class="cov2" title="2">{
|
|
crowdsecEnabled = true
|
|
}</span> else<span class="cov2" title="2"> {
|
|
crowdsecEnabled = false
|
|
}</span>
|
|
}
|
|
}
|
|
|
|
// ACL, WAF, RateLimit and CrowdSec should only be considered enabled if Cerberus is enabled.
|
|
<span class="cov8" title="35">if !cerbEnabled </span><span class="cov6" title="19">{
|
|
aclEnabled = false
|
|
wafEnabled = false
|
|
rateLimitEnabled = false
|
|
crowdsecEnabled = false
|
|
}</span>
|
|
|
|
<span class="cov8" title="35">return cerbEnabled, aclEnabled, wafEnabled, rateLimitEnabled, crowdsecEnabled</span>
|
|
}
|
|
</pre>
|
|
|
|
<pre class="file" id="file4" style="display: none">package caddy
|
|
|
|
// Config represents Caddy's top-level JSON configuration structure.
|
|
// Reference: https://caddyserver.com/docs/json/
|
|
type Config struct {
|
|
Apps Apps `json:"apps"`
|
|
Logging *LoggingConfig `json:"logging,omitempty"`
|
|
Storage Storage `json:"storage,omitempty"`
|
|
}
|
|
|
|
// LoggingConfig configures Caddy's logging facility.
|
|
type LoggingConfig struct {
|
|
Logs map[string]*LogConfig `json:"logs,omitempty"`
|
|
Sinks *SinkConfig `json:"sinks,omitempty"`
|
|
}
|
|
|
|
// LogConfig configures a specific logger.
|
|
type LogConfig struct {
|
|
Writer *WriterConfig `json:"writer,omitempty"`
|
|
Encoder *EncoderConfig `json:"encoder,omitempty"`
|
|
Level string `json:"level,omitempty"`
|
|
Include []string `json:"include,omitempty"`
|
|
Exclude []string `json:"exclude,omitempty"`
|
|
}
|
|
|
|
// WriterConfig configures the log writer (output).
|
|
type WriterConfig struct {
|
|
Output string `json:"output"`
|
|
Filename string `json:"filename,omitempty"`
|
|
Roll bool `json:"roll,omitempty"`
|
|
RollSize int `json:"roll_size_mb,omitempty"`
|
|
RollKeep int `json:"roll_keep,omitempty"`
|
|
RollKeepDays int `json:"roll_keep_days,omitempty"`
|
|
}
|
|
|
|
// EncoderConfig configures the log format.
|
|
type EncoderConfig struct {
|
|
Format string `json:"format"` // "json", "console", etc.
|
|
}
|
|
|
|
// SinkConfig configures log sinks (e.g. stderr).
|
|
type SinkConfig struct {
|
|
Writer *WriterConfig `json:"writer,omitempty"`
|
|
}
|
|
|
|
// Storage configures the storage module.
|
|
type Storage struct {
|
|
System string `json:"module"`
|
|
Root string `json:"root,omitempty"`
|
|
}
|
|
|
|
// Apps contains all Caddy app modules.
|
|
type Apps struct {
|
|
HTTP *HTTPApp `json:"http,omitempty"`
|
|
TLS *TLSApp `json:"tls,omitempty"`
|
|
}
|
|
|
|
// HTTPApp configures the HTTP app.
|
|
type HTTPApp struct {
|
|
Servers map[string]*Server `json:"servers"`
|
|
}
|
|
|
|
// Server represents an HTTP server instance.
|
|
type Server struct {
|
|
Listen []string `json:"listen"`
|
|
Routes []*Route `json:"routes"`
|
|
AutoHTTPS *AutoHTTPSConfig `json:"automatic_https,omitempty"`
|
|
Logs *ServerLogs `json:"logs,omitempty"`
|
|
}
|
|
|
|
// AutoHTTPSConfig controls automatic HTTPS behavior.
|
|
type AutoHTTPSConfig struct {
|
|
Disable bool `json:"disable,omitempty"`
|
|
DisableRedir bool `json:"disable_redirects,omitempty"`
|
|
Skip []string `json:"skip,omitempty"`
|
|
}
|
|
|
|
// ServerLogs configures access logging.
|
|
type ServerLogs struct {
|
|
DefaultLoggerName string `json:"default_logger_name,omitempty"`
|
|
}
|
|
|
|
// Route represents an HTTP route (matcher + handlers).
|
|
type Route struct {
|
|
Match []Match `json:"match,omitempty"`
|
|
Handle []Handler `json:"handle"`
|
|
Terminal bool `json:"terminal,omitempty"`
|
|
}
|
|
|
|
// Match represents a request matcher.
|
|
type Match struct {
|
|
Host []string `json:"host,omitempty"`
|
|
Path []string `json:"path,omitempty"`
|
|
}
|
|
|
|
// Handler is the interface for all handler types.
|
|
// Actual types will implement handler-specific fields.
|
|
type Handler map[string]interface{}
|
|
|
|
// ReverseProxyHandler creates a reverse_proxy handler.
|
|
// application: "none", "plex", "jellyfin", "emby", "homeassistant", "nextcloud", "vaultwarden"
|
|
func ReverseProxyHandler(dial string, enableWS bool, application string) Handler <span class="cov10" title="73">{
|
|
h := Handler{
|
|
"handler": "reverse_proxy",
|
|
"flush_interval": -1, // Disable buffering for better streaming performance (Plex, etc.)
|
|
"upstreams": []map[string]interface{}{
|
|
{"dial": dial},
|
|
},
|
|
}
|
|
|
|
// Build headers configuration
|
|
headers := make(map[string]interface{})
|
|
requestHeaders := make(map[string]interface{})
|
|
setHeaders := make(map[string][]string)
|
|
|
|
// WebSocket support
|
|
if enableWS </span><span class="cov3" title="3">{
|
|
setHeaders["Upgrade"] = []string{"{http.request.header.Upgrade}"}
|
|
setHeaders["Connection"] = []string{"{http.request.header.Connection}"}
|
|
}</span>
|
|
|
|
// Application-specific headers for proper client IP forwarding
|
|
// These are critical for media servers behind tunnels/CGNAT
|
|
<span class="cov10" title="73">switch application </span>{
|
|
case "plex":<span class="cov2" title="2">
|
|
// Pass-through common Plex headers for improved compatibility when proxying
|
|
setHeaders["X-Plex-Client-Identifier"] = []string{"{http.request.header.X-Plex-Client-Identifier}"}
|
|
setHeaders["X-Plex-Device"] = []string{"{http.request.header.X-Plex-Device}"}
|
|
setHeaders["X-Plex-Device-Name"] = []string{"{http.request.header.X-Plex-Device-Name}"}
|
|
setHeaders["X-Plex-Platform"] = []string{"{http.request.header.X-Plex-Platform}"}
|
|
setHeaders["X-Plex-Platform-Version"] = []string{"{http.request.header.X-Plex-Platform-Version}"}
|
|
setHeaders["X-Plex-Product"] = []string{"{http.request.header.X-Plex-Product}"}
|
|
setHeaders["X-Plex-Token"] = []string{"{http.request.header.X-Plex-Token}"}
|
|
setHeaders["X-Plex-Version"] = []string{"{http.request.header.X-Plex-Version}"}
|
|
// Also set X-Real-IP for accurate client IP reporting
|
|
setHeaders["X-Real-IP"] = []string{"{http.request.remote.host}"}
|
|
setHeaders["X-Forwarded-Host"] = []string{"{http.request.host}"}</span>
|
|
case "jellyfin", "emby", "homeassistant", "nextcloud", "vaultwarden":<span class="cov1" title="1">
|
|
// X-Real-IP is required by most apps to identify the real client
|
|
// Caddy already sets X-Forwarded-For and X-Forwarded-Proto by default
|
|
setHeaders["X-Real-IP"] = []string{"{http.request.remote.host}"}
|
|
// Some apps also check these headers
|
|
setHeaders["X-Forwarded-Host"] = []string{"{http.request.host}"}</span>
|
|
}
|
|
|
|
// Only add headers config if we have headers to set
|
|
<span class="cov10" title="73">if len(setHeaders) > 0 </span><span class="cov3" title="4">{
|
|
requestHeaders["set"] = setHeaders
|
|
headers["request"] = requestHeaders
|
|
h["headers"] = headers
|
|
}</span>
|
|
|
|
<span class="cov10" title="73">return h</span>
|
|
}
|
|
|
|
// HeaderHandler creates a handler that sets HTTP response headers.
|
|
func HeaderHandler(headers map[string][]string) Handler <span class="cov4" title="5">{
|
|
return Handler{
|
|
"handler": "headers",
|
|
"response": map[string]interface{}{
|
|
"set": headers,
|
|
},
|
|
}
|
|
}</span>
|
|
|
|
// BlockExploitsHandler creates a handler that blocks common exploits.
|
|
// This uses Caddy's request matchers to block malicious patterns.
|
|
func BlockExploitsHandler() Handler <span class="cov7" title="26">{
|
|
return Handler{
|
|
"handler": "vars",
|
|
// Placeholder for future exploit blocking logic
|
|
// Can be extended with specific matchers for SQL injection, XSS, etc.
|
|
}
|
|
}</span>
|
|
|
|
// RewriteHandler creates a rewrite handler.
|
|
func RewriteHandler(uri string) Handler <span class="cov6" title="11">{
|
|
return Handler{
|
|
"handler": "rewrite",
|
|
"uri": uri,
|
|
}
|
|
}</span>
|
|
|
|
// FileServerHandler creates a file_server handler.
|
|
func FileServerHandler(root string) Handler <span class="cov6" title="11">{
|
|
return Handler{
|
|
"handler": "file_server",
|
|
"root": root,
|
|
}
|
|
}</span>
|
|
|
|
// TLSApp configures the TLS app for certificate management.
|
|
type TLSApp struct {
|
|
Automation *AutomationConfig `json:"automation,omitempty"`
|
|
Certificates *CertificatesConfig `json:"certificates,omitempty"`
|
|
}
|
|
|
|
// CertificatesConfig configures manual certificate loading.
|
|
type CertificatesConfig struct {
|
|
LoadPEM []LoadPEMConfig `json:"load_pem,omitempty"`
|
|
}
|
|
|
|
// LoadPEMConfig defines a PEM-loaded certificate.
|
|
type LoadPEMConfig struct {
|
|
Certificate string `json:"certificate"`
|
|
Key string `json:"key"`
|
|
Tags []string `json:"tags,omitempty"`
|
|
}
|
|
|
|
// AutomationConfig controls certificate automation.
|
|
type AutomationConfig struct {
|
|
Policies []*AutomationPolicy `json:"policies,omitempty"`
|
|
}
|
|
|
|
// AutomationPolicy defines certificate management for specific domains.
|
|
type AutomationPolicy struct {
|
|
Subjects []string `json:"subjects,omitempty"`
|
|
IssuersRaw []interface{} `json:"issuers,omitempty"`
|
|
}
|
|
</pre>
|
|
|
|
<pre class="file" id="file5" style="display: none">package caddy
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// Validate performs pre-flight validation on a Caddy config before applying it.
|
|
func Validate(cfg *Config) error <span class="cov8" title="32">{
|
|
if cfg == nil </span><span class="cov1" title="1">{
|
|
return fmt.Errorf("config cannot be nil")
|
|
}</span>
|
|
|
|
<span class="cov8" title="31">if cfg.Apps.HTTP == nil </span><span class="cov1" title="1">{
|
|
return nil // Empty config is valid
|
|
}</span>
|
|
|
|
// Track seen hosts to detect duplicates
|
|
<span class="cov8" title="30">seenHosts := make(map[string]bool)
|
|
|
|
for serverName, server := range cfg.Apps.HTTP.Servers </span><span class="cov8" title="26">{
|
|
if len(server.Listen) == 0 </span><span class="cov1" title="1">{
|
|
return fmt.Errorf("server %s has no listen addresses", serverName)
|
|
}</span>
|
|
|
|
// Validate listen addresses
|
|
<span class="cov8" title="25">for _, addr := range server.Listen </span><span class="cov9" title="45">{
|
|
if err := validateListenAddr(addr); err != nil </span><span class="cov1" title="1">{
|
|
return fmt.Errorf("invalid listen address %s in server %s: %w", addr, serverName, err)
|
|
}</span>
|
|
}
|
|
|
|
// Validate routes
|
|
<span class="cov8" title="24">for i, route := range server.Routes </span><span class="cov8" title="26">{
|
|
if err := validateRoute(route, seenHosts); err != nil </span><span class="cov3" title="3">{
|
|
return fmt.Errorf("invalid route %d in server %s: %w", i, serverName, err)
|
|
}</span>
|
|
}
|
|
}
|
|
|
|
// Validate JSON marshalling works
|
|
<span class="cov8" title="25">if _, err := jsonMarshalValidate(cfg); err != nil </span><span class="cov1" title="1">{
|
|
return fmt.Errorf("config cannot be marshalled to JSON: %w", err)
|
|
}</span>
|
|
|
|
<span class="cov8" title="24">return nil</span>
|
|
}
|
|
|
|
// allow tests to override JSON marshalling to simulate errors
|
|
var jsonMarshalValidate = json.Marshal
|
|
|
|
func validateListenAddr(addr string) error <span class="cov9" title="55">{
|
|
// Strip network type prefix if present (tcp/, udp/)
|
|
if idx := strings.Index(addr, "/"); idx != -1 </span><span class="cov2" title="2">{
|
|
addr = addr[idx+1:]
|
|
}</span>
|
|
|
|
// Parse host:port
|
|
<span class="cov9" title="55">host, portStr, err := net.SplitHostPort(addr)
|
|
if err != nil </span><span class="cov1" title="1">{
|
|
return fmt.Errorf("invalid address format: %w", err)
|
|
}</span>
|
|
|
|
// Validate port
|
|
<span class="cov9" title="54">port, err := strconv.Atoi(portStr)
|
|
if err != nil </span><span class="cov1" title="1">{
|
|
return fmt.Errorf("invalid port: %w", err)
|
|
}</span>
|
|
<span class="cov9" title="53">if port < 1 || port > 65535 </span><span class="cov3" title="3">{
|
|
return fmt.Errorf("port %d out of range (1-65535)", port)
|
|
}</span>
|
|
|
|
// Validate host (allow empty for wildcard binding)
|
|
<span class="cov9" title="50">if host != "" && net.ParseIP(host) == nil </span><span class="cov2" title="2">{
|
|
return fmt.Errorf("invalid IP address: %s", host)
|
|
}</span>
|
|
|
|
<span class="cov9" title="48">return nil</span>
|
|
}
|
|
|
|
func validateRoute(route *Route, seenHosts map[string]bool) error <span class="cov8" title="26">{
|
|
if len(route.Handle) == 0 </span><span class="cov1" title="1">{
|
|
return fmt.Errorf("route has no handlers")
|
|
}</span>
|
|
|
|
// Check for duplicate host matchers
|
|
<span class="cov8" title="25">for _, match := range route.Match </span><span class="cov8" title="25">{
|
|
for _, host := range match.Host </span><span class="cov8" title="25">{
|
|
if seenHosts[host] </span><span class="cov1" title="1">{
|
|
return fmt.Errorf("duplicate host matcher: %s", host)
|
|
}</span>
|
|
<span class="cov8" title="24">seenHosts[host] = true</span>
|
|
}
|
|
}
|
|
|
|
// Validate handlers
|
|
<span class="cov8" title="24">for i, handler := range route.Handle </span><span class="cov9" title="53">{
|
|
if err := validateHandler(handler); err != nil </span><span class="cov1" title="1">{
|
|
return fmt.Errorf("invalid handler %d: %w", i, err)
|
|
}</span>
|
|
}
|
|
|
|
<span class="cov7" title="23">return nil</span>
|
|
}
|
|
|
|
func validateHandler(handler Handler) error <span class="cov10" title="57">{
|
|
handlerType, ok := handler["handler"].(string)
|
|
if !ok </span><span class="cov2" title="2">{
|
|
return fmt.Errorf("handler missing 'handler' field")
|
|
}</span>
|
|
|
|
<span class="cov9" title="55">switch handlerType </span>{
|
|
case "reverse_proxy":<span class="cov7" title="22">
|
|
return validateReverseProxy(handler)</span>
|
|
case "file_server", "static_response":<span class="cov3" title="3">
|
|
return nil</span> // Accept other common handlers
|
|
default:<span class="cov8" title="30">
|
|
// Unknown handlers are allowed (Caddy is extensible)
|
|
return nil</span>
|
|
}
|
|
}
|
|
|
|
func validateReverseProxy(handler Handler) error <span class="cov8" title="27">{
|
|
upstreams, ok := handler["upstreams"].([]map[string]interface{})
|
|
if !ok </span><span class="cov1" title="1">{
|
|
return fmt.Errorf("reverse_proxy missing upstreams")
|
|
}</span>
|
|
|
|
<span class="cov8" title="26">if len(upstreams) == 0 </span><span class="cov1" title="1">{
|
|
return fmt.Errorf("reverse_proxy has no upstreams")
|
|
}</span>
|
|
|
|
<span class="cov8" title="25">for i, upstream := range upstreams </span><span class="cov8" title="25">{
|
|
dial, ok := upstream["dial"].(string)
|
|
if !ok || dial == "" </span><span class="cov1" title="1">{
|
|
return fmt.Errorf("upstream %d missing dial address", i)
|
|
}</span>
|
|
|
|
// Validate dial address format (host:port)
|
|
<span class="cov8" title="24">if _, _, err := net.SplitHostPort(dial); err != nil </span><span class="cov1" title="1">{
|
|
return fmt.Errorf("upstream %d has invalid dial address %s: %w", i, dial, err)
|
|
}</span>
|
|
}
|
|
|
|
<span class="cov7" title="23">return nil</span>
|
|
}
|
|
</pre>
|
|
|
|
</div>
|
|
</body>
|
|
<script>
|
|
(function() {
|
|
var files = document.getElementById('files');
|
|
var visible;
|
|
files.addEventListener('change', onChange, false);
|
|
function select(part) {
|
|
if (visible)
|
|
visible.style.display = 'none';
|
|
visible = document.getElementById(part);
|
|
if (!visible)
|
|
return;
|
|
files.value = part;
|
|
visible.style.display = 'block';
|
|
location.hash = part;
|
|
}
|
|
function onChange() {
|
|
select(files.value);
|
|
window.scrollTo(0, 0);
|
|
}
|
|
if (location.hash != "") {
|
|
select(location.hash.substr(1));
|
|
}
|
|
if (!visible) {
|
|
select("file0");
|
|
}
|
|
})();
|
|
</script>
|
|
</html>
|