package waf import ( "fmt" "log/slog" "net" "sync" "github.com/corazawaf/coraza/v3" "git.netcell-it.de/projekte/edgeguard-native/internal/models" ) // DomainEngine bundles a Coraza WAF with its operating mode. type DomainEngine struct { WAF coraza.WAF Mode string // "detection" | "blocking" TrustedProxies []string // wenn src ∈ diese → echte Client-IP aus X-Forwarded-For } // Manager holds per-domain Coraza engine instances. Engines are // rebuilt only when their configuration changes (UpdatedAt differs). // All public methods are safe for concurrent use. type Manager struct { mu sync.RWMutex engines map[string]*DomainEngine // hostname → engine (nil entry = disabled) configKeys map[string]configKey // hostname → last-seen config fingerprint crsDir string } // configKey identifies a specific WAF config snapshot so we only // rebuild the engine when something actually changed. type configKey struct { enabled bool mode string paranoiaLevel int updatedAt int64 // unix nano } // NewManager creates an empty Manager with the given CRS directory. func NewManager(crsDir string) *Manager { if crsDir == "" { crsDir = DefaultCRSDir } return &Manager{ engines: make(map[string]*DomainEngine), configKeys: make(map[string]configKey), crsDir: crsDir, } } // DomainConfig pairs a domain hostname with its WAF policy. type DomainConfig struct { Hostname string Config models.WafConfig } // Reload refreshes engines from the given list, rebuilding only when // the config has actually changed since the last call. func (m *Manager) Reload(domains []DomainConfig) error { m.mu.RLock() prevEngines := m.engines prevKeys := m.configKeys m.mu.RUnlock() newEngines := make(map[string]*DomainEngine, len(domains)) newKeys := make(map[string]configKey, len(domains)) for _, dc := range domains { ck := configKey{ enabled: dc.Config.Enabled, mode: dc.Config.Mode, paranoiaLevel: dc.Config.ParanoiaLevel, updatedAt: dc.Config.UpdatedAt.UnixNano(), } newKeys[dc.Hostname] = ck if !dc.Config.Enabled { newEngines[dc.Hostname] = nil continue } // Reuse existing engine if config hasn't changed. if prev, ok := prevKeys[dc.Hostname]; ok && prev == ck { if existing := prevEngines[dc.Hostname]; existing != nil { newEngines[dc.Hostname] = existing continue } } waf, err := BuildEngine(dc.Config, m.crsDir) if err != nil { return fmt.Errorf("waf: build engine for %s: %w", dc.Hostname, err) } newEngines[dc.Hostname] = &DomainEngine{WAF: waf, Mode: dc.Config.Mode, TrustedProxies: dc.Config.TrustedProxies} slog.Info("waf: engine (re)loaded", "host", dc.Hostname, "mode", dc.Config.Mode, "paranoia_level", dc.Config.ParanoiaLevel, "crs", crsAvailable(m.crsDir), ) } m.mu.Lock() m.engines = newEngines m.configKeys = newKeys m.mu.Unlock() return nil } // GetForHost returns the DomainEngine for the given hostname, or // (nil, false) when the domain has no WAF or WAF is disabled. func (m *Manager) GetForHost(host string) (*DomainEngine, bool) { // Strip port if present (e.g. "example.com:443" → "example.com"). // SplitHostPort errors for a bare host or bare IPv6 literal → keep as-is. if h, _, err := net.SplitHostPort(host); err == nil { host = h } m.mu.RLock() de, ok := m.engines[host] m.mu.RUnlock() if !ok || de == nil { return nil, false } return de, true }