synapse_pingora/shadow/
config.rs1use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7use std::time::Duration;
8
9#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
11pub struct ShadowMirrorConfig {
12 #[serde(default)]
14 pub enabled: bool,
15
16 #[serde(default = "default_min_risk_score")]
18 pub min_risk_score: f32,
19
20 #[serde(default = "default_max_risk_score")]
22 pub max_risk_score: f32,
23
24 #[serde(default)]
26 pub honeypot_urls: Vec<String>,
27
28 #[serde(default = "default_sampling_rate")]
30 pub sampling_rate: f32,
31
32 #[serde(default = "default_per_ip_rate_limit")]
34 pub per_ip_rate_limit: u32,
35
36 #[serde(default = "default_timeout_secs")]
38 pub timeout_secs: u64,
39
40 #[serde(default)]
43 pub hmac_secret: Option<String>,
44
45 #[serde(default = "default_include_body")]
47 pub include_body: bool,
48
49 #[serde(default = "default_max_body_size")]
51 pub max_body_size: usize,
52
53 #[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 }
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 pub fn timeout(&self) -> Duration {
118 Duration::from_secs(self.timeout_secs)
119 }
120
121 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 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#[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}