Skip to main content

synapse_pingora/shadow/
client.rs

1//! Async HTTP client for shadow mirror delivery to honeypots.
2//!
3//! Uses fire-and-forget pattern to avoid impacting production latency.
4
5use reqwest::Client;
6use std::sync::atomic::{AtomicU64, Ordering};
7use std::time::Duration;
8use tracing::{debug, warn};
9
10use super::protocol::MirrorPayload;
11
12/// Async HTTP client for delivering shadow mirror payloads to honeypots.
13///
14/// Uses connection pooling and configurable timeouts for efficient delivery.
15pub struct ShadowMirrorClient {
16    /// Underlying HTTP client with connection pooling
17    http_client: Client,
18    /// HMAC secret for payload signing (optional)
19    hmac_secret: Option<String>,
20    /// Successful deliveries
21    successes: AtomicU64,
22    /// Failed deliveries
23    failures: AtomicU64,
24    /// Total bytes sent
25    bytes_sent: AtomicU64,
26}
27
28impl ShadowMirrorClient {
29    /// Creates a new shadow mirror client.
30    ///
31    /// # Arguments
32    /// * `hmac_secret` - Optional secret for HMAC-SHA256 payload signing
33    /// * `timeout` - Request timeout for honeypot delivery
34    ///
35    /// # Errors
36    /// Returns `ShadowMirrorError::ClientCreation` if the HTTP client cannot be built.
37    pub fn new(hmac_secret: Option<String>, timeout: Duration) -> Result<Self, ShadowMirrorError> {
38        let http_client = Client::builder()
39            .timeout(timeout)
40            .pool_max_idle_per_host(10)
41            .pool_idle_timeout(Duration::from_secs(30))
42            .connect_timeout(Duration::from_secs(5))
43            .build()
44            .map_err(|e| ShadowMirrorError::ClientCreation(e.to_string()))?;
45
46        Ok(Self {
47            http_client,
48            hmac_secret,
49            successes: AtomicU64::new(0),
50            failures: AtomicU64::new(0),
51            bytes_sent: AtomicU64::new(0),
52        })
53    }
54
55    /// Sends a payload to one of the honeypot URLs.
56    ///
57    /// Uses round-robin URL selection based on request ID for load distribution.
58    pub async fn send_to_honeypot(
59        &self,
60        urls: &[String],
61        payload: MirrorPayload,
62        timeout: Duration,
63    ) -> Result<(), ShadowMirrorError> {
64        if urls.is_empty() {
65            return Err(ShadowMirrorError::NoHoneypotUrls);
66        }
67
68        // Round-robin URL selection based on request ID hash
69        let url_index = self.select_url_index(&payload.request_id, urls.len());
70        let url = &urls[url_index];
71
72        let json = payload
73            .to_json_bytes()
74            .map_err(ShadowMirrorError::Serialization)?;
75        let json_len = json.len() as u64;
76
77        let mut request = self
78            .http_client
79            .post(url)
80            .timeout(timeout)
81            .header("Content-Type", "application/json")
82            .header("X-Shadow-Mirror", "1")
83            .header("X-Request-ID", &payload.request_id)
84            .header("X-Protocol-Version", &payload.protocol_version);
85
86        // Add HMAC signature if configured
87        if let Some(ref secret) = self.hmac_secret {
88            let signature = self.compute_hmac(secret, &json);
89            request = request.header("X-Signature", signature);
90        }
91
92        debug!(
93            url = %url,
94            request_id = %payload.request_id,
95            payload_size = json_len,
96            "Sending shadow mirror payload"
97        );
98
99        let result = request.body(json).send().await;
100
101        match result {
102            Ok(response) => {
103                if response.status().is_success() {
104                    self.successes.fetch_add(1, Ordering::Relaxed);
105                    self.bytes_sent.fetch_add(json_len, Ordering::Relaxed);
106                    debug!(
107                        url = %url,
108                        request_id = %payload.request_id,
109                        status = %response.status(),
110                        "Shadow mirror delivery succeeded"
111                    );
112                    Ok(())
113                } else {
114                    self.failures.fetch_add(1, Ordering::Relaxed);
115                    warn!(
116                        url = %url,
117                        request_id = %payload.request_id,
118                        status = %response.status(),
119                        "Shadow mirror delivery failed with non-success status"
120                    );
121                    Err(ShadowMirrorError::HttpError {
122                        status: response.status().as_u16(),
123                        url: url.clone(),
124                    })
125                }
126            }
127            Err(e) => {
128                self.failures.fetch_add(1, Ordering::Relaxed);
129                warn!(
130                    url = %url,
131                    request_id = %payload.request_id,
132                    error = %e,
133                    "Shadow mirror delivery failed"
134                );
135                Err(ShadowMirrorError::RequestFailed {
136                    url: url.clone(),
137                    reason: e.to_string(),
138                })
139            }
140        }
141    }
142
143    /// Selects a URL index using simple hash-based distribution.
144    fn select_url_index(&self, request_id: &str, url_count: usize) -> usize {
145        // Use a simple FNV-1a hash for better distribution
146        let mut hash: u64 = 14695981039346656037; // FNV offset basis
147        for byte in request_id.bytes() {
148            hash ^= byte as u64;
149            hash = hash.wrapping_mul(1099511628211); // FNV prime
150        }
151
152        (hash as usize) % url_count
153    }
154
155    /// Computes HMAC-SHA256 signature for the payload.
156    fn compute_hmac(&self, secret: &str, data: &[u8]) -> String {
157        use hmac::{Hmac, Mac};
158        use sha2::Sha256;
159
160        let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
161            .expect("HMAC can accept any key length");
162        mac.update(data);
163        hex::encode(mac.finalize().into_bytes())
164    }
165
166    /// Returns statistics about the client.
167    pub fn stats(&self) -> ShadowClientStats {
168        ShadowClientStats {
169            successes: self.successes.load(Ordering::Relaxed),
170            failures: self.failures.load(Ordering::Relaxed),
171            bytes_sent: self.bytes_sent.load(Ordering::Relaxed),
172        }
173    }
174
175    /// Resets statistics.
176    pub fn reset_stats(&self) {
177        self.successes.store(0, Ordering::Relaxed);
178        self.failures.store(0, Ordering::Relaxed);
179        self.bytes_sent.store(0, Ordering::Relaxed);
180    }
181}
182
183/// Shadow mirror client statistics.
184#[derive(Debug, Clone, serde::Serialize)]
185pub struct ShadowClientStats {
186    /// Number of successful deliveries
187    pub successes: u64,
188    /// Number of failed deliveries
189    pub failures: u64,
190    /// Total bytes sent to honeypots
191    pub bytes_sent: u64,
192}
193
194impl ShadowClientStats {
195    /// Returns the success rate as a percentage.
196    pub fn success_rate(&self) -> f64 {
197        let total = self.successes + self.failures;
198        if total == 0 {
199            100.0
200        } else {
201            (self.successes as f64 / total as f64) * 100.0
202        }
203    }
204}
205
206/// Errors that can occur during shadow mirror operations.
207#[derive(Debug, thiserror::Error)]
208pub enum ShadowMirrorError {
209    #[error("failed to create HTTP client: {0}")]
210    ClientCreation(String),
211
212    #[error("no honeypot URLs configured")]
213    NoHoneypotUrls,
214
215    #[error("failed to serialize payload: {0}")]
216    Serialization(#[from] serde_json::Error),
217
218    #[error("HTTP request to {url} failed with status {status}")]
219    HttpError { status: u16, url: String },
220
221    #[error("request to {url} failed: {reason}")]
222    RequestFailed { url: String, reason: String },
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    fn create_test_payload() -> MirrorPayload {
230        MirrorPayload::new(
231            "test-request-id".to_string(),
232            "192.168.1.100".to_string(),
233            55.0,
234            "POST".to_string(),
235            "/api/login".to_string(),
236            "example.com".to_string(),
237            "sensor-01".to_string(),
238        )
239    }
240
241    #[test]
242    fn test_client_creation() {
243        let client = ShadowMirrorClient::new(None, Duration::from_secs(5))
244            .expect("client creation should succeed");
245        let stats = client.stats();
246        assert_eq!(stats.successes, 0);
247        assert_eq!(stats.failures, 0);
248    }
249
250    #[test]
251    fn test_client_with_hmac() {
252        let client =
253            ShadowMirrorClient::new(Some("my-secret-key".to_string()), Duration::from_secs(5))
254                .expect("client creation should succeed");
255        assert!(client.hmac_secret.is_some());
256    }
257
258    #[test]
259    fn test_hmac_computation() {
260        let client =
261            ShadowMirrorClient::new(Some("test-secret".to_string()), Duration::from_secs(5))
262                .expect("client creation should succeed");
263
264        let data = b"test payload data";
265        let signature = client.compute_hmac("test-secret", data);
266
267        // HMAC-SHA256 produces 64 hex characters
268        assert_eq!(signature.len(), 64);
269        // Should be consistent
270        let signature2 = client.compute_hmac("test-secret", data);
271        assert_eq!(signature, signature2);
272    }
273
274    #[test]
275    fn test_url_selection_distribution() {
276        let client = ShadowMirrorClient::new(None, Duration::from_secs(5))
277            .expect("client creation should succeed");
278        let urls = 3;
279
280        let mut counts = [0u32; 3];
281
282        // Test with various request IDs
283        for i in 0..100 {
284            let request_id = format!("request-{}", i);
285            let index = client.select_url_index(&request_id, urls);
286            counts[index] += 1;
287        }
288
289        // Each URL should get some traffic (basic distribution check)
290        for (i, count) in counts.iter().enumerate() {
291            assert!(*count > 0, "URL {} got no traffic", i);
292        }
293    }
294
295    #[test]
296    fn test_url_selection_consistent() {
297        let client = ShadowMirrorClient::new(None, Duration::from_secs(5))
298            .expect("client creation should succeed");
299
300        // Same request ID should always select same URL
301        let request_id = "consistent-request-id";
302        let first = client.select_url_index(request_id, 5);
303        let second = client.select_url_index(request_id, 5);
304        assert_eq!(first, second);
305    }
306
307    #[test]
308    fn test_stats_reset() {
309        let client = ShadowMirrorClient::new(None, Duration::from_secs(5))
310            .expect("client creation should succeed");
311
312        // Manually increment counters for testing
313        client.successes.store(10, Ordering::Relaxed);
314        client.failures.store(5, Ordering::Relaxed);
315
316        let stats = client.stats();
317        assert_eq!(stats.successes, 10);
318        assert_eq!(stats.failures, 5);
319
320        client.reset_stats();
321
322        let stats = client.stats();
323        assert_eq!(stats.successes, 0);
324        assert_eq!(stats.failures, 0);
325    }
326
327    #[test]
328    fn test_success_rate() {
329        let stats = ShadowClientStats {
330            successes: 90,
331            failures: 10,
332            bytes_sent: 1000,
333        };
334        assert!((stats.success_rate() - 90.0).abs() < 0.01);
335
336        let stats = ShadowClientStats {
337            successes: 0,
338            failures: 0,
339            bytes_sent: 0,
340        };
341        assert!((stats.success_rate() - 100.0).abs() < 0.01);
342    }
343
344    #[tokio::test]
345    async fn test_send_empty_urls() {
346        let client = ShadowMirrorClient::new(None, Duration::from_secs(1))
347            .expect("client creation should succeed");
348        let payload = create_test_payload();
349
350        let result = client
351            .send_to_honeypot(&[], payload, Duration::from_secs(1))
352            .await;
353        assert!(matches!(result, Err(ShadowMirrorError::NoHoneypotUrls)));
354    }
355}