//! Per-IP forensic buckets for scanner and handshake failure observation. use std::collections::{BTreeMap, HashMap}; use std::net::IpAddr; use std::time::{Duration, Instant}; use parking_lot::Mutex; const CLEANUP_INTERVAL: Duration = Duration::from_secs(30); const MAX_BEOBACHTEN_ENTRIES: usize = 65_536; #[derive(Default)] struct BeobachtenInner { entries: HashMap<(String, IpAddr), BeobachtenEntry>, last_cleanup: Option, } #[derive(Clone, Copy)] struct BeobachtenEntry { tries: u64, last_seen: Instant, } /// In-memory, TTL-scoped per-IP counters keyed by source class. pub struct BeobachtenStore { inner: Mutex, } impl Default for BeobachtenStore { fn default() -> Self { Self::new() } } impl BeobachtenStore { pub fn new() -> Self { Self { inner: Mutex::new(BeobachtenInner::default()), } } pub fn record(&self, class: &str, ip: IpAddr, ttl: Duration) { if class.is_empty() || ttl.is_zero() { return; } let now = Instant::now(); let mut guard = self.inner.lock(); Self::cleanup_if_needed(&mut guard, now, ttl); let key = (class.to_string(), ip); if let Some(entry) = guard.entries.get_mut(&key) { entry.tries = entry.tries.saturating_add(1); entry.last_seen = now; return; } if guard.entries.len() >= MAX_BEOBACHTEN_ENTRIES { return; } guard.entries.insert( key, BeobachtenEntry { tries: 1, last_seen: now, }, ); } pub fn snapshot_text(&self, ttl: Duration) -> String { if ttl.is_zero() { return "beobachten disabled\n".to_string(); } let now = Instant::now(); let entries = { let mut guard = self.inner.lock(); Self::cleanup(&mut guard, now, ttl); guard.last_cleanup = Some(now); guard .entries .iter() .map(|((class, ip), entry)| (class.clone(), *ip, entry.tries)) .collect::>() }; let mut grouped = BTreeMap::>::new(); for (class, ip, tries) in entries { grouped.entry(class).or_default().push((ip, tries)); } if grouped.is_empty() { return "empty\n".to_string(); } let mut out = String::with_capacity(grouped.len() * 64); for (class, entries) in &mut grouped { out.push('['); out.push_str(class); out.push_str("]\n"); entries.sort_by(|(ip_a, tries_a), (ip_b, tries_b)| { tries_b .cmp(tries_a) .then_with(|| ip_a.to_string().cmp(&ip_b.to_string())) }); for (ip, tries) in entries { out.push_str(&format!("{ip}-{tries}\n")); } } out } fn cleanup_if_needed(inner: &mut BeobachtenInner, now: Instant, ttl: Duration) { let should_cleanup = match inner.last_cleanup { Some(last) => now.saturating_duration_since(last) >= CLEANUP_INTERVAL, None => true, }; if should_cleanup { Self::cleanup(inner, now, ttl); inner.last_cleanup = Some(now); } } fn cleanup(inner: &mut BeobachtenInner, now: Instant, ttl: Duration) { inner .entries .retain(|_, entry| now.saturating_duration_since(entry.last_seen) <= ttl); } }