Skip to main content

synapse_pingora/
config_manager.rs

1//! Centralized configuration manager with coordinated updates.
2//!
3//! This module provides atomic configuration mutations that coordinate updates
4//! across VhostMatcher, SiteWafManager, RateLimitManager, and AccessListManager.
5
6use std::collections::HashMap;
7use std::fs;
8use std::path::PathBuf;
9use std::sync::Arc;
10
11use parking_lot::RwLock;
12use serde::{Deserialize, Serialize};
13use sha2::{Digest, Sha256};
14use tracing::{debug, info, warn};
15
16use crate::access::AccessListManager;
17use crate::config::{AccessControlConfig, ConfigFile};
18use crate::ratelimit::RateLimitManager;
19use crate::site_waf::SiteWafManager;
20use crate::validation::{
21    validate_cidr, validate_hostname, validate_rate_limit, validate_upstream,
22    validate_waf_threshold, ValidationError,
23};
24use crate::vhost::{SiteConfig, VhostMatcher};
25use crate::waf::Synapse;
26
27#[path = "rules.rs"]
28mod rules;
29pub use rules::{
30    CustomRuleAction, CustomRuleCondition, CustomRuleInput, CustomRuleUpdate, RuleMetadata,
31    RuleView, StoredRule,
32};
33
34// ─────────────────────────────────────────────────────────────────────────────
35// Request Types
36// ─────────────────────────────────────────────────────────────────────────────
37
38/// Request to create a new site configuration.
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct CreateSiteRequest {
41    pub hostname: String,
42    pub upstreams: Vec<String>,
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub waf: Option<SiteWafRequest>,
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub rate_limit: Option<RateLimitRequest>,
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub access_list: Option<AccessListRequest>,
49}
50
51/// Request to update an existing site configuration.
52#[derive(Debug, Clone, Serialize, Deserialize, Default)]
53pub struct UpdateSiteRequest {
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub upstreams: Option<Vec<String>>,
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub waf: Option<SiteWafRequest>,
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub rate_limit: Option<RateLimitRequest>,
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub access_list: Option<AccessListRequest>,
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub shadow_mirror: Option<crate::shadow::ShadowMirrorConfig>,
64}
65
66/// WAF configuration request.
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct SiteWafRequest {
69    pub enabled: bool,
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub threshold: Option<f64>,
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub rule_overrides: Option<HashMap<String, bool>>,
74}
75
76/// Rate limiting configuration request.
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct RateLimitRequest {
79    pub requests_per_second: u64,
80    pub burst: u64,
81}
82
83/// IP access list configuration request.
84#[derive(Debug, Clone, Serialize, Deserialize, Default)]
85pub struct AccessListRequest {
86    #[serde(default)]
87    pub allow: Vec<String>,
88    #[serde(default)]
89    pub deny: Vec<String>,
90}
91
92// ─────────────────────────────────────────────────────────────────────────────
93// Response Types
94// ─────────────────────────────────────────────────────────────────────────────
95
96/// Result of a configuration mutation operation.
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct MutationResult {
99    pub applied: bool,
100    pub persisted: bool,
101    pub rebuild_required: bool,
102    #[serde(default)]
103    pub warnings: Vec<String>,
104}
105
106impl MutationResult {
107    fn new() -> Self {
108        Self {
109            applied: false,
110            persisted: false,
111            rebuild_required: false,
112            warnings: Vec::new(),
113        }
114    }
115
116    fn with_applied(mut self) -> Self {
117        self.applied = true;
118        self
119    }
120
121    fn with_persisted(mut self) -> Self {
122        self.persisted = true;
123        self
124    }
125
126    fn with_rebuild(mut self) -> Self {
127        self.rebuild_required = true;
128        self
129    }
130
131    fn add_warning(&mut self, warning: impl Into<String>) {
132        self.warnings.push(warning.into());
133    }
134}
135
136/// Detailed site configuration response.
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct SiteDetailResponse {
139    pub hostname: String,
140    pub upstreams: Vec<String>,
141    pub tls_enabled: bool,
142    pub waf: Option<SiteWafResponse>,
143    pub rate_limit: Option<RateLimitResponse>,
144    pub access_list: Option<AccessListResponse>,
145    pub shadow_mirror: Option<crate::shadow::ShadowMirrorConfig>,
146}
147
148/// WAF configuration response.
149#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct SiteWafResponse {
151    pub enabled: bool,
152    pub threshold: u8,
153    pub rule_overrides: HashMap<String, String>,
154}
155
156/// Rate limit configuration response.
157#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct RateLimitResponse {
159    pub requests_per_second: u32,
160    pub burst: u32,
161}
162
163/// Access list configuration response.
164#[derive(Debug, Clone, Serialize, Deserialize)]
165pub struct AccessListResponse {
166    pub allow: Vec<String>,
167    pub deny: Vec<String>,
168}
169
170// ─────────────────────────────────────────────────────────────────────────────
171// Error Types
172// ─────────────────────────────────────────────────────────────────────────────
173
174/// Errors that can occur during configuration operations.
175#[derive(Debug, thiserror::Error)]
176pub enum ConfigManagerError {
177    #[error("site not found: {0}")]
178    SiteNotFound(String),
179
180    #[error("site already exists: {0}")]
181    SiteExists(String),
182
183    #[error("validation error: {0}")]
184    Validation(#[from] ValidationError),
185
186    #[error("persistence error: {0}")]
187    Persistence(String),
188
189    #[error("rebuild error: {0}")]
190    RebuildError(String),
191
192    #[error("at least one upstream is required")]
193    NoUpstreams,
194
195    #[error("rule not found: {0}")]
196    RuleNotFound(String),
197
198    #[error("rule already exists: {0}")]
199    RuleExists(String),
200}
201
202// ─────────────────────────────────────────────────────────────────────────────
203// ConfigManager
204// ─────────────────────────────────────────────────────────────────────────────
205
206/// Centralized configuration manager that coordinates updates across all runtime managers.
207pub struct ConfigManager {
208    config: Arc<RwLock<ConfigFile>>,
209    sites: Arc<RwLock<Vec<SiteConfig>>>,
210    vhost: Arc<RwLock<VhostMatcher>>,
211    waf: Arc<RwLock<SiteWafManager>>,
212    rate_limiter: Arc<RwLock<RateLimitManager>>,
213    access_lists: Arc<RwLock<AccessListManager>>,
214    config_path: Option<PathBuf>,
215    rules_store: Arc<RwLock<Vec<StoredRule>>>,
216    rules_engine: Option<Arc<RwLock<Synapse>>>,
217    rules_path: Option<PathBuf>,
218    rules_hash: Option<Arc<RwLock<String>>>,
219}
220
221impl ConfigManager {
222    /// Creates a new ConfigManager with references to all runtime managers.
223    pub fn new(
224        config: Arc<RwLock<ConfigFile>>,
225        sites: Arc<RwLock<Vec<SiteConfig>>>,
226        vhost: Arc<RwLock<VhostMatcher>>,
227        waf: Arc<RwLock<SiteWafManager>>,
228        rate_limiter: Arc<RwLock<RateLimitManager>>,
229        access_lists: Arc<RwLock<AccessListManager>>,
230    ) -> Self {
231        Self {
232            config,
233            sites,
234            vhost,
235            waf,
236            rate_limiter,
237            access_lists,
238            config_path: None,
239            rules_store: Arc::new(RwLock::new(Vec::new())),
240            rules_engine: None,
241            rules_path: None,
242            rules_hash: None,
243        }
244    }
245
246    /// Enables configuration persistence to the specified file path.
247    pub fn with_persistence(mut self, path: impl AsRef<std::path::Path>) -> Self {
248        self.config_path = Some(path.as_ref().to_path_buf());
249        self
250    }
251
252    /// Enable rule management with a shared Synapse engine and optional persistence.
253    pub fn with_rules(
254        mut self,
255        engine: Arc<RwLock<Synapse>>,
256        rules_path: Option<PathBuf>,
257        rules_hash: Option<Arc<RwLock<String>>>,
258    ) -> Self {
259        self.rules_engine = Some(engine);
260        self.rules_path = rules_path;
261        self.rules_hash = rules_hash;
262
263        if let Err(err) = self.load_rules_from_disk() {
264            warn!("Failed to load rules from disk: {}", err);
265        }
266
267        self
268    }
269
270    // ─────────────────────────────────────────────────────────────────────────
271    // CRUD Operations
272    // ─────────────────────────────────────────────────────────────────────────
273
274    /// Creates a new site configuration.
275    pub fn create_site(
276        &self,
277        req: CreateSiteRequest,
278    ) -> Result<MutationResult, ConfigManagerError> {
279        let mut result = MutationResult::new();
280
281        // Validate hostname
282        validate_hostname(&req.hostname)?;
283
284        // Validate upstreams
285        if req.upstreams.is_empty() {
286            return Err(ConfigManagerError::NoUpstreams);
287        }
288        for upstream in &req.upstreams {
289            validate_upstream(upstream)?;
290        }
291
292        // Validate WAF threshold if provided
293        if let Some(ref waf) = req.waf {
294            if let Some(threshold) = waf.threshold {
295                validate_waf_threshold(threshold)?;
296            }
297        }
298
299        // Validate rate limit if provided
300        if let Some(ref rl) = req.rate_limit {
301            validate_rate_limit(rl.requests_per_second, rl.burst)?;
302        }
303
304        // Validate CIDR in access list if provided
305        if let Some(ref al) = req.access_list {
306            for cidr in al.allow.iter().chain(al.deny.iter()) {
307                validate_cidr(cidr)?;
308            }
309        }
310
311        // Check for duplicate hostname
312        {
313            let sites = self.sites.read();
314            if sites
315                .iter()
316                .any(|s| s.hostname.to_lowercase() == req.hostname.to_lowercase())
317            {
318                return Err(ConfigManagerError::SiteExists(req.hostname.clone()));
319            }
320        }
321
322        // Build site config
323        let site_config = SiteConfig {
324            hostname: req.hostname.clone(),
325            upstreams: req.upstreams.clone(),
326            tls_enabled: false,
327            tls_cert: None,
328            tls_key: None,
329            waf_threshold: req
330                .waf
331                .as_ref()
332                .and_then(|w| w.threshold.map(|t| (t * 100.0) as u8)),
333            waf_enabled: req.waf.as_ref().map(|w| w.enabled).unwrap_or(true),
334            access_control: req
335                .access_list
336                .as_ref()
337                .map(|access_list| AccessControlConfig {
338                    allow: access_list.allow.clone(),
339                    deny: access_list.deny.clone(),
340                    default_action: "allow".to_string(),
341                }),
342            headers: None,
343            shadow_mirror: None,
344        };
345
346        // Apply changes
347        {
348            let mut sites = self.sites.write();
349            let site_id = sites.len();
350            sites.push(site_config);
351
352            // Update managers
353            let mut waf = self.waf.write();
354
355            if let Some(waf_req) = &req.waf {
356                let rule_overrides = waf_req
357                    .rule_overrides
358                    .as_ref()
359                    .map(|overrides| {
360                        overrides
361                            .iter()
362                            .map(|(rule_id, _enabled)| {
363                                (
364                                    rule_id.clone(),
365                                    crate::site_waf::RuleOverride {
366                                        rule_id: rule_id.clone(),
367                                        action: crate::site_waf::WafAction::Block,
368                                        threshold: None,
369                                        enabled: *_enabled,
370                                    },
371                                )
372                            })
373                            .collect()
374                    })
375                    .unwrap_or_default();
376
377                let waf_config = crate::site_waf::SiteWafConfig {
378                    enabled: waf_req.enabled,
379                    threshold: waf_req.threshold.map(|t| (t * 100.0) as u8).unwrap_or(70),
380                    rule_overrides,
381                    custom_block_page: None,
382                    default_action: crate::site_waf::WafAction::Block,
383                };
384                waf.add_site(&req.hostname, waf_config);
385            }
386
387            if let Some(rl_req) = &req.rate_limit {
388                let rl_config = crate::ratelimit::RateLimitConfig {
389                    rps: rl_req.requests_per_second as u32,
390                    burst: rl_req.burst as u32,
391                    enabled: true,
392                    window_secs: 1,
393                };
394                self.rate_limiter.write().add_site(&req.hostname, rl_config);
395            }
396
397            if let Some(al_req) = &req.access_list {
398                let mut access_list = crate::access::AccessList::allow_all();
399
400                for cidr in &al_req.allow {
401                    if let Err(e) = access_list.allow(cidr) {
402                        warn!("failed to add allow rule '{}': {}", cidr, e);
403                    }
404                }
405
406                for cidr in &al_req.deny {
407                    if let Err(e) = access_list.deny(cidr) {
408                        warn!("failed to add deny rule '{}': {}", cidr, e);
409                    }
410                }
411
412                self.access_lists
413                    .write()
414                    .add_site(&req.hostname, access_list);
415            }
416
417            info!(hostname = %req.hostname, site_id = site_id, "created new site");
418        }
419
420        result = result.with_applied();
421
422        // Rebuild VhostMatcher
423        self.rebuild_vhost()?;
424        result = result.with_rebuild();
425
426        // Persist if enabled
427        if self.config_path.is_some() {
428            match self.persist_config() {
429                Ok(()) => result = result.with_persisted(),
430                Err(e) => {
431                    result.add_warning(format!("failed to persist config: {}", e));
432                    warn!(error = %e, "failed to persist config after create_site");
433                }
434            }
435        }
436
437        Ok(result)
438    }
439
440    /// Retrieves detailed information about a site.
441    pub fn get_site(&self, hostname: &str) -> Result<SiteDetailResponse, ConfigManagerError> {
442        let sites = self.sites.read();
443        let waf = self.waf.read();
444
445        let site = sites
446            .iter()
447            .find(|s| s.hostname.to_lowercase() == hostname.to_lowercase())
448            .ok_or_else(|| ConfigManagerError::SiteNotFound(hostname.to_string()))?;
449
450        let waf_config = waf.get_config(hostname);
451        let waf_response = Some(SiteWafResponse {
452            enabled: waf_config.enabled,
453            threshold: waf_config.threshold,
454            rule_overrides: waf_config
455                .rule_overrides
456                .iter()
457                .map(|(k, v)| (k.clone(), format!("{:?}", v.action)))
458                .collect(),
459        });
460
461        Ok(SiteDetailResponse {
462            hostname: site.hostname.clone(),
463            upstreams: site.upstreams.clone(),
464            tls_enabled: site.tls_enabled,
465            waf: waf_response,
466            rate_limit: None,
467            access_list: None,
468            shadow_mirror: site.shadow_mirror.clone(),
469        })
470    }
471
472    /// Lists all configured site hostnames.
473    pub fn list_sites(&self) -> Vec<String> {
474        let sites = self.sites.read();
475        sites.iter().map(|s| s.hostname.clone()).collect()
476    }
477
478    /// Returns full site info for all sites (for API response).
479    pub fn get_sites_info(&self) -> Vec<crate::api::SiteInfo> {
480        let sites = self.sites.read();
481        sites
482            .iter()
483            .map(|s| crate::api::SiteInfo {
484                hostname: s.hostname.clone(),
485                upstreams: s.upstreams.clone(),
486                tls_enabled: s.tls_enabled,
487                waf_enabled: s.waf_enabled,
488            })
489            .collect()
490    }
491
492    /// Updates an existing site configuration.
493    pub fn update_site(
494        &self,
495        hostname: &str,
496        req: UpdateSiteRequest,
497    ) -> Result<MutationResult, ConfigManagerError> {
498        let mut result = MutationResult::new();
499
500        // Validate upstreams if provided
501        if let Some(ref upstreams) = req.upstreams {
502            if upstreams.is_empty() {
503                return Err(ConfigManagerError::NoUpstreams);
504            }
505            for upstream in upstreams {
506                validate_upstream(upstream)?;
507            }
508        }
509
510        // Validate WAF threshold if provided
511        if let Some(ref waf) = req.waf {
512            if let Some(threshold) = waf.threshold {
513                validate_waf_threshold(threshold)?;
514            }
515        }
516
517        // Validate rate limit if provided
518        if let Some(ref rl) = req.rate_limit {
519            validate_rate_limit(rl.requests_per_second, rl.burst)?;
520        }
521
522        // Validate CIDR in access list if provided
523        if let Some(ref al) = req.access_list {
524            for cidr in al.allow.iter().chain(al.deny.iter()) {
525                validate_cidr(cidr)?;
526            }
527        }
528
529        // Apply changes
530        {
531            let mut sites = self.sites.write();
532            let mut waf = self.waf.write();
533
534            let (_site_id, site) = sites
535                .iter_mut()
536                .enumerate()
537                .find(|(_, s)| s.hostname.to_lowercase() == hostname.to_lowercase())
538                .ok_or_else(|| ConfigManagerError::SiteNotFound(hostname.to_string()))?;
539
540            // Update upstreams
541            if let Some(upstreams) = req.upstreams {
542                site.upstreams = upstreams;
543                debug!(hostname = %hostname, "updated upstreams");
544            }
545
546            // Update WAF
547            if let Some(waf_req) = req.waf {
548                site.waf_enabled = waf_req.enabled;
549                site.waf_threshold = waf_req.threshold.map(|t| (t * 100.0) as u8);
550
551                let rule_overrides = waf_req
552                    .rule_overrides
553                    .as_ref()
554                    .map(|overrides| {
555                        overrides
556                            .iter()
557                            .map(|(rule_id, _enabled)| {
558                                (
559                                    rule_id.clone(),
560                                    crate::site_waf::RuleOverride {
561                                        rule_id: rule_id.clone(),
562                                        action: crate::site_waf::WafAction::Block,
563                                        threshold: None,
564                                        enabled: *_enabled,
565                                    },
566                                )
567                            })
568                            .collect()
569                    })
570                    .unwrap_or_default();
571
572                if let Some(config) = waf.get_config_mut(hostname) {
573                    config.enabled = waf_req.enabled;
574                    config.threshold = waf_req.threshold.map(|t| (t * 100.0) as u8).unwrap_or(70);
575                    config.rule_overrides = rule_overrides;
576                } else {
577                    let waf_config = crate::site_waf::SiteWafConfig {
578                        enabled: waf_req.enabled,
579                        threshold: waf_req.threshold.map(|t| (t * 100.0) as u8).unwrap_or(70),
580                        rule_overrides,
581                        custom_block_page: None,
582                        default_action: crate::site_waf::WafAction::Block,
583                    };
584                    waf.add_site(hostname, waf_config);
585                }
586                debug!(hostname = %hostname, "updated WAF config");
587            }
588
589            // Update rate limit
590            if let Some(rl_req) = req.rate_limit {
591                let rl_config = crate::ratelimit::RateLimitConfig {
592                    rps: rl_req.requests_per_second as u32,
593                    burst: rl_req.burst as u32,
594                    enabled: true,
595                    window_secs: 1,
596                };
597                self.rate_limiter.write().add_site(hostname, rl_config);
598                debug!(hostname = %hostname, "updated rate limit config");
599            }
600
601            // Update access list
602            if let Some(al_req) = req.access_list {
603                site.access_control = Some(AccessControlConfig {
604                    allow: al_req.allow.clone(),
605                    deny: al_req.deny.clone(),
606                    default_action: "allow".to_string(),
607                });
608                let mut access_list = crate::access::AccessList::allow_all();
609
610                for cidr in &al_req.allow {
611                    if let Err(e) = access_list.allow(cidr) {
612                        warn!("failed to add allow rule '{}': {}", cidr, e);
613                    }
614                }
615
616                for cidr in &al_req.deny {
617                    if let Err(e) = access_list.deny(cidr) {
618                        warn!("failed to add deny rule '{}': {}", cidr, e);
619                    }
620                }
621
622                self.access_lists.write().add_site(hostname, access_list);
623                debug!(hostname = %hostname, "updated access list config");
624            }
625
626            // Update shadow mirror config
627            if let Some(shadow_mirror_config) = req.shadow_mirror {
628                site.shadow_mirror = Some(shadow_mirror_config);
629                debug!(hostname = %hostname, "updated shadow mirror config");
630            }
631
632            info!(hostname = %hostname, "updated site configuration");
633        }
634
635        result = result.with_applied();
636
637        // Persist if enabled
638        if self.config_path.is_some() {
639            match self.persist_config() {
640                Ok(()) => result = result.with_persisted(),
641                Err(e) => {
642                    result.add_warning(format!("failed to persist config: {}", e));
643                    warn!(error = %e, "failed to persist config after update_site");
644                }
645            }
646        }
647
648        Ok(result)
649    }
650
651    /// Deletes a site configuration.
652    pub fn delete_site(&self, hostname: &str) -> Result<MutationResult, ConfigManagerError> {
653        let mut result = MutationResult::new();
654
655        {
656            let mut sites = self.sites.write();
657
658            let _site_id = sites
659                .iter()
660                .position(|s| s.hostname.to_lowercase() == hostname.to_lowercase())
661                .ok_or_else(|| ConfigManagerError::SiteNotFound(hostname.to_string()))?;
662
663            sites.remove(_site_id);
664            // Note: WAF, rate_limiter, and access_lists don't have remove_site methods,
665            // so they will retain the site configuration but it won't be matched during lookups
666
667            info!(hostname = %hostname, "deleted site");
668        }
669
670        result = result.with_applied();
671
672        // Rebuild VhostMatcher
673        self.rebuild_vhost()?;
674        result = result.with_rebuild();
675
676        // Persist if enabled
677        if self.config_path.is_some() {
678            match self.persist_config() {
679                Ok(()) => result = result.with_persisted(),
680                Err(e) => {
681                    result.add_warning(format!("failed to persist config: {}", e));
682                    warn!(error = %e, "failed to persist config after delete_site");
683                }
684            }
685        }
686
687        Ok(result)
688    }
689
690    /// Retrieves the full runtime configuration.
691    pub fn get_full_config(&self) -> ConfigFile {
692        let config = self.config.read();
693        config.clone()
694    }
695
696    /// Computes a stable hash of the current configuration for diagnostics.
697    pub fn config_hash(&self) -> String {
698        let config = self.config.read();
699        let payload = serde_json::to_vec(&*config).unwrap_or_default();
700        let mut hasher = Sha256::new();
701        hasher.update(payload);
702        let digest = format!("{:x}", hasher.finalize());
703        digest.get(..16).unwrap_or(&digest).to_string()
704    }
705
706    /// Returns the current rules hash (or computes one if not cached).
707    pub fn rules_hash(&self) -> String {
708        if let Some(hash) = self.rules_hash.as_ref() {
709            return hash.read().clone();
710        }
711        let rules = self.rules_store.read();
712        rules::rules_hash(&rules)
713    }
714
715    /// Updates the full configuration (hot reload).
716    ///
717    /// This replaces the entire configuration state and triggers a rebuild
718    /// of all dependent components (VHost, WAF, RateLimit, AccessList).
719    pub fn update_full_config(
720        &self,
721        new_config: ConfigFile,
722    ) -> Result<MutationResult, ConfigManagerError> {
723        let mut result = MutationResult::new();
724
725        // Validate the new configuration comprehensively
726        if new_config.sites.is_empty() {
727            // It's allowed to have no sites, but worth a warning
728            result.add_warning("Configuration has no sites defined");
729        }
730
731        // Validate each site in the configuration
732        let mut seen_hostnames: std::collections::HashSet<String> =
733            std::collections::HashSet::new();
734        for (idx, site) in new_config.sites.iter().enumerate() {
735            // Validate hostname
736            if let Err(e) = validate_hostname(&site.hostname) {
737                return Err(ConfigManagerError::Validation(
738                    ValidationError::InvalidDomain(format!(
739                        "Site[{}] hostname '{}': {}",
740                        idx, site.hostname, e
741                    )),
742                ));
743            }
744
745            // Check for duplicate hostnames
746            let normalized = site.hostname.to_lowercase();
747            if seen_hostnames.contains(&normalized) {
748                return Err(ConfigManagerError::Validation(
749                    ValidationError::InvalidDomain(format!(
750                        "Site[{}] hostname '{}' is duplicated",
751                        idx, site.hostname
752                    )),
753                ));
754            }
755            seen_hostnames.insert(normalized);
756
757            // Validate upstreams
758            if site.upstreams.is_empty() {
759                return Err(ConfigManagerError::Validation(
760                    ValidationError::InvalidDomain(format!(
761                        "Site[{}] '{}' has no upstreams defined",
762                        idx, site.hostname
763                    )),
764                ));
765            }
766            for (u_idx, upstream) in site.upstreams.iter().enumerate() {
767                // UpstreamConfig has host/port fields - format as host:port for validation
768                let upstream_str = format!("{}:{}", upstream.host, upstream.port);
769                if let Err(e) = validate_upstream(&upstream_str) {
770                    return Err(ConfigManagerError::Validation(
771                        ValidationError::InvalidDomain(format!(
772                            "Site[{}] '{}' upstream[{}] '{}:{}': {}",
773                            idx, site.hostname, u_idx, upstream.host, upstream.port, e
774                        )),
775                    ));
776                }
777            }
778
779            // Validate WAF threshold if present
780            if let Some(ref waf) = site.waf {
781                if let Some(threshold) = waf.threshold {
782                    // threshold is u8 (0-255), validate_waf_threshold expects f64 (0-100)
783                    if let Err(e) = validate_waf_threshold(threshold as f64) {
784                        return Err(ConfigManagerError::Validation(
785                            ValidationError::InvalidDomain(format!(
786                                "Site[{}] '{}' WAF threshold: {}",
787                                idx, site.hostname, e
788                            )),
789                        ));
790                    }
791                }
792            }
793
794            // Validate rate limit if present
795            if let Some(ref rl) = site.rate_limit {
796                // RateLimitConfig has rps: u32, burst: Option<u32>
797                // validate_rate_limit expects (requests: u64, window: u64)
798                let burst = rl.burst.unwrap_or(rl.rps.saturating_mul(2));
799                if let Err(e) = validate_rate_limit(rl.rps as u64, burst as u64) {
800                    return Err(ConfigManagerError::Validation(
801                        ValidationError::InvalidDomain(format!(
802                            "Site[{}] '{}' rate limit: {}",
803                            idx, site.hostname, e
804                        )),
805                    ));
806                }
807            }
808
809            // Validate access control CIDRs if present
810            if let Some(ref ac) = site.access_control {
811                for (c_idx, cidr) in ac.allow.iter().enumerate() {
812                    if let Err(e) = validate_cidr(cidr) {
813                        return Err(ConfigManagerError::Validation(
814                            ValidationError::InvalidDomain(format!(
815                                "Site[{}] '{}' access_control.allow[{}] '{}': {}",
816                                idx, site.hostname, c_idx, cidr, e
817                            )),
818                        ));
819                    }
820                }
821                for (c_idx, cidr) in ac.deny.iter().enumerate() {
822                    if let Err(e) = validate_cidr(cidr) {
823                        return Err(ConfigManagerError::Validation(
824                            ValidationError::InvalidDomain(format!(
825                                "Site[{}] '{}' access_control.deny[{}] '{}': {}",
826                                idx, site.hostname, c_idx, cidr, e
827                            )),
828                        ));
829                    }
830                }
831            }
832        }
833
834        // Apply changes atomically
835        {
836            // 1. Update ConfigFile wrapper
837            let mut config = self.config.write();
838            *config = new_config.clone();
839
840            // 2. Update Sites list (convert SiteYamlConfig -> SiteConfig)
841            let mut sites = self.sites.write();
842            *sites = new_config
843                .sites
844                .iter()
845                .map(|s| crate::vhost::SiteConfig::from(s.clone()))
846                .collect();
847
848            // 3. Update SiteWafManager with full state replacement
849            // Build set of new hostnames for efficient lookup
850            let new_hostnames: std::collections::HashSet<String> = new_config
851                .sites
852                .iter()
853                .map(|s| s.hostname.to_lowercase())
854                .collect();
855
856            let mut waf = self.waf.write();
857
858            // Remove sites that are no longer in the configuration
859            let old_hostnames = waf.hostnames();
860            for old_host in old_hostnames {
861                if !new_hostnames.contains(&old_host.to_lowercase()) {
862                    waf.remove_site(&old_host);
863                    info!(
864                        hostname = %old_host,
865                        "Removed site WAF configuration (no longer in config)"
866                    );
867                }
868            }
869
870            // Add/update sites from new config
871            for site in &new_config.sites {
872                if let Some(ref waf_yaml) = site.waf {
873                    if let Some(threshold) = waf_yaml.threshold {
874                        let waf_config = crate::site_waf::SiteWafConfig {
875                            enabled: waf_yaml.enabled,
876                            threshold,
877                            rule_overrides: HashMap::new(),
878                            custom_block_page: None,
879                            default_action: crate::site_waf::WafAction::Block,
880                        };
881                        waf.add_site(&site.hostname, waf_config);
882                    }
883                }
884            }
885
886            // 4. Update RateLimitManager
887            // Similar limitation: additive updates only
888            // TODO: Refactor managers to support full state replacement
889            let _rate_limiter = self.rate_limiter.write();
890            // Assuming config has rate limit settings? ConfigFile doesn't explicitly store RL per site
891            // except via the API-driven updates. This is a mismatch in the current architecture.
892            // The ConfigFile struct tracks `sites`, and `SiteConfig` doesn't strictly have RL fields
893            // other than what we added in memory?
894            // Actually, `SiteConfig` in `vhost.rs` DOES NOT have rate limit fields.
895            // They are managed separately.
896            // This means `update_full_config` mainly updates sites/upstreams/tls/waf-threshold.
897            // It might lose RL/AccessList state if not persisted in ConfigFile.
898
899            // 5. Update AccessListManager with full state replacement
900            let mut access_lists = self.access_lists.write();
901
902            // Remove sites that are no longer in the configuration
903            let old_access_sites = access_lists.list_sites();
904            for old_host in old_access_sites {
905                if !new_hostnames.contains(&old_host.to_lowercase()) {
906                    access_lists.remove_site(&old_host);
907                    info!(
908                        hostname = %old_host,
909                        "Removed site access list (no longer in config)"
910                    );
911                }
912            }
913
914            // Add/update sites from new config
915            for site in &new_config.sites {
916                if let Some(ac) = &site.access_control {
917                    let mut list = crate::access::AccessList::allow_all();
918                    for cidr in &ac.allow {
919                        let _ = list.allow(cidr);
920                    }
921                    for cidr in &ac.deny {
922                        let _ = list.deny(cidr);
923                    }
924                    access_lists.add_site(&site.hostname, list);
925                }
926            }
927
928            info!("Full configuration updated with {} sites", sites.len());
929        }
930
931        result = result.with_applied();
932
933        // Rebuild VhostMatcher
934        self.rebuild_vhost()?;
935        result = result.with_rebuild();
936
937        // Persist
938        if self.config_path.is_some() {
939            match self.persist_config() {
940                Ok(()) => result = result.with_persisted(),
941                Err(e) => {
942                    result.add_warning(format!("failed to persist config: {}", e));
943                    warn!(error = %e, "failed to persist config after update_full_config");
944                }
945            }
946        }
947
948        Ok(result)
949    }
950
951    // ─────────────────────────────────────────────────────────────────────────
952    // Partial Update Operations
953    // ─────────────────────────────────────────────────────────────────────────
954
955    /// Updates only the WAF configuration for a site.
956    pub fn update_site_waf(
957        &self,
958        hostname: &str,
959        waf_req: SiteWafRequest,
960    ) -> Result<MutationResult, ConfigManagerError> {
961        self.update_site(
962            hostname,
963            UpdateSiteRequest {
964                waf: Some(waf_req),
965                ..Default::default()
966            },
967        )
968    }
969
970    /// Updates only the rate limit configuration for a site.
971    pub fn update_site_rate_limit(
972        &self,
973        hostname: &str,
974        rate_limit: RateLimitRequest,
975    ) -> Result<MutationResult, ConfigManagerError> {
976        self.update_site(
977            hostname,
978            UpdateSiteRequest {
979                rate_limit: Some(rate_limit),
980                ..Default::default()
981            },
982        )
983    }
984
985    /// Updates only the access list configuration for a site.
986    pub fn update_site_access_list(
987        &self,
988        hostname: &str,
989        access_list: AccessListRequest,
990    ) -> Result<MutationResult, ConfigManagerError> {
991        self.update_site(
992            hostname,
993            UpdateSiteRequest {
994                access_list: Some(access_list),
995                ..Default::default()
996            },
997        )
998    }
999
1000    // ─────────────────────────────────────────────────────────────────────────
1001    // Rules Management
1002    // ─────────────────────────────────────────────────────────────────────────
1003
1004    /// List all rules currently stored on the sensor.
1005    pub fn list_rules(&self) -> Vec<StoredRule> {
1006        self.rules_store.read().clone()
1007    }
1008
1009    /// Create a new rule and apply it to the WAF engine.
1010    pub fn create_rule(&self, rule: StoredRule) -> Result<StoredRule, ConfigManagerError> {
1011        let mut rules = self.rules_store.read().clone();
1012        let rule_id = rules::rule_identifier(&rule);
1013
1014        if rules
1015            .iter()
1016            .any(|existing| rules::matches_rule_id(existing, &rule_id))
1017        {
1018            return Err(ConfigManagerError::RuleExists(rule_id));
1019        }
1020
1021        rules.push(rule.clone());
1022        self.apply_rules(rules, true, None)?;
1023        Ok(rule)
1024    }
1025
1026    /// Update an existing rule and apply changes to the WAF engine.
1027    pub fn update_rule(
1028        &self,
1029        rule_id: &str,
1030        update: CustomRuleUpdate,
1031    ) -> Result<StoredRule, ConfigManagerError> {
1032        let mut rules = self.rules_store.read().clone();
1033        let Some(index) = rules
1034            .iter()
1035            .position(|rule| rules::matches_rule_id(rule, rule_id))
1036        else {
1037            return Err(ConfigManagerError::RuleNotFound(rule_id.to_string()));
1038        };
1039
1040        let updated = rules::merge_rule_update(&rules[index], update)
1041            .map_err(ConfigManagerError::Persistence)?;
1042        rules[index] = updated.clone();
1043        self.apply_rules(rules, true, None)?;
1044        Ok(updated)
1045    }
1046
1047    /// Delete a rule by ID and apply changes to the WAF engine.
1048    pub fn delete_rule(&self, rule_id: &str) -> Result<(), ConfigManagerError> {
1049        let mut rules = self.rules_store.read().clone();
1050        let original_len = rules.len();
1051        rules.retain(|rule| !rules::matches_rule_id(rule, rule_id));
1052
1053        if rules.len() == original_len {
1054            return Err(ConfigManagerError::RuleNotFound(rule_id.to_string()));
1055        }
1056
1057        self.apply_rules(rules, true, None)?;
1058        Ok(())
1059    }
1060
1061    /// Replace all rules with a new set and apply to the WAF engine.
1062    pub fn replace_rules(
1063        &self,
1064        rules: Vec<StoredRule>,
1065        hash_override: Option<String>,
1066    ) -> Result<usize, ConfigManagerError> {
1067        self.apply_rules(rules, true, hash_override)
1068    }
1069
1070    /// Updates WAF rules from JSON bytes received from Horizon Hub.
1071    ///
1072    /// This method is called when the sensor receives a RulesUpdate or PushRules
1073    /// message from the Signal Horizon Hub via WebSocket. The rules are parsed and
1074    /// applied to the WAF engine.
1075    ///
1076    /// # Arguments
1077    /// * `rules_json` - JSON bytes containing an array of rule definitions
1078    /// * `hash_override` - Optional hash provided by Signal Horizon
1079    ///
1080    /// # Returns
1081    /// * `Ok(count)` - Number of rules received (including disabled rules)
1082    /// * `Err` - If rules parsing or application fails
1083    pub fn update_waf_rules(
1084        &self,
1085        rules_json: &[u8],
1086        hash_override: Option<&str>,
1087    ) -> Result<usize, ConfigManagerError> {
1088        let value: serde_json::Value = serde_json::from_slice(rules_json)
1089            .map_err(|e| ConfigManagerError::Persistence(format!("Invalid rules JSON: {}", e)))?;
1090
1091        let rules = rules::parse_rules_payload(value).map_err(ConfigManagerError::Persistence)?;
1092
1093        let rule_count = rules.len();
1094
1095        if rule_count == 0 {
1096            warn!("Received empty rules update from Horizon Hub");
1097            return Ok(0);
1098        }
1099
1100        info!(rule_count, "Received WAF rules update from Horizon Hub");
1101
1102        let applied = self.apply_rules(rules, true, hash_override.map(|s| s.to_string()))?;
1103
1104        info!(
1105            rules_received = rule_count,
1106            rules_applied = applied,
1107            sites_affected = self.waf.read().site_count(),
1108            "WAF rules synchronized from Horizon Hub"
1109        );
1110
1111        Ok(rule_count)
1112    }
1113
1114    // ─────────────────────────────────────────────────────────────────────────
1115    // Internal Helpers
1116    // ─────────────────────────────────────────────────────────────────────────
1117
1118    fn load_rules_from_disk(&self) -> Result<usize, ConfigManagerError> {
1119        let Some(path) = self.rules_path.clone() else {
1120            return Ok(0);
1121        };
1122
1123        if let Err(err) = recover_rules_from_wal(&path) {
1124            warn!("Failed to recover rules WAL: {}", err);
1125        }
1126
1127        if !path.exists() {
1128            return Ok(0);
1129        }
1130
1131        let rules_json = fs::read(&path)
1132            .map_err(|e| ConfigManagerError::Persistence(format!("failed to read rules: {}", e)))?;
1133        let value: serde_json::Value = serde_json::from_slice(&rules_json)
1134            .map_err(|e| ConfigManagerError::Persistence(format!("invalid rules JSON: {}", e)))?;
1135        let rules = rules::parse_rules_payload(value).map_err(ConfigManagerError::Persistence)?;
1136
1137        if rules.is_empty() {
1138            return Ok(0);
1139        }
1140
1141        self.apply_rules(rules, false, None)
1142    }
1143
1144    fn apply_rules(
1145        &self,
1146        rules: Vec<StoredRule>,
1147        persist: bool,
1148        hash_override: Option<String>,
1149    ) -> Result<usize, ConfigManagerError> {
1150        let engine = self.rules_engine.as_ref().ok_or_else(|| {
1151            ConfigManagerError::Persistence("rules engine not configured".to_string())
1152        })?;
1153
1154        let mut active_rules: Vec<&StoredRule> = rules
1155            .iter()
1156            .filter(|rule| rule.meta.enabled.unwrap_or(true))
1157            .collect();
1158
1159        active_rules.sort_by(|a, b| {
1160            let a_priority = a.meta.priority.unwrap_or(100);
1161            let b_priority = b.meta.priority.unwrap_or(100);
1162            a_priority
1163                .cmp(&b_priority)
1164                .then_with(|| a.rule.id.cmp(&b.rule.id))
1165        });
1166
1167        let waf_rules: Vec<_> = active_rules.iter().map(|rule| rule.rule.clone()).collect();
1168        let waf_json = serde_json::to_vec(&waf_rules).map_err(|e| {
1169            ConfigManagerError::Persistence(format!("failed to serialize waf rules: {}", e))
1170        })?;
1171
1172        let applied = engine.write().load_rules(&waf_json).map_err(|e| {
1173            ConfigManagerError::Persistence(format!("failed to load waf rules: {}", e))
1174        })?;
1175
1176        *self.rules_store.write() = rules.clone();
1177
1178        if persist {
1179            self.persist_rules(&rules)?;
1180        }
1181
1182        self.update_rules_hash(hash_override.unwrap_or_else(|| rules::rules_hash(&rules)));
1183
1184        Ok(applied)
1185    }
1186
1187    fn persist_rules(&self, rules: &[StoredRule]) -> Result<(), ConfigManagerError> {
1188        let Some(path) = self.rules_path.clone() else {
1189            return Ok(());
1190        };
1191
1192        if let Some(parent) = path.parent() {
1193            if let Err(err) = fs::create_dir_all(parent) {
1194                return Err(ConfigManagerError::Persistence(format!(
1195                    "failed to create rules directory: {}",
1196                    err
1197                )));
1198            }
1199        }
1200
1201        let payload = serde_json::to_vec_pretty(rules).map_err(|e| {
1202            ConfigManagerError::Persistence(format!("failed to serialize rules: {}", e))
1203        })?;
1204
1205        let wal_path = path.with_extension("wal");
1206        append_wal_entry(
1207            &wal_path,
1208            serde_json::json!({
1209                "timestamp_ms": current_timestamp_ms(),
1210                "type": "rules_update",
1211                "rules": rules,
1212            }),
1213        )?;
1214
1215        write_file_with_fsync(&path, &payload).map_err(|e| {
1216            ConfigManagerError::Persistence(format!("failed to write rules: {}", e))
1217        })?;
1218
1219        clear_wal(&wal_path)?;
1220
1221        info!(path = %path.display(), "persisted rules");
1222        Ok(())
1223    }
1224
1225    fn update_rules_hash(&self, value: String) {
1226        if let Some(hash_lock) = self.rules_hash.as_ref() {
1227            *hash_lock.write() = value;
1228        }
1229    }
1230
1231    fn rebuild_vhost(&self) -> Result<(), ConfigManagerError> {
1232        let sites = self.sites.read();
1233        let new_vhost = VhostMatcher::new(sites.clone())
1234            .map_err(|e| ConfigManagerError::RebuildError(e.to_string()))?;
1235
1236        let mut vhost = self.vhost.write();
1237        *vhost = new_vhost;
1238
1239        debug!("rebuilt VhostMatcher with {} sites", sites.len());
1240        Ok(())
1241    }
1242
1243    fn persist_config(&self) -> Result<(), ConfigManagerError> {
1244        let path = self.config_path.as_ref().ok_or_else(|| {
1245            ConfigManagerError::Persistence("no config path configured".to_string())
1246        })?;
1247
1248        let config = self.config.read();
1249        let yaml = serde_yaml::to_string(&*config).map_err(|e| {
1250            ConfigManagerError::Persistence(format!("failed to serialize config: {}", e))
1251        })?;
1252
1253        write_file_with_fsync(path, yaml.as_bytes()).map_err(|e| {
1254            ConfigManagerError::Persistence(format!("failed to write config: {}", e))
1255        })?;
1256
1257        info!(path = %path.display(), "persisted configuration");
1258        Ok(())
1259    }
1260}
1261
1262fn current_timestamp_ms() -> u64 {
1263    use std::time::{SystemTime, UNIX_EPOCH};
1264
1265    SystemTime::now()
1266        .duration_since(UNIX_EPOCH)
1267        .map(|duration| duration.as_millis() as u64)
1268        .unwrap_or(0)
1269}
1270
1271fn recover_rules_from_wal(path: &std::path::Path) -> Result<bool, ConfigManagerError> {
1272    let wal_path = path.with_extension("wal");
1273    if !wal_path.exists() {
1274        return Ok(false);
1275    }
1276
1277    let contents = fs::read_to_string(&wal_path).map_err(|err| {
1278        ConfigManagerError::Persistence(format!("failed to read WAL file: {}", err))
1279    })?;
1280    if contents.trim().is_empty() {
1281        return Ok(false);
1282    }
1283
1284    let mut last_rules: Option<Vec<StoredRule>> = None;
1285    for line in contents.lines() {
1286        let value: serde_json::Value = match serde_json::from_str(line) {
1287            Ok(value) => value,
1288            Err(err) => {
1289                warn!("Skipping invalid WAL entry: {}", err);
1290                continue;
1291            }
1292        };
1293        if value.get("type").and_then(|t| t.as_str()) != Some("rules_update") {
1294            continue;
1295        }
1296        let rules_value = value
1297            .get("rules")
1298            .cloned()
1299            .unwrap_or(serde_json::Value::Null);
1300        match serde_json::from_value::<Vec<StoredRule>>(rules_value) {
1301            Ok(rules) if !rules.is_empty() => {
1302                last_rules = Some(rules);
1303            }
1304            Ok(_) => {}
1305            Err(err) => {
1306                warn!("Skipping WAL rules entry: {}", err);
1307            }
1308        }
1309    }
1310
1311    let Some(rules) = last_rules else {
1312        return Ok(false);
1313    };
1314
1315    if let Some(parent) = path.parent() {
1316        if let Err(err) = fs::create_dir_all(parent) {
1317            return Err(ConfigManagerError::Persistence(format!(
1318                "failed to create rules directory: {}",
1319                err
1320            )));
1321        }
1322    }
1323
1324    let payload = serde_json::to_vec_pretty(&rules).map_err(|err| {
1325        ConfigManagerError::Persistence(format!("failed to serialize WAL rules: {}", err))
1326    })?;
1327
1328    write_file_with_fsync(path, &payload).map_err(|err| {
1329        ConfigManagerError::Persistence(format!("failed to apply WAL rules: {}", err))
1330    })?;
1331    clear_wal(&wal_path)?;
1332    info!(path = %path.display(), "recovered rules from WAL");
1333    Ok(true)
1334}
1335
1336fn append_wal_entry(
1337    path: &std::path::Path,
1338    entry: serde_json::Value,
1339) -> Result<(), ConfigManagerError> {
1340    use std::io::Write;
1341    let Some(parent) = path.parent() else {
1342        return Err(ConfigManagerError::Persistence(
1343            "invalid WAL path".to_string(),
1344        ));
1345    };
1346
1347    if let Err(err) = fs::create_dir_all(parent) {
1348        return Err(ConfigManagerError::Persistence(format!(
1349            "failed to create WAL directory: {}",
1350            err
1351        )));
1352    }
1353
1354    let mut file = fs::OpenOptions::new()
1355        .create(true)
1356        .append(true)
1357        .open(path)
1358        .map_err(|err| {
1359            ConfigManagerError::Persistence(format!("failed to open WAL file: {}", err))
1360        })?;
1361
1362    let payload = serde_json::to_vec(&entry).map_err(|err| {
1363        ConfigManagerError::Persistence(format!("failed to serialize WAL entry: {}", err))
1364    })?;
1365    file.write_all(&payload)
1366        .and_then(|_| file.write_all(b"\n"))
1367        .and_then(|_| file.sync_all())
1368        .map_err(|err| {
1369            ConfigManagerError::Persistence(format!("failed to persist WAL entry: {}", err))
1370        })?;
1371
1372    Ok(())
1373}
1374
1375fn write_file_with_fsync(path: &std::path::Path, contents: &[u8]) -> Result<(), std::io::Error> {
1376    use std::io::Write;
1377
1378    let mut file = fs::OpenOptions::new()
1379        .create(true)
1380        .truncate(true)
1381        .write(true)
1382        .open(path)?;
1383    file.write_all(contents)?;
1384    file.sync_all()?;
1385    Ok(())
1386}
1387
1388fn clear_wal(path: &std::path::Path) -> Result<(), ConfigManagerError> {
1389    use std::io::Write;
1390
1391    let mut file = fs::OpenOptions::new()
1392        .create(true)
1393        .truncate(true)
1394        .write(true)
1395        .open(path)
1396        .map_err(|err| {
1397            ConfigManagerError::Persistence(format!("failed to open WAL file: {}", err))
1398        })?;
1399    file.write_all(b"")
1400        .and_then(|_| file.sync_all())
1401        .map_err(|err| {
1402            ConfigManagerError::Persistence(format!("failed to clear WAL file: {}", err))
1403        })?;
1404    Ok(())
1405}
1406
1407#[cfg(test)]
1408mod tests {
1409    use super::*;
1410    use crate::waf::{MatchCondition, MatchValue, WafRule};
1411
1412    #[test]
1413    fn test_mutation_result_builder() {
1414        let result = MutationResult::new().with_applied().with_rebuild();
1415
1416        assert!(result.applied);
1417        assert!(result.rebuild_required);
1418        assert!(!result.persisted);
1419    }
1420
1421    #[test]
1422    fn test_create_site_request_serialization() {
1423        let req = CreateSiteRequest {
1424            hostname: "api.example.com".to_string(),
1425            upstreams: vec!["10.0.0.1:8080".to_string()],
1426            waf: Some(SiteWafRequest {
1427                enabled: true,
1428                threshold: Some(0.7),
1429                rule_overrides: None,
1430            }),
1431            rate_limit: None,
1432            access_list: None,
1433        };
1434
1435        let json = serde_json::to_string(&req).unwrap();
1436        assert!(json.contains("api.example.com"));
1437        assert!(json.contains("10.0.0.1:8080"));
1438    }
1439
1440    #[test]
1441    fn test_update_site_request_default() {
1442        let req = UpdateSiteRequest::default();
1443        assert!(req.upstreams.is_none());
1444        assert!(req.waf.is_none());
1445        assert!(req.rate_limit.is_none());
1446        assert!(req.access_list.is_none());
1447    }
1448
1449    #[test]
1450    fn test_site_detail_response_serialization() {
1451        let response = SiteDetailResponse {
1452            hostname: "api.example.com".to_string(),
1453            upstreams: vec!["10.0.0.1:8080".to_string()],
1454            tls_enabled: false,
1455            waf: Some(SiteWafResponse {
1456                enabled: true,
1457                threshold: 70,
1458                rule_overrides: HashMap::new(),
1459            }),
1460            rate_limit: Some(RateLimitResponse {
1461                requests_per_second: 100,
1462                burst: 200,
1463            }),
1464            access_list: None,
1465            shadow_mirror: None,
1466        };
1467
1468        let json = serde_json::to_string(&response).unwrap();
1469        assert!(json.contains("api.example.com"));
1470        assert!(json.contains("\"threshold\":70"));
1471    }
1472
1473    fn test_rule(id: u32) -> StoredRule {
1474        StoredRule {
1475            rule: WafRule {
1476                id,
1477                description: format!("rule-{}", id),
1478                contributing_score: None,
1479                risk: None,
1480                blocking: None,
1481                matches: vec![MatchCondition {
1482                    kind: "match".to_string(),
1483                    match_value: Some(MatchValue::Str("test".to_string())),
1484                    op: None,
1485                    field: None,
1486                    direction: None,
1487                    field_type: None,
1488                    name: None,
1489                    selector: None,
1490                    cleanup_after: None,
1491                    count: None,
1492                    timeframe: None,
1493                }],
1494            },
1495            meta: RuleMetadata::default(),
1496        }
1497    }
1498
1499    #[test]
1500    fn test_recover_rules_from_wal_overwrites_rules_file() {
1501        let dir = tempfile::tempdir().unwrap();
1502        let rules_path = dir.path().join("rules.json");
1503        let wal_path = rules_path.with_extension("wal");
1504
1505        let old_rules = vec![test_rule(1)];
1506        let new_rules = vec![test_rule(42)];
1507
1508        fs::write(&rules_path, serde_json::to_vec_pretty(&old_rules).unwrap()).unwrap();
1509
1510        let wal_entry = serde_json::json!({
1511            "timestamp_ms": 1,
1512            "type": "rules_update",
1513            "rules": new_rules,
1514        });
1515        fs::write(&wal_path, format!("{}\n", wal_entry)).unwrap();
1516
1517        let recovered = recover_rules_from_wal(&rules_path).unwrap();
1518        assert!(recovered);
1519
1520        let persisted: Vec<StoredRule> =
1521            serde_json::from_slice(&fs::read(&rules_path).unwrap()).unwrap();
1522        assert_eq!(persisted.len(), 1);
1523        assert_eq!(persisted[0].rule.id, 42);
1524
1525        let wal_contents = fs::read_to_string(&wal_path).unwrap();
1526        assert!(wal_contents.trim().is_empty());
1527    }
1528}