Complete lint remediation addressing errcheck, gosec, and staticcheck violations across backend test files. Tighten pre-commit configuration to prevent future blind spots. Key Changes: - Fix 61 Go linting issues (errcheck, gosec G115/G301/G304/G306, bodyclose) - Add proper error handling for json.Unmarshal, os.Setenv, db.Close(), w.Write() - Fix gosec G115 integer overflow with strconv.FormatUint - Add #nosec annotations with justifications for test fixtures - Fix SecurityService goroutine leaks (add Close() calls) - Fix CrowdSec tar.gz non-deterministic ordering with sorted keys Pre-commit Hardening: - Remove test file exclusion from golangci-lint hook - Add gosec to .golangci-fast.yml with critical checks (G101, G110, G305) - Replace broad .golangci.yml exclusions with targeted path-specific rules - Test files now linted on every commit Test Fixes: - Fix emergency route count assertions (1→2 for dual-port setup) - Fix DNS provider service tests with proper mock setup - Fix certificate service tests with deterministic behavior Backend: 27 packages pass, 83.5% coverage Frontend: 0 lint warnings, 0 TypeScript errors Pre-commit: All 14 hooks pass (~37s)
685 lines
24 KiB
Go
685 lines
24 KiB
Go
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/crypto"
|
|
"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
|
|
jsonMarshalDebugFunc = json.Marshal // For debug logging, separate hook for testing
|
|
// Test hooks for bandaging validation/generation flows
|
|
generateConfigFunc = GenerateConfig
|
|
validateConfigFunc = Validate
|
|
)
|
|
|
|
// DNSProviderConfig contains a DNS provider with its decrypted credentials
|
|
// for use in Caddy DNS challenge configuration generation
|
|
type DNSProviderConfig struct {
|
|
ID uint
|
|
ProviderType string
|
|
PropagationTimeout int
|
|
|
|
// Single-credential mode: Use these credentials for all domains
|
|
Credentials map[string]string
|
|
|
|
// Multi-credential mode: Use zone-specific credentials
|
|
UseMultiCredentials bool
|
|
ZoneCredentials map[string]map[string]string // map[baseDomain]credentials
|
|
}
|
|
|
|
// CaddyClient defines the interface for interacting with Caddy Admin API
|
|
type CaddyClient interface {
|
|
Load(ctx context.Context, config *Config) error
|
|
Ping(ctx context.Context) error
|
|
GetConfig(ctx context.Context) (*Config, error)
|
|
}
|
|
|
|
// Manager orchestrates Caddy configuration lifecycle: generate, validate, apply, rollback.
|
|
type Manager struct {
|
|
client CaddyClient
|
|
db *gorm.DB
|
|
configDir string
|
|
frontendDir string
|
|
acmeStaging bool
|
|
securityCfg config.SecurityConfig
|
|
}
|
|
|
|
// NewManager creates a configuration manager.
|
|
func NewManager(client CaddyClient, db *gorm.DB, configDir, 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 {
|
|
// Fetch all proxy hosts from database
|
|
var hosts []models.ProxyHost
|
|
if err := m.db.Preload("Locations").Preload("Certificate").Preload("AccessList").Preload("SecurityHeaderProfile").Preload("DNSProvider").Find(&hosts).Error; err != nil {
|
|
return fmt.Errorf("fetch proxy hosts: %w", err)
|
|
}
|
|
|
|
// Fetch all DNS providers for DNS challenge configuration
|
|
var dnsProviders []models.DNSProvider
|
|
if err := m.db.Where("enabled = ?", true).Find(&dnsProviders).Error; err != nil {
|
|
logger.Log().WithError(err).Warn("failed to load DNS providers for config generation")
|
|
}
|
|
|
|
// Decrypt DNS provider credentials for config generation
|
|
// We need an encryption service to decrypt the credentials
|
|
var dnsProviderConfigs []DNSProviderConfig
|
|
if len(dnsProviders) > 0 {
|
|
// Try to get encryption key from environment
|
|
encryptionKey := os.Getenv("CHARON_ENCRYPTION_KEY")
|
|
if encryptionKey == "" {
|
|
// Try alternative env vars
|
|
for _, key := range []string{"ENCRYPTION_KEY", "CERBERUS_ENCRYPTION_KEY"} {
|
|
if val := os.Getenv(key); val != "" {
|
|
encryptionKey = val
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if encryptionKey != "" {
|
|
// Import crypto package for inline decryption
|
|
encryptor, err := crypto.NewEncryptionService(encryptionKey)
|
|
if err != nil {
|
|
logger.Log().WithError(err).Warn("failed to initialize encryption service for DNS provider credentials")
|
|
} else {
|
|
// Decrypt each DNS provider's credentials
|
|
for _, provider := range dnsProviders {
|
|
// Skip if provider uses multi-credentials (will be handled in Phase 2)
|
|
if provider.UseMultiCredentials {
|
|
// Add to dnsProviderConfigs with empty Credentials for now
|
|
// Phase 2 will populate ZoneCredentials
|
|
dnsProviderConfigs = append(dnsProviderConfigs, DNSProviderConfig{
|
|
ID: provider.ID,
|
|
ProviderType: provider.ProviderType,
|
|
PropagationTimeout: provider.PropagationTimeout,
|
|
Credentials: nil, // Will be populated in Phase 2
|
|
})
|
|
continue
|
|
}
|
|
|
|
if provider.CredentialsEncrypted == "" {
|
|
continue
|
|
}
|
|
|
|
decryptedData, err := encryptor.Decrypt(provider.CredentialsEncrypted)
|
|
if err != nil {
|
|
logger.Log().WithError(err).WithField("provider_id", provider.ID).Warn("failed to decrypt DNS provider credentials")
|
|
continue
|
|
}
|
|
|
|
var credentials map[string]string
|
|
if err := json.Unmarshal(decryptedData, &credentials); err != nil {
|
|
logger.Log().WithError(err).WithField("provider_id", provider.ID).Warn("failed to parse DNS provider credentials")
|
|
continue
|
|
}
|
|
|
|
dnsProviderConfigs = append(dnsProviderConfigs, DNSProviderConfig{
|
|
ID: provider.ID,
|
|
ProviderType: provider.ProviderType,
|
|
PropagationTimeout: provider.PropagationTimeout,
|
|
Credentials: credentials,
|
|
})
|
|
}
|
|
}
|
|
} else {
|
|
logger.Log().Warn("CHARON_ENCRYPTION_KEY not set, DNS challenge configuration will be skipped")
|
|
}
|
|
}
|
|
|
|
// Phase 2: Resolve zone-specific credentials for multi-credential providers
|
|
// For each provider with UseMultiCredentials=true, build a map of domain->credentials
|
|
// by iterating through all proxy hosts that use DNS challenge
|
|
for i := range dnsProviderConfigs {
|
|
cfg := &dnsProviderConfigs[i]
|
|
|
|
// Find the provider in the dnsProviders slice to check UseMultiCredentials
|
|
var provider *models.DNSProvider
|
|
for j := range dnsProviders {
|
|
if dnsProviders[j].ID == cfg.ID {
|
|
provider = &dnsProviders[j]
|
|
break
|
|
}
|
|
}
|
|
|
|
// Skip if not multi-credential mode or provider not found
|
|
if provider == nil || !provider.UseMultiCredentials {
|
|
continue
|
|
}
|
|
|
|
// Enable multi-credential mode for this provider config
|
|
cfg.UseMultiCredentials = true
|
|
cfg.ZoneCredentials = make(map[string]map[string]string)
|
|
|
|
// Preload credentials for this provider (eager loading for better logging)
|
|
if err := m.db.Preload("Credentials").First(provider, provider.ID).Error; err != nil {
|
|
logger.Log().WithError(err).WithField("provider_id", provider.ID).Warn("failed to preload credentials for provider")
|
|
continue
|
|
}
|
|
|
|
// Iterate through proxy hosts to find domains that use this provider
|
|
for _, host := range hosts {
|
|
if !host.Enabled || host.DNSProviderID == nil || *host.DNSProviderID != provider.ID {
|
|
continue
|
|
}
|
|
|
|
// Extract base domain from host's domain names
|
|
baseDomain := extractBaseDomain(host.DomainNames)
|
|
if baseDomain == "" {
|
|
continue
|
|
}
|
|
|
|
// Skip if we already resolved credentials for this domain
|
|
if _, exists := cfg.ZoneCredentials[baseDomain]; exists {
|
|
continue
|
|
}
|
|
|
|
// Resolve the appropriate credential for this domain
|
|
credentials, err := m.getCredentialForDomain(provider.ID, baseDomain, provider)
|
|
if err != nil {
|
|
logger.Log().
|
|
WithError(err).
|
|
WithField("provider_id", provider.ID).
|
|
WithField("domain", baseDomain).
|
|
Warn("failed to resolve credential for domain, DNS challenge will be skipped for this domain")
|
|
continue
|
|
}
|
|
|
|
// Store resolved credentials for this domain
|
|
cfg.ZoneCredentials[baseDomain] = credentials
|
|
|
|
logger.Log().WithFields(map[string]any{
|
|
"provider_id": provider.ID,
|
|
"provider_type": provider.ProviderType,
|
|
"domain": baseDomain,
|
|
}).Debug("resolved credential for domain")
|
|
}
|
|
|
|
// Log summary of credential resolution for audit trail
|
|
logger.Log().WithFields(map[string]any{
|
|
"provider_id": provider.ID,
|
|
"provider_type": provider.ProviderType,
|
|
"domains_resolved": len(cfg.ZoneCredentials),
|
|
}).Info("multi-credential DNS provider resolution complete")
|
|
}
|
|
|
|
// Fetch ACME email 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 and parse it
|
|
var sslProviderSetting models.Setting
|
|
var sslProviderVal string
|
|
if err := m.db.Where("key = ?", "caddy.ssl_provider").First(&sslProviderSetting).Error; err == nil {
|
|
sslProviderVal = sslProviderSetting.Value
|
|
}
|
|
|
|
// Determine effective provider and staging flag based on the setting value
|
|
effectiveProvider := ""
|
|
effectiveStaging := false // Default to production
|
|
|
|
switch sslProviderVal {
|
|
case "letsencrypt-staging":
|
|
effectiveProvider = "letsencrypt"
|
|
effectiveStaging = true
|
|
case "letsencrypt-prod":
|
|
effectiveProvider = "letsencrypt"
|
|
effectiveStaging = false
|
|
case "zerossl":
|
|
effectiveProvider = "zerossl"
|
|
effectiveStaging = false
|
|
case "auto":
|
|
effectiveProvider = "" // "both" (auto-select between Let's Encrypt and ZeroSSL)
|
|
effectiveStaging = false
|
|
default:
|
|
// Empty or unrecognized value: fallback to environment variable for backward compatibility
|
|
effectiveProvider = ""
|
|
if sslProviderVal == "" {
|
|
effectiveStaging = m.acmeStaging // Respect env var if setting is unset
|
|
} else {
|
|
effectiveStaging = false // Unknown value defaults to production
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
// warn but allow initial startup to proceed. This prevents total lockout when
|
|
// the user has enabled Cerberus but hasn't configured admin_whitelist yet.
|
|
// The warning alerts them to configure it properly.
|
|
var secCfg models.SecurityConfig
|
|
if err := m.db.Where("name = ?", "default").First(&secCfg).Error; err == nil {
|
|
if secCfg.Enabled && strings.TrimSpace(secCfg.AdminWhitelist) == "" {
|
|
logger.Log().Warn("Cerberus is enabled but admin_whitelist is empty. " +
|
|
"Security features that depend on admin whitelist will not function correctly. " +
|
|
"Please configure an admin whitelist via Settings → Security to enable full protection.")
|
|
}
|
|
}
|
|
|
|
// Load ruleset metadata (WAF/Coraza) for config generation
|
|
var rulesets []models.SecurityRuleSet
|
|
if err := m.db.Find(&rulesets).Error; err != nil {
|
|
// non-fatal: just log the error and continue with empty rules
|
|
logger.Log().WithError(err).Warn("failed to load rulesets for generate config")
|
|
}
|
|
|
|
// Load recent security decisions so they can be injected into the generated config
|
|
var decisions []models.SecurityDecision
|
|
if err := m.db.Order("created_at desc").Find(&decisions).Error; err != nil {
|
|
logger.Log().WithError(err).Warn("failed to load security decisions for generate config")
|
|
}
|
|
|
|
// Generate Caddy config
|
|
// Read admin whitelist for config generation so handlers can exclude admin IPs
|
|
var adminWhitelist string
|
|
if secCfg.AdminWhitelist != "" {
|
|
adminWhitelist = secCfg.AdminWhitelist
|
|
}
|
|
// 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, 0o700); err != nil {
|
|
logger.Log().WithError(err).Warn("failed to create coraza rulesets dir")
|
|
}
|
|
for _, rs := range rulesets {
|
|
// Sanitize name to a safe filename - prevent path traversal and special chars
|
|
safeName := strings.ToLower(rs.Name)
|
|
safeName = strings.ReplaceAll(safeName, " ", "-")
|
|
safeName = strings.ReplaceAll(safeName, "/", "-")
|
|
safeName = strings.ReplaceAll(safeName, "\\", "-")
|
|
safeName = strings.ReplaceAll(safeName, "..", "") // Strip path traversal sequences
|
|
safeName = strings.ReplaceAll(safeName, "\x00", "") // Strip null bytes
|
|
safeName = strings.ReplaceAll(safeName, "%2f", "-") // URL-encoded slash
|
|
safeName = strings.ReplaceAll(safeName, "%2e", "") // URL-encoded dot
|
|
safeName = strings.Trim(safeName, ".-") // Trim leading/trailing dots and dashes
|
|
if safeName == "" {
|
|
safeName = "unnamed-ruleset"
|
|
}
|
|
|
|
// Prepend required Coraza directives if not already present.
|
|
// These are essential for the WAF to actually enforce rules:
|
|
// - SecRuleEngine On: enables blocking mode (blocks malicious requests)
|
|
// - SecRuleEngine DetectionOnly: monitor mode (logs but doesn't block)
|
|
// - SecRequestBodyAccess On: allows inspecting POST body content
|
|
content := rs.Content
|
|
if !strings.Contains(strings.ToLower(content), "secruleengine") {
|
|
// Determine WAF engine mode: per-ruleset mode takes precedence,
|
|
// then global WAFMode, defaulting to blocking if neither is set
|
|
engineMode := "On" // default to blocking
|
|
if rs.Mode == "detection" || rs.Mode == "monitor" {
|
|
engineMode = "DetectionOnly"
|
|
} else if rs.Mode == "" && strings.EqualFold(secCfg.WAFMode, "monitor") {
|
|
// No per-ruleset mode set, use global WAFMode
|
|
engineMode = "DetectionOnly"
|
|
}
|
|
content = fmt.Sprintf("SecRuleEngine %s\nSecRequestBodyAccess On\n\n", engineMode) + content
|
|
}
|
|
|
|
// Calculate hash of the FINAL content (after prepending mode directives)
|
|
// to ensure filename changes when mode changes, forcing Caddy to reload
|
|
hash := sha256.Sum256([]byte(content))
|
|
shortHash := fmt.Sprintf("%x", hash)[:8]
|
|
filePath := filepath.Join(corazaDir, fmt.Sprintf("%s-%s.conf", safeName, shortHash))
|
|
|
|
// 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), 0o644); 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")
|
|
}
|
|
}
|
|
|
|
// Cleanup stale ruleset files that are no longer in the database
|
|
if entries, err := readDirFunc(corazaDir); err == nil {
|
|
for _, entry := range entries {
|
|
if entry.IsDir() {
|
|
continue
|
|
}
|
|
fileName := entry.Name()
|
|
filePath := filepath.Join(corazaDir, fileName)
|
|
// Check if this file is in the current rulesetPaths
|
|
isActive := false
|
|
for _, activePath := range rulesetPaths {
|
|
if activePath == filePath {
|
|
isActive = true
|
|
break
|
|
}
|
|
}
|
|
if !isActive {
|
|
if err := removeFileFunc(filePath); err != nil {
|
|
logger.Log().WithError(err).WithField("path", filePath).Warn("failed to remove stale ruleset file")
|
|
} else {
|
|
logger.Log().WithField("path", filePath).Info("removed stale ruleset file")
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
logger.Log().WithError(err).Warn("failed to read coraza rulesets dir for cleanup")
|
|
}
|
|
}
|
|
|
|
generatedConfig, err := generateConfigFunc(hosts, filepath.Join(m.configDir, "data"), acmeEmail, m.frontendDir, effectiveProvider, effectiveStaging, crowdsecEnabled, wafEnabled, rateLimitEnabled, aclEnabled, adminWhitelist, rulesets, rulesetPaths, decisions, &secCfg, dnsProviderConfigs)
|
|
if err != nil {
|
|
return fmt.Errorf("generate config: %w", err)
|
|
}
|
|
|
|
// Debug logging: WAF configuration state for troubleshooting integration issues
|
|
logger.Log().WithFields(map[string]any{
|
|
"waf_enabled": wafEnabled,
|
|
"waf_mode": secCfg.WAFMode,
|
|
"waf_rules_source": secCfg.WAFRulesSource,
|
|
"ruleset_count": len(rulesets),
|
|
"ruleset_paths_len": len(rulesetPaths),
|
|
}).Debug("WAF configuration state")
|
|
for rsName, rsPath := range rulesetPaths {
|
|
logger.Log().WithFields(map[string]any{
|
|
"ruleset_name": rsName,
|
|
"ruleset_path": rsPath,
|
|
}).Debug("WAF ruleset path mapping")
|
|
}
|
|
|
|
// Log generated config size and a compact JSON snippet for debugging when in debug mode
|
|
if cfgJSON, jerr := jsonMarshalDebugFunc(generatedConfig); 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(generatedConfig); err != nil {
|
|
return fmt.Errorf("validation failed: %w", err)
|
|
}
|
|
|
|
// Save snapshot for rollback
|
|
snapshotPath, err := m.saveSnapshot(generatedConfig)
|
|
if err != nil {
|
|
return fmt.Errorf("save snapshot: %w", err)
|
|
}
|
|
|
|
// Calculate config hash for audit trail
|
|
configJSON, _ := json.Marshal(generatedConfig)
|
|
configHash := fmt.Sprintf("%x", sha256.Sum256(configJSON))
|
|
|
|
// Apply to Caddy
|
|
if err := m.client.Load(ctx, generatedConfig); err != nil {
|
|
// Remove the failed snapshot so rollback uses the previous one
|
|
_ = removeFileFunc(snapshotPath)
|
|
|
|
// Rollback on failure
|
|
if rollbackErr := m.rollback(ctx); rollbackErr != nil {
|
|
// 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)
|
|
}
|
|
|
|
// Record failed attempt
|
|
m.recordConfigChange(configHash, false, err.Error())
|
|
return fmt.Errorf("apply failed (rolled back): %w", err)
|
|
}
|
|
|
|
// Record successful application
|
|
m.recordConfigChange(configHash, true, "")
|
|
|
|
// Cleanup old snapshots (keep last 10)
|
|
if err := m.rotateSnapshots(10); err != nil {
|
|
// Non-fatal - log but don't fail
|
|
logger.Log().WithError(err).Warn("warning: snapshot rotation failed")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// saveSnapshot stores the config to disk with timestamp.
|
|
func (m *Manager) saveSnapshot(conf *Config) (string, error) {
|
|
timestamp := time.Now().Unix()
|
|
filename := fmt.Sprintf("config-%d.json", timestamp)
|
|
path := filepath.Join(m.configDir, filename)
|
|
|
|
configJSON, err := jsonMarshalFunc(conf, "", " ")
|
|
if err != nil {
|
|
return "", fmt.Errorf("marshal config: %w", err)
|
|
}
|
|
|
|
if err := writeFileFunc(path, configJSON, 0o644); err != nil {
|
|
return "", fmt.Errorf("write snapshot: %w", err)
|
|
}
|
|
|
|
return path, nil
|
|
}
|
|
|
|
// rollback loads the most recent snapshot from disk.
|
|
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]
|
|
configJSON, err := readFileFunc(latestSnapshot)
|
|
if err != nil {
|
|
return fmt.Errorf("read snapshot: %w", err)
|
|
}
|
|
|
|
var conf Config
|
|
if err := json.Unmarshal(configJSON, &conf); err != nil {
|
|
return fmt.Errorf("unmarshal snapshot: %w", err)
|
|
}
|
|
|
|
// Apply the snapshot
|
|
if err := m.client.Load(ctx, &conf); err != nil {
|
|
return fmt.Errorf("load snapshot: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// listSnapshots returns all snapshot file paths sorted by modification time.
|
|
func (m *Manager) listSnapshots() ([]string, error) {
|
|
entries, err := readDirFunc(m.configDir)
|
|
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" {
|
|
continue
|
|
}
|
|
snapshots = append(snapshots, filepath.Join(m.configDir, entry.Name()))
|
|
}
|
|
|
|
// Sort by modification time
|
|
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
|
|
}
|
|
|
|
// rotateSnapshots keeps only the N most recent snapshots.
|
|
func (m *Manager) rotateSnapshots(keep int) error {
|
|
snapshots, err := m.listSnapshots()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(snapshots) <= keep {
|
|
return nil
|
|
}
|
|
|
|
// Delete oldest snapshots
|
|
toDelete := snapshots[:len(snapshots)-keep]
|
|
for _, path := range toDelete {
|
|
if err := removeFileFunc(path); err != nil {
|
|
return fmt.Errorf("delete snapshot %s: %w", path, err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// recordConfigChange stores an audit record in the database.
|
|
func (m *Manager) recordConfigChange(configHash string, success bool, errorMsg string) {
|
|
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)
|
|
}
|
|
|
|
// Ping checks if Caddy is reachable.
|
|
func (m *Manager) Ping(ctx context.Context) error {
|
|
return m.client.Ping(ctx)
|
|
}
|
|
|
|
// GetCurrentConfig retrieves the running config from Caddy.
|
|
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(_ context.Context) (cerbEnabled, aclEnabled, wafEnabled, rateLimitEnabled, crowdsecEnabled bool) {
|
|
// Start with base flags from static config (environment variables)
|
|
cerbEnabled = m.securityCfg.CerberusEnabled
|
|
wafEnabled = m.securityCfg.WAFMode != "" && m.securityCfg.WAFMode != "disabled"
|
|
rateLimitEnabled = m.securityCfg.RateLimitMode == "enabled"
|
|
crowdsecEnabled = m.securityCfg.CrowdSecMode == "local"
|
|
aclEnabled = m.securityCfg.ACLMode == "enabled"
|
|
|
|
if m.db != nil {
|
|
// Priority 1: Read from SecurityConfig table (DB overrides static config)
|
|
var sc models.SecurityConfig
|
|
if err := m.db.Where("name = ?", "default").First(&sc).Error; err == nil {
|
|
// SecurityConfig.Enabled controls Cerberus globally
|
|
cerbEnabled = sc.Enabled
|
|
|
|
// WAF mode from DB
|
|
if sc.WAFMode != "" {
|
|
wafEnabled = !strings.EqualFold(sc.WAFMode, "disabled")
|
|
}
|
|
|
|
// Rate limiting from DB
|
|
if sc.RateLimitMode != "" {
|
|
rateLimitEnabled = strings.EqualFold(sc.RateLimitMode, "enabled")
|
|
} else if sc.RateLimitEnable {
|
|
// Fallback to boolean field for backward compatibility
|
|
rateLimitEnabled = true
|
|
}
|
|
|
|
// CrowdSec mode from DB
|
|
if sc.CrowdSecMode != "" {
|
|
crowdsecEnabled = sc.CrowdSecMode == "local"
|
|
}
|
|
|
|
// ACL mode (if we add it to SecurityConfig in the future)
|
|
// For now, ACL mode stays at static config value or settings override below
|
|
}
|
|
|
|
// Priority 2: Settings table overrides (for feature flags)
|
|
var s models.Setting
|
|
// runtime override for cerberus enabled (check feature flag first, fallback to legacy key)
|
|
if err := m.db.Where("key = ?", "feature.cerberus.enabled").First(&s).Error; err == nil {
|
|
cerbEnabled = strings.EqualFold(s.Value, "true")
|
|
} else 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
|
|
s = models.Setting{} // Reset to prevent ID leakage from previous query
|
|
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 WAF enabled
|
|
s = models.Setting{} // Reset
|
|
if err := m.db.Where("key = ?", "security.waf.enabled").First(&s).Error; err == nil {
|
|
if strings.EqualFold(s.Value, "true") {
|
|
wafEnabled = true
|
|
} else if strings.EqualFold(s.Value, "false") {
|
|
wafEnabled = false
|
|
}
|
|
}
|
|
|
|
// runtime override for Rate Limit enabled
|
|
s = models.Setting{} // Reset
|
|
if err := m.db.Where("key = ?", "security.rate_limit.enabled").First(&s).Error; err == nil {
|
|
if strings.EqualFold(s.Value, "true") {
|
|
rateLimitEnabled = true
|
|
} else if strings.EqualFold(s.Value, "false") {
|
|
rateLimitEnabled = 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
|
|
}
|