Skip to main content

synapse_pingora/shadow/
config.rs

1//! Shadow mirroring configuration.
2//!
3//! Defines the `ShadowMirrorConfig` struct for per-site shadow mirroring settings.
4
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7use std::time::Duration;
8
9/// Configuration for shadow mirroring suspicious traffic to honeypots.
10#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
11pub struct ShadowMirrorConfig {
12    /// Enable shadow mirroring for this site
13    #[serde(default)]
14    pub enabled: bool,
15
16    /// Minimum risk score to trigger mirroring (default: 40)
17    #[serde(default = "default_min_risk_score")]
18    pub min_risk_score: f32,
19
20    /// Maximum risk score - above this we block, no need to mirror (default: 70)
21    #[serde(default = "default_max_risk_score")]
22    pub max_risk_score: f32,
23
24    /// Honeypot/canary endpoint URLs (load balanced)
25    #[serde(default)]
26    pub honeypot_urls: Vec<String>,
27
28    /// Sampling rate 0.0-1.0 (default: 1.0 = 100%)
29    #[serde(default = "default_sampling_rate")]
30    pub sampling_rate: f32,
31
32    /// Per-IP rate limit (requests per minute)
33    #[serde(default = "default_per_ip_rate_limit")]
34    pub per_ip_rate_limit: u32,
35
36    /// Request timeout for honeypot delivery in seconds (default: 5s)
37    #[serde(default = "default_timeout_secs")]
38    pub timeout_secs: u64,
39
40    /// HMAC secret for payload signing (prevents honeypot spoofing)
41    /// Load from environment variable for security
42    #[serde(default)]
43    pub hmac_secret: Option<String>,
44
45    /// Include request body in mirror (default: true)
46    #[serde(default = "default_include_body")]
47    pub include_body: bool,
48
49    /// Maximum body size to mirror in bytes (default: 1MB)
50    #[serde(default = "default_max_body_size")]
51    pub max_body_size: usize,
52
53    /// Additional headers to include in mirror
54    #[serde(default = "default_include_headers")]
55    pub include_headers: Vec<String>,
56}
57
58fn default_min_risk_score() -> f32 {
59    40.0
60}
61
62fn default_max_risk_score() -> f32 {
63    70.0
64}
65
66fn default_sampling_rate() -> f32 {
67    1.0
68}
69
70fn default_per_ip_rate_limit() -> u32 {
71    10
72}
73
74fn default_timeout_secs() -> u64 {
75    5
76}
77
78fn default_include_body() -> bool {
79    true
80}
81
82fn default_max_body_size() -> usize {
83    1024 * 1024 // 1MB
84}
85
86fn default_include_headers() -> Vec<String> {
87    vec![
88        "User-Agent".to_string(),
89        "Referer".to_string(),
90        "Origin".to_string(),
91        "Accept".to_string(),
92        "Accept-Language".to_string(),
93        "Accept-Encoding".to_string(),
94    ]
95}
96
97impl Default for ShadowMirrorConfig {
98    fn default() -> Self {
99        Self {
100            enabled: false,
101            min_risk_score: default_min_risk_score(),
102            max_risk_score: default_max_risk_score(),
103            honeypot_urls: Vec::new(),
104            sampling_rate: default_sampling_rate(),
105            per_ip_rate_limit: default_per_ip_rate_limit(),
106            timeout_secs: default_timeout_secs(),
107            hmac_secret: None,
108            include_body: default_include_body(),
109            max_body_size: default_max_body_size(),
110            include_headers: default_include_headers(),
111        }
112    }
113}
114
115impl ShadowMirrorConfig {
116    /// Returns the timeout as a Duration.
117    pub fn timeout(&self) -> Duration {
118        Duration::from_secs(self.timeout_secs)
119    }
120
121    /// Validates the configuration.
122    pub fn validate(&self) -> Result<(), ShadowConfigError> {
123        if self.enabled && self.honeypot_urls.is_empty() {
124            return Err(ShadowConfigError::NoHoneypotUrls);
125        }
126
127        if self.min_risk_score >= self.max_risk_score {
128            return Err(ShadowConfigError::InvalidRiskRange {
129                min: self.min_risk_score,
130                max: self.max_risk_score,
131            });
132        }
133
134        if self.sampling_rate < 0.0 || self.sampling_rate > 1.0 {
135            return Err(ShadowConfigError::InvalidSamplingRate(self.sampling_rate));
136        }
137
138        // Validate honeypot URLs
139        for url in &self.honeypot_urls {
140            if !url.starts_with("http://") && !url.starts_with("https://") {
141                return Err(ShadowConfigError::InvalidHoneypotUrl(url.clone()));
142            }
143        }
144
145        Ok(())
146    }
147}
148
149/// Errors from shadow mirror configuration validation.
150#[derive(Debug, thiserror::Error)]
151pub enum ShadowConfigError {
152    #[error("shadow mirroring enabled but no honeypot URLs configured")]
153    NoHoneypotUrls,
154
155    #[error("invalid risk score range: min ({min}) must be less than max ({max})")]
156    InvalidRiskRange { min: f32, max: f32 },
157
158    #[error("invalid sampling rate: {0} (must be 0.0-1.0)")]
159    InvalidSamplingRate(f32),
160
161    #[error("invalid honeypot URL: {0} (must start with http:// or https://)")]
162    InvalidHoneypotUrl(String),
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    #[test]
170    fn test_default_config() {
171        let config = ShadowMirrorConfig::default();
172        assert!(!config.enabled);
173        assert_eq!(config.min_risk_score, 40.0);
174        assert_eq!(config.max_risk_score, 70.0);
175        assert_eq!(config.sampling_rate, 1.0);
176        assert_eq!(config.per_ip_rate_limit, 10);
177        assert!(config.include_body);
178    }
179
180    #[test]
181    fn test_validate_disabled_without_urls() {
182        let config = ShadowMirrorConfig::default();
183        assert!(config.validate().is_ok());
184    }
185
186    #[test]
187    fn test_validate_enabled_without_urls() {
188        let mut config = ShadowMirrorConfig::default();
189        config.enabled = true;
190        assert!(matches!(
191            config.validate(),
192            Err(ShadowConfigError::NoHoneypotUrls)
193        ));
194    }
195
196    #[test]
197    fn test_validate_invalid_risk_range() {
198        let mut config = ShadowMirrorConfig::default();
199        config.min_risk_score = 70.0;
200        config.max_risk_score = 40.0;
201        assert!(matches!(
202            config.validate(),
203            Err(ShadowConfigError::InvalidRiskRange { .. })
204        ));
205    }
206
207    #[test]
208    fn test_validate_invalid_sampling_rate() {
209        let mut config = ShadowMirrorConfig::default();
210        config.sampling_rate = 1.5;
211        assert!(matches!(
212            config.validate(),
213            Err(ShadowConfigError::InvalidSamplingRate(_))
214        ));
215    }
216
217    #[test]
218    fn test_validate_invalid_honeypot_url() {
219        let mut config = ShadowMirrorConfig::default();
220        config.enabled = true;
221        config.honeypot_urls = vec!["not-a-url".to_string()];
222        assert!(matches!(
223            config.validate(),
224            Err(ShadowConfigError::InvalidHoneypotUrl(_))
225        ));
226    }
227
228    #[test]
229    fn test_validate_valid_config() {
230        let mut config = ShadowMirrorConfig::default();
231        config.enabled = true;
232        config.honeypot_urls = vec!["https://honeypot.example.com/mirror".to_string()];
233        assert!(config.validate().is_ok());
234    }
235
236    #[test]
237    fn test_timeout_duration() {
238        let config = ShadowMirrorConfig::default();
239        assert_eq!(config.timeout(), Duration::from_secs(5));
240    }
241}