diff --git a/Cargo.lock b/Cargo.lock index 8779f8e..236ef66 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2448,6 +2448,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moka" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + [[package]] name = "ndarray" version = "0.16.1" @@ -2498,6 +2515,7 @@ dependencies = [ "macros", "maxminddb", "mime_guess", + "moka", "network-types", "notify", "parking_lot", @@ -3803,6 +3821,12 @@ dependencies = [ "windows", ] +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + [[package]] name = "tar" version = "0.4.44" diff --git a/net-guardia/Cargo.toml b/net-guardia/Cargo.toml index 65e2d2e..bb59083 100644 --- a/net-guardia/Cargo.toml +++ b/net-guardia/Cargo.toml @@ -58,6 +58,7 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus async-trait = "0.1" dashmap = "6" arc-swap = "1" +moka = { version = "0.12", features = ["sync"] } notify = { version = "7", default-features = false, features = ["macos_kqueue"] } # Utilities diff --git a/net-guardia/src/adapter/ebpf/dns_filter.rs b/net-guardia/src/adapter/ebpf/dns_filter.rs index 7ecbc9e..80f3245 100644 --- a/net-guardia/src/adapter/ebpf/dns_filter.rs +++ b/net-guardia/src/adapter/ebpf/dns_filter.rs @@ -1,8 +1,7 @@ use core::str; -use std::collections::HashSet; use common::model::dns_name::DnsName; -use parking_lot::RwLock; +use dashmap::DashSet; use crate::interface::port::dns_filter_api::DnsFilterPort; use crate::interface::port::dns_query_filter::DnsQueryFilter; @@ -10,30 +9,33 @@ use crate::model::error::Error; use crate::model::error::misc::MiscError; pub struct DnsFilter { - blacklist: RwLock>, + blacklist: DashSet, } impl DnsFilter { pub fn new() -> Self { Self { - blacklist: RwLock::new(HashSet::new()), + blacklist: DashSet::new(), } } pub fn add_domain(&self, domain: &str) -> Result<(), Error> { let name = domain_to_wire_format(domain)?; - self.blacklist.write().insert(name); + self.blacklist.insert(name); Ok(()) } pub fn remove_domain(&self, domain: &str) -> Result<(), Error> { let name = domain_to_wire_format(domain)?; - self.blacklist.write().remove(&name); + self.blacklist.remove(&name); Ok(()) } pub fn list_domains(&self) -> Vec { - self.blacklist.read().iter().filter_map(wire_format_to_domain).collect() + self.blacklist + .iter() + .filter_map(|entry| wire_format_to_domain(&entry)) + .collect() } /// Fast-path helper combining `parse_query_name` + `is_blacklisted` — used @@ -47,12 +49,11 @@ impl DnsFilter { /// Check if a DNS query name (in wire format) or any of its parent domains is blacklisted. pub fn is_blacklisted(&self, name: &DnsName, name_len: usize) -> bool { - let bl = self.blacklist.read(); - if bl.is_empty() { + if self.blacklist.is_empty() { return false; } // Check exact match - if bl.contains(name) { + if self.blacklist.contains(name) { return true; } // Check parent domains @@ -76,7 +77,7 @@ impl DnsFilter { let mut parent = DnsName::zeroed(); let remaining = name_len - offset; parent.data[..remaining.min(128)].copy_from_slice(&name.data[offset..offset + remaining.min(128)]); - if bl.contains(&parent) { + if self.blacklist.contains(&parent) { return true; } } diff --git a/net-guardia/src/adapter/ebpf/drop_monitor.rs b/net-guardia/src/adapter/ebpf/drop_monitor.rs index 03434c4..d8d8271 100644 --- a/net-guardia/src/adapter/ebpf/drop_monitor.rs +++ b/net-guardia/src/adapter/ebpf/drop_monitor.rs @@ -1,6 +1,7 @@ use std::mem; use std::net::Ipv6Addr; use std::sync::Arc; +use std::sync::atomic::Ordering; use std::time::Duration; use aya::maps::{MapData, RingBuf}; @@ -9,14 +10,13 @@ use tokio::time::interval; use common::define::drop_reason::*; use common::model::drop_event::DropEvent as RawDropEvent; -use parking_lot::Mutex; use crate::model::config::constants::DROP_CHANNEL_CAPACITY; -use crate::model::monitoring::drop_event::{DropCounters, DropEventMessage}; +use crate::model::monitoring::drop_event::{DropCounters, DropCountersAtomic, DropEventMessage}; pub struct DropMonitor { broadcast_tx: broadcast::Sender, - counters: Mutex, + counters: DropCountersAtomic, } impl DropMonitor { @@ -24,7 +24,7 @@ impl DropMonitor { let (tx, _) = broadcast::channel(DROP_CHANNEL_CAPACITY); Self { broadcast_tx: tx, - counters: Mutex::new(DropCounters::default()), + counters: DropCountersAtomic::default(), } } @@ -33,25 +33,24 @@ impl DropMonitor { } pub fn get_counters(&self) -> DropCounters { - self.counters.lock().clone() + self.counters.snapshot() } fn process_event(&self, raw: &RawDropEvent) { - // Update counters - { - let mut c = self.counters.lock(); - c.total += 1; - match raw.reason { - DROP_REASON_ACL_BLACKLIST => c.acl_blacklist += 1, - DROP_REASON_RATE_LIMIT_PKT => c.rate_limit_pkt += 1, - DROP_REASON_RATE_LIMIT_SYN => c.rate_limit_syn += 1, - DROP_REASON_RATE_LIMIT_UDP => c.rate_limit_udp += 1, - DROP_REASON_RATE_LIMIT_DNS => c.rate_limit_dns += 1, - DROP_REASON_PROTOCOL_FILTER => c.protocol_filter += 1, - DROP_REASON_DNS_BLACKLIST => c.dns_blacklist += 1, - DROP_REASON_GEO_BLOCK => c.geo_block += 1, - _ => {} - } + self.counters.total.fetch_add(1, Ordering::Relaxed); + let bucket = match raw.reason { + DROP_REASON_ACL_BLACKLIST => Some(&self.counters.acl_blacklist), + DROP_REASON_RATE_LIMIT_PKT => Some(&self.counters.rate_limit_pkt), + DROP_REASON_RATE_LIMIT_SYN => Some(&self.counters.rate_limit_syn), + DROP_REASON_RATE_LIMIT_UDP => Some(&self.counters.rate_limit_udp), + DROP_REASON_RATE_LIMIT_DNS => Some(&self.counters.rate_limit_dns), + DROP_REASON_PROTOCOL_FILTER => Some(&self.counters.protocol_filter), + DROP_REASON_DNS_BLACKLIST => Some(&self.counters.dns_blacklist), + DROP_REASON_GEO_BLOCK => Some(&self.counters.geo_block), + _ => None, + }; + if let Some(counter) = bucket { + counter.fetch_add(1, Ordering::Relaxed); } let reason_str = reason_to_str(raw.reason); diff --git a/net-guardia/src/adapter/ebpf/geo_block.rs b/net-guardia/src/adapter/ebpf/geo_block.rs index 553b142..ca08c90 100644 --- a/net-guardia/src/adapter/ebpf/geo_block.rs +++ b/net-guardia/src/adapter/ebpf/geo_block.rs @@ -1,6 +1,7 @@ use std::collections::{HashMap as StdHashMap, HashSet}; use std::sync::Arc; +use arc_swap::ArcSwap; use aya::Ebpf; use aya::maps::MapData; use aya::maps::lpm_trie::{Key, LpmTrie}; @@ -23,7 +24,7 @@ struct GeoIndex { pub struct GeoBlock { geo_block_v4: RwLock>>, geo_block_v6: RwLock>>, - blocked_countries: RwLock>, + blocked_countries: ArcSwap>, index: Arc, } @@ -43,7 +44,7 @@ impl GeoBlock { Ok(Self { geo_block_v4: RwLock::new(Some(v4_trie)), geo_block_v6: RwLock::new(Some(v6_trie)), - blocked_countries: RwLock::new(HashSet::new()), + blocked_countries: ArcSwap::from_pointee(HashSet::new()), index: Arc::new(index), }) } @@ -63,7 +64,7 @@ impl GeoBlock { Self { geo_block_v4: RwLock::new(None), geo_block_v6: RwLock::new(None), - blocked_countries: RwLock::new(HashSet::new()), + blocked_countries: ArcSwap::from_pointee(HashSet::new()), index: Arc::new(index), } } @@ -116,42 +117,44 @@ impl GeoBlock { /// Block multiple countries at once, rebuilding tries only once. pub fn block_countries(&self, country_codes: &[String]) -> Result { - { - let mut countries = self.blocked_countries.write(); + self.blocked_countries.rcu(|cur| { + let mut next: HashSet = (**cur).clone(); for code in country_codes { let upper = code.trim().to_uppercase(); if upper.len() == 2 && upper.chars().all(|c| c.is_ascii_alphabetic()) { - countries.insert(upper); + next.insert(upper); } } - } + next + }); self.rebuild_tries() } /// Unblock multiple countries at once, rebuilding tries only once. pub fn unblock_countries(&self, country_codes: &[String]) -> Result { - { - let mut countries = self.blocked_countries.write(); + self.blocked_countries.rcu(|cur| { + let mut next: HashSet = (**cur).clone(); for code in country_codes { - countries.remove(&code.trim().to_uppercase()); + next.remove(&code.trim().to_uppercase()); } - } + next + }); self.rebuild_tries() } pub fn get_blocked_countries(&self) -> Vec { - self.blocked_countries.read().iter().cloned().collect() + self.blocked_countries.load().iter().cloned().collect() } /// Rebuild LPM tries from pre-indexed data. Fast — no DB scan. fn rebuild_tries(&self) -> Result { - let countries = self.blocked_countries.read().clone(); + let countries = self.blocked_countries.load_full(); // Collect entries from index (no DB scan) let mut v4_entries: Vec<(Key, u8)> = Vec::new(); let mut v6_entries: Vec<(Key, u8)> = Vec::new(); - for code in &countries { + for code in countries.iter() { if let Some(prefixes) = self.index.v4.get(code) { for &(ip_be, prefix_len) in prefixes { v4_entries.push((Key::new(prefix_len, ip_be), 1u8)); diff --git a/net-guardia/src/adapter/http/health.rs b/net-guardia/src/adapter/http/health.rs index bd71560..27f35b5 100644 --- a/net-guardia/src/adapter/http/health.rs +++ b/net-guardia/src/adapter/http/health.rs @@ -22,11 +22,11 @@ async fn get_health_status(health: web::Data) -> impl Responder { } async fn get_ebpf_health(health: web::Data) -> impl Responder { - let ebpf = health.ebpf_health().read().clone(); + let ebpf = (**health.ebpf_health().load()).clone(); HttpResponse::Ok().json(ebpf) } async fn get_suricata_health(manager: web::Data) -> impl Responder { - let state = manager.health().read().clone(); + let state = (**manager.health().load()).clone(); HttpResponse::Ok().json(state) } diff --git a/net-guardia/src/adapter/telegram/mod.rs b/net-guardia/src/adapter/telegram/mod.rs index 6f05efd..537ea45 100644 --- a/net-guardia/src/adapter/telegram/mod.rs +++ b/net-guardia/src/adapter/telegram/mod.rs @@ -1,9 +1,9 @@ use std::sync::Arc; -use std::time::{Duration, Instant}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use async_trait::async_trait; use macros::log; -use parking_lot::Mutex; use reqwest::Client; use tokio::time::sleep; @@ -22,8 +22,10 @@ pub struct TelegramAdapter { notif: Arc, repo: Arc, secrets: Option>, - /// Rate limiter: (count, window_start) - rate_state: Mutex<(u32, Instant)>, + /// Packed rate-limit state: high 32 bits = window-start unix seconds, + /// low 32 bits = count consumed in this window. Updated via CAS so the + /// hot path stays lock-free. + rate_state: AtomicU64, } impl TelegramAdapter { @@ -42,7 +44,7 @@ impl TelegramAdapter { notif, repo, secrets, - rate_state: Mutex::new((0, Instant::now())), + rate_state: AtomicU64::new(0), }) } @@ -73,30 +75,60 @@ impl TelegramAdapter { } } - /// Check rate limit. Returns true if send is allowed. - fn check_rate_limit(&self) -> bool { - let mut state = self.rate_state.lock(); - let (count, window_start) = &mut *state; - - // Reset window if >60s has passed - if window_start.elapsed() > Duration::from_secs(60) { - *count = 0; - *window_start = Instant::now(); - } - - let max_per_min: u32 = self - .repo - .get_setting("telegram_max_messages_per_minute") + /// Read the configured per-window message cap from settings. + fn rate_limit_max_messages(&self) -> u32 { + self.repo + .get_setting("telegram_rate_limit_max_messages") .ok() .flatten() .and_then(|v| v.parse().ok()) - .unwrap_or(20); - if *count >= max_per_min { + .unwrap_or(20) + } + + /// Read the configured window length (seconds) from settings. + fn rate_limit_window_secs(&self) -> u32 { + self.repo + .get_setting("telegram_rate_limit_window_secs") + .ok() + .flatten() + .and_then(|v| v.parse().ok()) + .unwrap_or(60) + } + + /// Check rate limit. Returns true if send is allowed. + fn check_rate_limit(&self) -> bool { + let max_messages = self.rate_limit_max_messages(); + if max_messages == 0 { return false; } + let window_secs = self.rate_limit_window_secs().max(1); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as u32) + .unwrap_or(0); - *count += 1; - true + loop { + let cur = self.rate_state.load(Ordering::Acquire); + let count = cur as u32; + let window = (cur >> 32) as u32; + + let (next_count, next_window) = if now.saturating_sub(window) >= window_secs { + (1u32, now) + } else if count >= max_messages { + return false; + } else { + (count + 1, window) + }; + + let new = ((next_window as u64) << 32) | next_count as u64; + if self + .rate_state + .compare_exchange_weak(cur, new, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + { + return true; + } + } } /// Send a message via Telegram Bot API with retry on 429. @@ -200,15 +232,9 @@ impl AlertNotifier for TelegramAdapter { }; if !self.check_rate_limit() { - let max_per_min: u32 = self - .repo - .get_setting("telegram_max_messages_per_minute") - .ok() - .flatten() - .and_then(|v| v.parse().ok()) - .unwrap_or(20); log!(SystemLog::TelegramLocalRateLimitDropped( - max_per_min, + self.rate_limit_max_messages(), + self.rate_limit_window_secs(), payload.source_ip.clone(), )); return Ok(()); diff --git a/net-guardia/src/core/config_service.rs b/net-guardia/src/core/config_service.rs index bfefd56..33d1ee2 100644 --- a/net-guardia/src/core/config_service.rs +++ b/net-guardia/src/core/config_service.rs @@ -56,7 +56,10 @@ const SETTINGS_MAP: &[(&str, &[&str])] = &[ ("misc", &["geoip_db_name"]), ("soar", &["soar_max_auto_block_cap", "soar_max_ttl_secs"]), ("ml", &["ml_drift_window_secs"]), - ("telegram", &["telegram_max_messages_per_minute"]), + ( + "telegram", + &["telegram_rate_limit_max_messages", "telegram_rate_limit_window_secs"], + ), ("dns", &["dns_max_domains_per_request"]), ("smtp", &["smtp_host", "smtp_port", "smtp_username", "smtp_recipient"]), ]; @@ -129,7 +132,8 @@ impl ConfigService { "ml_drift_window_secs": get("ml_drift_window_secs"), }, "telegram": { - "telegram_max_messages_per_minute": get("telegram_max_messages_per_minute"), + "telegram_rate_limit_max_messages": get("telegram_rate_limit_max_messages"), + "telegram_rate_limit_window_secs": get("telegram_rate_limit_window_secs"), }, "dns": { "dns_max_domains_per_request": get("dns_max_domains_per_request"), diff --git a/net-guardia/src/core/soar/actions.rs b/net-guardia/src/core/soar/actions.rs index 452996f..815b1d0 100644 --- a/net-guardia/src/core/soar/actions.rs +++ b/net-guardia/src/core/soar/actions.rs @@ -41,7 +41,7 @@ impl SoarEngine { } // Check admin whitelist - if self.admin_whitelist.read().contains(&event.source_ip) { + if self.admin_whitelist.load().contains(&event.source_ip) { log!(SoarLog::WhitelistSkipped( event.source_ip.clone(), playbook.name.clone() @@ -465,7 +465,7 @@ impl SoarEngine { /// Only fires when source_ip is present. pub(super) async fn execute_fallback(&self, event: &ThreatDetectedEvent) -> Result<(), Error> { // Check admin whitelist — never block admin IPs even in fallback - if self.admin_whitelist.read().contains(&event.source_ip) { + if self.admin_whitelist.load().contains(&event.source_ip) { log!(SoarLog::WhitelistSkipped( event.source_ip.clone(), "fallback".to_string() diff --git a/net-guardia/src/core/soar/engine.rs b/net-guardia/src/core/soar/engine.rs index 25d28ad..acd620f 100644 --- a/net-guardia/src/core/soar/engine.rs +++ b/net-guardia/src/core/soar/engine.rs @@ -3,10 +3,10 @@ use std::sync::Arc; use std::sync::atomic::{AtomicU8, AtomicU32, Ordering}; use std::time::Instant; +use arc_swap::ArcSwap; use chrono::{NaiveDateTime, Utc}; use dashmap::DashMap; use macros::log; -use parking_lot::RwLock; use serde_json::Value; use tokio::sync::broadcast::error::RecvError; use tokio::sync::{Mutex as TokioMutex, broadcast}; @@ -45,9 +45,9 @@ pub struct SoarEngine { pub(super) db: Arc, pub(super) access_control: Arc, /// In-memory cache of playbooks (loaded at startup, refreshed on change). - pub(super) playbooks: RwLock>, + pub(super) playbooks: ArcSwap>, /// In-memory cache of admin whitelist IPs. - pub(super) admin_whitelist: RwLock>, + pub(super) admin_whitelist: ArcSwap>, /// Cooldown tracker: maps (playbook_id, source_ip) → last execution time. pub(super) cooldowns: DashMap, /// Frequency tracker for frequency-based conditions. @@ -83,8 +83,8 @@ impl SoarEngine { let engine = Self { db, access_control, - playbooks: RwLock::new(Vec::new()), - admin_whitelist: RwLock::new(HashSet::new()), + playbooks: ArcSwap::from_pointee(Vec::new()), + admin_whitelist: ArcSwap::from_pointee(HashSet::new()), cooldowns: DashMap::new(), frequency_tracker: FrequencyTracker::new(), active_block_count: AtomicU32::new(0), @@ -201,21 +201,20 @@ impl SoarEngine { } } - *self.playbooks.write() = playbooks; + let playbook_count = playbooks.len(); + self.playbooks.store(Arc::new(playbooks)); // Load admin whitelist let whitelist = self.db.load_admin_whitelist()?; - *self.admin_whitelist.write() = whitelist.into_iter().collect(); + let whitelist: HashSet = whitelist.into_iter().collect(); + let whitelist_count = whitelist.len(); + self.admin_whitelist.store(Arc::new(whitelist)); // Initialize block counter from DB let count = self.db.count_active_soar_blocks()?; self.active_block_count.store(count, Ordering::SeqCst); - log!(SoarLog::CacheLoaded( - self.playbooks.read().len(), - self.admin_whitelist.read().len(), - count, - )); + log!(SoarLog::CacheLoaded(playbook_count, whitelist_count, count)); Ok(()) } @@ -759,18 +758,18 @@ mod tests { let engine = SoarEngine::new(db.clone(), mock, None, None, None, cache, None).expect("Failed to create engine"); // Should have loaded default playbooks - let count = engine.playbooks.read().len(); + let count = engine.playbooks.load().len(); assert!(count > 0, "Should have loaded default playbooks"); // Add a new playbook directly to DB db.insert_playbook("test_pb", "port_scan", None, None, None, 60).ok(); // Cache should not have it yet - assert_eq!(engine.playbooks.read().len(), count); + assert_eq!(engine.playbooks.load().len(), count); // After reload, should have one more engine.reload_cache().expect("reload should succeed"); - assert_eq!(engine.playbooks.read().len(), count + 1); + assert_eq!(engine.playbooks.load().len(), count + 1); } fn test_event(confidence: f32, country: Option<&str>, ip: &str, repeat: bool) -> ThreatDetectedEvent { diff --git a/net-guardia/src/core/soar/matcher.rs b/net-guardia/src/core/soar/matcher.rs index 05f9d9e..069c03c 100644 --- a/net-guardia/src/core/soar/matcher.rs +++ b/net-guardia/src/core/soar/matcher.rs @@ -33,8 +33,8 @@ const DEFAULT_COOLDOWN_EXPIRY_SECS: u64 = 3600; impl SoarEngine { /// Find playbooks matching the event via trigger_event + multi-condition AND logic. pub(super) fn find_matching_playbooks(&self, event: &ThreatDetectedEvent) -> Vec { - let playbooks = self.playbooks.read(); - playbooks + self.playbooks + .load() .iter() .filter(|pb| pb.enabled && pb.trigger_event == event.attack_type) .filter(|pb| self.evaluate_conditions(pb, event)) @@ -257,14 +257,13 @@ impl SoarEngine { /// Remove expired cooldown entries to prevent unbounded growth. /// Called by TTL scheduler every 60 seconds. pub fn cleanup_expired_cooldowns(&self) { - let max_cooldown_secs = { - let playbooks = self.playbooks.read(); - playbooks - .iter() - .map(|p| p.cooldown_secs as u64) - .max() - .unwrap_or(DEFAULT_COOLDOWN_EXPIRY_SECS) - }; + let max_cooldown_secs = self + .playbooks + .load() + .iter() + .map(|p| p.cooldown_secs as u64) + .max() + .unwrap_or(DEFAULT_COOLDOWN_EXPIRY_SECS); let expiry = Duration::from_secs(max_cooldown_secs.saturating_mul(2).max(DEFAULT_COOLDOWN_EXPIRY_SECS)); let before = self.cooldowns.len(); self.cooldowns.retain(|_, instant| instant.elapsed() < expiry); @@ -310,8 +309,11 @@ impl SoarEngine { /// admin so they don't assume a `would_fire=true` playbook will /// definitely fire on the next matching real event. pub fn dry_run(&self, event: &ThreatDetectedEvent) -> Vec { - let playbooks = self.playbooks.read(); - playbooks.iter().map(|pb| simulate_playbook(pb, event)).collect() + self.playbooks + .load() + .iter() + .map(|pb| simulate_playbook(pb, event)) + .collect() } /// Check if an IP address is private/loopback/link-local (SSRF protection). diff --git a/net-guardia/src/infrastructure/app_config.rs b/net-guardia/src/infrastructure/app_config.rs index bd54e24..7bbe3e0 100644 --- a/net-guardia/src/infrastructure/app_config.rs +++ b/net-guardia/src/infrastructure/app_config.rs @@ -69,7 +69,8 @@ impl AppConfig { // ML ("ml_drift_window_secs", "3600".into()), // Telegram - ("telegram_max_messages_per_minute", "20".into()), + ("telegram_rate_limit_max_messages", "20".into()), + ("telegram_rate_limit_window_secs", "60".into()), // Directories ("report_dir", "/var/lib/netguardia/reports".into()), ("log_dir", "logs".into()), diff --git a/net-guardia/src/infrastructure/app_services.rs b/net-guardia/src/infrastructure/app_services.rs index 9d49fd7..5a709f9 100644 --- a/net-guardia/src/infrastructure/app_services.rs +++ b/net-guardia/src/infrastructure/app_services.rs @@ -2,6 +2,7 @@ use std::path::PathBuf; use std::sync::Arc; use std::time::{Duration, SystemTime}; +use arc_swap::ArcSwap; use crossbeam::queue::SegQueue; use macros::log; use tokio::sync::oneshot; @@ -50,7 +51,7 @@ impl AppServices { inference_config: Arc, ml_manifest: Option, drift_detector: Arc>, - ebpf_health: Arc>, + ebpf_health: Arc>, comm: Arc, ) -> Result { let health = SystemHealth::new(app_config.clone(), ebpf_health)?; diff --git a/net-guardia/src/infrastructure/geoip.rs b/net-guardia/src/infrastructure/geoip.rs index ff07e6f..efe3888 100644 --- a/net-guardia/src/infrastructure/geoip.rs +++ b/net-guardia/src/infrastructure/geoip.rs @@ -2,10 +2,8 @@ use std::net::IpAddr; use std::path::{Path, PathBuf}; use std::sync::Arc; -use lru::LruCache; use maxminddb::{MaxMindDbError, Reader, geoip2}; -use std::num::NonZeroUsize; -use tokio::sync::RwLock; +use moka::sync::Cache; use tokio::task; use crate::model::monitoring::geolocation::GeoLocation; @@ -13,7 +11,7 @@ use crate::utils::ip_address; pub struct GeoIpService { reader: Arc>>, - cache: Arc>>>, + cache: Cache>, } impl GeoIpService { @@ -24,12 +22,11 @@ impl GeoIpService { pub fn with_cache_size>(db_path: P, cache_size: usize) -> Result { let reader = Reader::open_readfile(db_path)?; - // SAFETY: 10000 is a non-zero literal, NonZeroUsize::new is infallible. - let cache_capacity = NonZeroUsize::new(cache_size).unwrap_or_else(|| NonZeroUsize::new(10000).unwrap()); + let capacity = if cache_size == 0 { 10_000 } else { cache_size } as u64; Ok(Self { reader: Arc::new(reader), - cache: Arc::new(RwLock::new(LruCache::new(cache_capacity))), + cache: Cache::new(capacity), }) } @@ -45,11 +42,8 @@ impl GeoIpService { })); } - { - let cache = self.cache.read().await; - if let Some(cached) = cache.peek(&ip) { - return Ok(cached.clone()); - } + if let Some(cached) = self.cache.get(&ip) { + return Ok(cached); } let reader = self.reader.clone(); @@ -60,10 +54,7 @@ impl GeoIpService { offset: None, })??; - { - let mut cache = self.cache.write().await; - cache.put(ip, result.clone()); - } + self.cache.insert(ip, result.clone()); Ok(result) } diff --git a/net-guardia/src/infrastructure/health.rs b/net-guardia/src/infrastructure/health.rs index ddb15a8..557477e 100644 --- a/net-guardia/src/infrastructure/health.rs +++ b/net-guardia/src/infrastructure/health.rs @@ -2,6 +2,7 @@ use std::env::consts; use std::sync::Arc; use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use arc_swap::ArcSwap; use macros::log; use sysinfo::{Components, Networks, System}; use tokio::sync::{RwLock, broadcast, oneshot}; @@ -22,11 +23,11 @@ pub struct SystemHealth { broadcast_tx: broadcast::Sender, ingress_interface: String, egress_interface: String, - ebpf_health: Arc>, + ebpf_health: Arc>, } impl SystemHealth { - pub fn new(config: Arc, ebpf_health: Arc>) -> Result { + pub fn new(config: Arc, ebpf_health: Arc>) -> Result { let (broadcast_tx, _) = broadcast::channel(100); let health = SystemHealth { @@ -74,7 +75,7 @@ impl SystemHealth { let networks = self.networks.read().await; let components = self.components.read().await; - let ebpf = self.ebpf_health.read().clone(); + let ebpf = (**self.ebpf_health.load()).clone(); let metrics = Self::collect_metrics( &system, &networks, @@ -243,7 +244,7 @@ impl SystemHealth { let networks = self.networks.read().await; let components = self.components.read().await; - let ebpf = self.ebpf_health.read().clone(); + let ebpf = (**self.ebpf_health.load()).clone(); Self::collect_metrics( &system, &networks, @@ -257,7 +258,7 @@ impl SystemHealth { /// Returns a handle to the shared eBPF health state. Consumers (HTTP /// handlers, setup wizard, frontend) can read the current eBPF state /// without going through the full metrics broadcast. - pub fn ebpf_health(&self) -> &Arc> { + pub fn ebpf_health(&self) -> &Arc> { &self.ebpf_health } diff --git a/net-guardia/src/infrastructure/service_factory.rs b/net-guardia/src/infrastructure/service_factory.rs index 266d455..ffe433c 100644 --- a/net-guardia/src/infrastructure/service_factory.rs +++ b/net-guardia/src/infrastructure/service_factory.rs @@ -5,7 +5,8 @@ use std::sync::Arc; use std::sync::atomic::AtomicU8; use std::time::Duration; -use parking_lot::{Mutex, RwLock}; +use arc_swap::ArcSwap; +use parking_lot::Mutex; use aya::Ebpf; use aya::maps::{Array, MapData, ProgramArray}; @@ -91,7 +92,7 @@ pub struct AppState { /// or to `Unavailable { stage, category, reason }` when any stage fails. /// Read by `SystemHealth` for the metrics broadcast and by HTTP handlers /// that render runtime status to the frontend. - pub ebpf_health: Arc>, + pub ebpf_health: Arc>, pub suricata_manager: Arc, } @@ -138,7 +139,7 @@ impl ServiceFactory { // Shared eBPF health handle. Initialized Healthy; downgraded to // Unavailable with a classified reason if any stage below fails. - let ebpf_health = Arc::new(RwLock::new(EbpfHealth::Healthy)); + let ebpf_health = Arc::new(ArcSwap::from_pointee(EbpfHealth::Healthy)); // Attempt full eBPF bring-up. On any failure we classify the error, // write it into `ebpf_health`, and fall back to an `EbpfServices` @@ -152,7 +153,7 @@ impl ServiceFactory { Err((stage, err)) => { let health = ebpf_preflight::classify(stage, &err, None); log!(SystemLog::EbpfBringupFailed(format!("{:?}", health))); - *ebpf_health.write() = health; + ebpf_health.store(Arc::new(health)); ( None, None, diff --git a/net-guardia/src/infrastructure/suricata_manager.rs b/net-guardia/src/infrastructure/suricata_manager.rs index b243b8e..882b8fa 100644 --- a/net-guardia/src/infrastructure/suricata_manager.rs +++ b/net-guardia/src/infrastructure/suricata_manager.rs @@ -15,8 +15,8 @@ use std::path::Path; use std::sync::Arc; use std::time::Duration; +use arc_swap::ArcSwap; use macros::log; -use parking_lot::RwLock; use tokio::process::{Child, Command}; use tokio::sync::oneshot; use tokio::time::{sleep, timeout}; @@ -29,7 +29,7 @@ use crate::model::system::suricata::SuricataHealth; pub struct SuricataManager { config: Arc, - health: Arc>, + health: Arc>, } impl SuricataManager { @@ -43,12 +43,12 @@ impl SuricataManager { }; Arc::new(Self { config, - health: Arc::new(RwLock::new(initial)), + health: Arc::new(ArcSwap::from_pointee(initial)), }) } /// Shared handle for HTTP handlers and the health broadcast. - pub fn health(&self) -> Arc> { + pub fn health(&self) -> Arc> { self.health.clone() } @@ -73,20 +73,22 @@ impl SuricataManager { loop { // Pre-flight: validate binary + config exist before spawning. if let Err(e) = Self::preflight(&self.config) { - *self.health.write() = SuricataHealth::Stopped { reason: e.to_string() }; + self.health + .store(Arc::new(SuricataHealth::Stopped { reason: e.to_string() })); return; } let mut child = match self.spawn_child() { Ok(c) => c, Err(e) => { - *self.health.write() = SuricataHealth::Stopped { reason: e.to_string() }; + self.health + .store(Arc::new(SuricataHealth::Stopped { reason: e.to_string() })); return; } }; let pid = child.id().unwrap_or(0); - *self.health.write() = SuricataHealth::Running { pid }; + self.health.store(Arc::new(SuricataHealth::Running { pid })); log!(SuricataLog::Started(pid)); tokio::select! { @@ -98,21 +100,21 @@ impl SuricataManager { if self.config.suricata.auto_restart_on_crash { let backoff = self.config.suricata.restart_backoff_secs; log!(SuricataLog::CrashedRestartPending(reason.clone(), backoff)); - *self.health.write() = SuricataHealth::Stopped { reason }; + self.health.store(Arc::new(SuricataHealth::Stopped { reason })); sleep(Duration::from_secs(backoff)).await; continue; } else { log!(SuricataLog::Stopped(reason.clone())); - *self.health.write() = SuricataHealth::Stopped { reason }; + self.health.store(Arc::new(SuricataHealth::Stopped { reason })); return; } } _ = &mut shutdown_rx => { log!(SuricataLog::ShutdownRequested); Self::graceful_stop(&mut child).await; - *self.health.write() = SuricataHealth::Stopped { + self.health.store(Arc::new(SuricataHealth::Stopped { reason: "shutdown".to_string(), - }; + })); return; } } diff --git a/net-guardia/src/infrastructure/system.rs b/net-guardia/src/infrastructure/system.rs index ccea331..032bbed 100644 --- a/net-guardia/src/infrastructure/system.rs +++ b/net-guardia/src/infrastructure/system.rs @@ -3,10 +3,10 @@ use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; +use arc_swap::ArcSwap; use aya::Ebpf; use aya::maps::{MapData, ProgramArray}; use macros::log; -use parking_lot::{Mutex, RwLock}; use sd_notify::NotifyState; use tokio::signal::ctrl_c; use tokio::sync::broadcast::{Receiver, error::RecvError}; @@ -65,26 +65,23 @@ pub enum ShutdownMode { Restart, } -/// Handle for triggering shutdown from HTTP endpoints. -/// Uses a parking_lot::Mutex> so it can be shared as app_data. +/// Handle for triggering shutdown from HTTP endpoints. Backed by a +/// capacity-1 mpsc so the first trigger atomically wins via `try_send`, +/// and subsequent calls receive `TrySendError::Full` — no lock, no +/// `Option::take`, no `Mutex`. pub struct ShutdownHandle { - tx: Mutex>>, + tx: mpsc::Sender, } impl ShutdownHandle { - fn new(tx: oneshot::Sender) -> Self { - Self { - tx: Mutex::new(Some(tx)), - } + fn new(tx: mpsc::Sender) -> Self { + Self { tx } } - /// Trigger shutdown. Returns false if already triggered. + /// Trigger shutdown. Returns false if already triggered or the + /// receiver has been dropped. pub fn trigger(&self, mode: ShutdownMode) -> bool { - if let Some(tx) = self.tx.lock().take() { - tx.send(mode).is_ok() - } else { - false - } + self.tx.try_send(mode).is_ok() } } @@ -115,7 +112,7 @@ pub struct System { pub drift_detector: Arc>, pub shutdown_handle: Option>, _ingress_program_array: Option>, - pub ebpf_health: Arc>, + pub ebpf_health: Arc>, pub suricata_manager: Arc, suricata_shutdown: Option>, } @@ -222,7 +219,7 @@ impl System { let iface = self.app_config.network.ingress_ifname.as_str(); let health = ebpf_preflight::classify(EbpfFailStage::AfXdpBind, &e, Some(iface)); log!(SystemLog::EbpfBringupFailed(format!("{:?}", health))); - *self.ebpf_health.write() = health; + self.ebpf_health.store(Arc::new(health)); } app_services.run().await?; @@ -314,8 +311,10 @@ impl System { // SOAR engine started above (self.soar_engine.start succeeded) readiness_state.soar_engine_running.store(true, Ordering::SeqCst); - // Create shutdown channel for API-triggered shutdown/restart - let (shutdown_tx, shutdown_rx) = oneshot::channel::(); + // Create shutdown channel for API-triggered shutdown/restart. + // Capacity 1 means only the first `try_send` lands a value; later + // ones report Full, which `ShutdownHandle::trigger` surfaces as `false`. + let (shutdown_tx, mut shutdown_rx) = mpsc::channel::(1); let shutdown_handle = Arc::new(ShutdownHandle::new(shutdown_tx)); self.shutdown_handle = Some(shutdown_handle.clone()); @@ -393,7 +392,7 @@ impl System { _ = ctrl_c() => { Ok(ShutdownMode::Shutdown) } - mode = shutdown_rx => { + mode = shutdown_rx.recv() => { Ok(mode.unwrap_or(ShutdownMode::Shutdown)) } } @@ -535,7 +534,7 @@ impl System { }; let health = ebpf_preflight::classify(EbpfFailStage::XdpAttach, err, Some(iface)); log!(SystemLog::EbpfBringupFailed(format!("{:?}", health))); - *self.ebpf_health.write() = health; + self.ebpf_health.store(Arc::new(health)); } } diff --git a/net-guardia/src/model/log/system.rs b/net-guardia/src/model/log/system.rs index 50fc741..41fdbe8 100644 --- a/net-guardia/src/model/log/system.rs +++ b/net-guardia/src/model/log/system.rs @@ -111,8 +111,8 @@ loggable! { #[error("Telegram not configured, skipping alert")] TelegramNotConfiguredSkipped => tracing::Level::DEBUG, - #[error("Telegram rate limit reached ({max_per_min}/min), dropping alert for IP {source_ip}")] - TelegramLocalRateLimitDropped { max_per_min: u32, source_ip: String } => tracing::Level::WARN, + #[error("Telegram rate limit reached ({max_messages} per {window_secs}s), dropping alert for IP {source_ip}")] + TelegramLocalRateLimitDropped { max_messages: u32, window_secs: u32, source_ip: String } => tracing::Level::WARN, #[error("Stats aggregator started (1h interval)")] StatsAggregatorStarted => tracing::Level::INFO, diff --git a/net-guardia/src/model/monitoring/drop_event.rs b/net-guardia/src/model/monitoring/drop_event.rs index 192bbd1..0dac23b 100644 --- a/net-guardia/src/model/monitoring/drop_event.rs +++ b/net-guardia/src/model/monitoring/drop_event.rs @@ -1,3 +1,5 @@ +use std::sync::atomic::{AtomicU64, Ordering}; + use serde::Serialize; #[derive(Debug, Clone, Serialize)] @@ -12,6 +14,8 @@ pub struct DropEventMessage { pub ip_version: u8, } +/// Wire snapshot of drop counts. Returned by `DropMonitor::snapshot` and +/// serialized to JSON for the HTTP stats endpoint. #[derive(Default, Clone, Serialize)] pub struct DropCounters { pub acl_blacklist: u64, @@ -24,3 +28,35 @@ pub struct DropCounters { pub geo_block: u64, pub total: u64, } + +/// Lock-free atomic counters incremented on the drop ring-buffer consumer +/// path. `Relaxed` is sufficient — counters are independent and the +/// snapshot does not require a global consistent ordering across them. +#[derive(Default)] +pub struct DropCountersAtomic { + pub acl_blacklist: AtomicU64, + pub rate_limit_pkt: AtomicU64, + pub rate_limit_syn: AtomicU64, + pub rate_limit_udp: AtomicU64, + pub rate_limit_dns: AtomicU64, + pub protocol_filter: AtomicU64, + pub dns_blacklist: AtomicU64, + pub geo_block: AtomicU64, + pub total: AtomicU64, +} + +impl DropCountersAtomic { + pub fn snapshot(&self) -> DropCounters { + DropCounters { + acl_blacklist: self.acl_blacklist.load(Ordering::Relaxed), + rate_limit_pkt: self.rate_limit_pkt.load(Ordering::Relaxed), + rate_limit_syn: self.rate_limit_syn.load(Ordering::Relaxed), + rate_limit_udp: self.rate_limit_udp.load(Ordering::Relaxed), + rate_limit_dns: self.rate_limit_dns.load(Ordering::Relaxed), + protocol_filter: self.protocol_filter.load(Ordering::Relaxed), + dns_blacklist: self.dns_blacklist.load(Ordering::Relaxed), + geo_block: self.geo_block.load(Ordering::Relaxed), + total: self.total.load(Ordering::Relaxed), + } + } +}