package waf import ( "fmt" "log/slog" "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" } // 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} 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"). if i := lastColon(host); i >= 0 { host = host[:i] } m.mu.RLock() de, ok := m.engines[host] m.mu.RUnlock() if !ok || de == nil { return nil, false } return de, true } // lastColon returns the index of the last ':' in s that looks like a // port separator (after the final ']' for IPv6), or -1. func lastColon(s string) int { // IPv6 addresses in brackets: "[::1]:443" if len(s) > 0 && s[0] == '[' { if rb := lastByte(s, ']'); rb >= 0 && rb < len(s)-1 && s[rb+1] == ':' { return rb + 1 } return -1 } // Plain host — only strip port if there's exactly one colon. count := 0 idx := -1 for i, c := range s { if c == ':' { count++ idx = i } } if count == 1 { return idx } return -1 } func lastByte(s string, b byte) int { for i := len(s) - 1; i >= 0; i-- { if s[i] == b { return i } } return -1 }