use reqwest::Client;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use tracing::{debug, warn};
use super::protocol::MirrorPayload;
pub struct ShadowMirrorClient {
http_client: Client,
hmac_secret: Option<String>,
successes: AtomicU64,
failures: AtomicU64,
bytes_sent: AtomicU64,
}
impl ShadowMirrorClient {
pub fn new(hmac_secret: Option<String>, timeout: Duration) -> Result<Self, ShadowMirrorError> {
let http_client = Client::builder()
.timeout(timeout)
.pool_max_idle_per_host(10)
.pool_idle_timeout(Duration::from_secs(30))
.connect_timeout(Duration::from_secs(5))
.build()
.map_err(|e| ShadowMirrorError::ClientCreation(e.to_string()))?;
Ok(Self {
http_client,
hmac_secret,
successes: AtomicU64::new(0),
failures: AtomicU64::new(0),
bytes_sent: AtomicU64::new(0),
})
}
pub async fn send_to_honeypot(
&self,
urls: &[String],
payload: MirrorPayload,
timeout: Duration,
) -> Result<(), ShadowMirrorError> {
if urls.is_empty() {
return Err(ShadowMirrorError::NoHoneypotUrls);
}
let url_index = self.select_url_index(&payload.request_id, urls.len());
let url = &urls[url_index];
let json = payload
.to_json_bytes()
.map_err(ShadowMirrorError::Serialization)?;
let json_len = json.len() as u64;
let mut request = self
.http_client
.post(url)
.timeout(timeout)
.header("Content-Type", "application/json")
.header("X-Shadow-Mirror", "1")
.header("X-Request-ID", &payload.request_id)
.header("X-Protocol-Version", &payload.protocol_version);
if let Some(ref secret) = self.hmac_secret {
let signature = self.compute_hmac(secret, &json);
request = request.header("X-Signature", signature);
}
debug!(
url = %url,
request_id = %payload.request_id,
payload_size = json_len,
"Sending shadow mirror payload"
);
let result = request.body(json).send().await;
match result {
Ok(response) => {
if response.status().is_success() {
self.successes.fetch_add(1, Ordering::Relaxed);
self.bytes_sent.fetch_add(json_len, Ordering::Relaxed);
debug!(
url = %url,
request_id = %payload.request_id,
status = %response.status(),
"Shadow mirror delivery succeeded"
);
Ok(())
} else {
self.failures.fetch_add(1, Ordering::Relaxed);
warn!(
url = %url,
request_id = %payload.request_id,
status = %response.status(),
"Shadow mirror delivery failed with non-success status"
);
Err(ShadowMirrorError::HttpError {
status: response.status().as_u16(),
url: url.clone(),
})
}
}
Err(e) => {
self.failures.fetch_add(1, Ordering::Relaxed);
warn!(
url = %url,
request_id = %payload.request_id,
error = %e,
"Shadow mirror delivery failed"
);
Err(ShadowMirrorError::RequestFailed {
url: url.clone(),
reason: e.to_string(),
})
}
}
}
fn select_url_index(&self, request_id: &str, url_count: usize) -> usize {
let mut hash: u64 = 14695981039346656037; for byte in request_id.bytes() {
hash ^= byte as u64;
hash = hash.wrapping_mul(1099511628211); }
(hash as usize) % url_count
}
fn compute_hmac(&self, secret: &str, data: &[u8]) -> String {
use hmac::{Hmac, Mac};
use sha2::Sha256;
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
.expect("HMAC can accept any key length");
mac.update(data);
hex::encode(mac.finalize().into_bytes())
}
pub fn stats(&self) -> ShadowClientStats {
ShadowClientStats {
successes: self.successes.load(Ordering::Relaxed),
failures: self.failures.load(Ordering::Relaxed),
bytes_sent: self.bytes_sent.load(Ordering::Relaxed),
}
}
pub fn reset_stats(&self) {
self.successes.store(0, Ordering::Relaxed);
self.failures.store(0, Ordering::Relaxed);
self.bytes_sent.store(0, Ordering::Relaxed);
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ShadowClientStats {
pub successes: u64,
pub failures: u64,
pub bytes_sent: u64,
}
impl ShadowClientStats {
pub fn success_rate(&self) -> f64 {
let total = self.successes + self.failures;
if total == 0 {
100.0
} else {
(self.successes as f64 / total as f64) * 100.0
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum ShadowMirrorError {
#[error("failed to create HTTP client: {0}")]
ClientCreation(String),
#[error("no honeypot URLs configured")]
NoHoneypotUrls,
#[error("failed to serialize payload: {0}")]
Serialization(#[from] serde_json::Error),
#[error("HTTP request to {url} failed with status {status}")]
HttpError { status: u16, url: String },
#[error("request to {url} failed: {reason}")]
RequestFailed { url: String, reason: String },
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_payload() -> MirrorPayload {
MirrorPayload::new(
"test-request-id".to_string(),
"192.168.1.100".to_string(),
55.0,
"POST".to_string(),
"/api/login".to_string(),
"example.com".to_string(),
"sensor-01".to_string(),
)
}
#[test]
fn test_client_creation() {
let client = ShadowMirrorClient::new(None, Duration::from_secs(5))
.expect("client creation should succeed");
let stats = client.stats();
assert_eq!(stats.successes, 0);
assert_eq!(stats.failures, 0);
}
#[test]
fn test_client_with_hmac() {
let client =
ShadowMirrorClient::new(Some("my-secret-key".to_string()), Duration::from_secs(5))
.expect("client creation should succeed");
assert!(client.hmac_secret.is_some());
}
#[test]
fn test_hmac_computation() {
let client =
ShadowMirrorClient::new(Some("test-secret".to_string()), Duration::from_secs(5))
.expect("client creation should succeed");
let data = b"test payload data";
let signature = client.compute_hmac("test-secret", data);
assert_eq!(signature.len(), 64);
let signature2 = client.compute_hmac("test-secret", data);
assert_eq!(signature, signature2);
}
#[test]
fn test_url_selection_distribution() {
let client = ShadowMirrorClient::new(None, Duration::from_secs(5))
.expect("client creation should succeed");
let urls = 3;
let mut counts = [0u32; 3];
for i in 0..100 {
let request_id = format!("request-{}", i);
let index = client.select_url_index(&request_id, urls);
counts[index] += 1;
}
for (i, count) in counts.iter().enumerate() {
assert!(*count > 0, "URL {} got no traffic", i);
}
}
#[test]
fn test_url_selection_consistent() {
let client = ShadowMirrorClient::new(None, Duration::from_secs(5))
.expect("client creation should succeed");
let request_id = "consistent-request-id";
let first = client.select_url_index(request_id, 5);
let second = client.select_url_index(request_id, 5);
assert_eq!(first, second);
}
#[test]
fn test_stats_reset() {
let client = ShadowMirrorClient::new(None, Duration::from_secs(5))
.expect("client creation should succeed");
client.successes.store(10, Ordering::Relaxed);
client.failures.store(5, Ordering::Relaxed);
let stats = client.stats();
assert_eq!(stats.successes, 10);
assert_eq!(stats.failures, 5);
client.reset_stats();
let stats = client.stats();
assert_eq!(stats.successes, 0);
assert_eq!(stats.failures, 0);
}
#[test]
fn test_success_rate() {
let stats = ShadowClientStats {
successes: 90,
failures: 10,
bytes_sent: 1000,
};
assert!((stats.success_rate() - 90.0).abs() < 0.01);
let stats = ShadowClientStats {
successes: 0,
failures: 0,
bytes_sent: 0,
};
assert!((stats.success_rate() - 100.0).abs() < 0.01);
}
#[tokio::test]
async fn test_send_empty_urls() {
let client = ShadowMirrorClient::new(None, Duration::from_secs(1))
.expect("client creation should succeed");
let payload = create_test_payload();
let result = client
.send_to_honeypot(&[], payload, Duration::from_secs(1))
.await;
assert!(matches!(result, Err(ShadowMirrorError::NoHoneypotUrls)));
}
}