diff --git a/src/stats/beobachten.rs b/src/stats/beobachten.rs new file mode 100644 index 0000000..2e87fcc --- /dev/null +++ b/src/stats/beobachten.rs @@ -0,0 +1,117 @@ +//! 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); + +#[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); + let entry = guard.entries.entry(key).or_insert(BeobachtenEntry { + tries: 0, + last_seen: now, + }); + entry.tries = entry.tries.saturating_add(1); + entry.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 mut guard = self.inner.lock(); + Self::cleanup(&mut guard, now, ttl); + guard.last_cleanup = Some(now); + + let mut grouped = BTreeMap::>::new(); + for ((class, ip), entry) in &guard.entries { + grouped + .entry(class.clone()) + .or_default() + .push((*ip, entry.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 + }); + } +}