diff --git a/net-guardia/src/adapter/ebpf/drop_monitor.rs b/net-guardia/src/adapter/ebpf/drop_monitor.rs index 31eba34..b592431 100644 --- a/net-guardia/src/adapter/ebpf/drop_monitor.rs +++ b/net-guardia/src/adapter/ebpf/drop_monitor.rs @@ -10,7 +10,6 @@ use common::model::drop_event::DropEvent as RawDropEvent; use tokio::sync::{broadcast, oneshot}; use tokio::time::interval; -use crate::model::config::constants::DROP_CHANNEL_CAPACITY; use crate::model::monitoring::drop_event::{DropCounters, DropCountersAtomic, DropEventMessage}; pub struct DropMonitor { @@ -19,8 +18,8 @@ pub struct DropMonitor { } impl DropMonitor { - pub fn new() -> Self { - let (tx, _) = broadcast::channel(DROP_CHANNEL_CAPACITY); + pub fn new(channel_capacity: usize) -> Self { + let (tx, _) = broadcast::channel(channel_capacity.max(1)); Self { broadcast_tx: tx, counters: DropCountersAtomic::default(), @@ -95,12 +94,6 @@ impl DropMonitor { } } -impl Default for DropMonitor { - fn default() -> Self { - Self::new() - } -} - fn format_ips(raw: &RawDropEvent) -> (String, String) { match raw.ip_version { 4 => { diff --git a/net-guardia/src/adapter/ebpf/geo_block.rs b/net-guardia/src/adapter/ebpf/geo_block.rs index 8bf5621..6d85b18 100644 --- a/net-guardia/src/adapter/ebpf/geo_block.rs +++ b/net-guardia/src/adapter/ebpf/geo_block.rs @@ -9,8 +9,8 @@ use ipnetwork::IpNetwork; use maxminddb::{Reader, geoip2}; use parking_lot::RwLock; -use crate::infrastructure::app_config::AppConfig; use crate::interface::port::geo_block_api::GeoBlockPort; +use crate::model::config::AppConfig; use crate::model::error::Error; use crate::model::error::ebpf::EbpfError; use crate::model::error::misc::MiscError; @@ -29,15 +29,15 @@ pub struct GeoBlock { } impl GeoBlock { - pub fn new(ebpf: &mut Ebpf, app_config: &AppConfig) -> Result { + pub fn new(ebpf: &mut Ebpf, app_config: Arc>) -> Result { let v4_map = ebpf.take_map("GEO_BLOCK_V4").ok_or(EbpfError::MapNotFound)?; let v4_trie = LpmTrie::try_from(v4_map).map_err(EbpfError::MapOperationError)?; let v6_map = ebpf.take_map("GEO_BLOCK_V6").ok_or(EbpfError::MapNotFound)?; let v6_trie = LpmTrie::try_from(v6_map).map_err(EbpfError::MapOperationError)?; - let db_path = &app_config.misc.geoip_db_name; - let reader = Reader::open_readfile(db_path).map_err(|e| MiscError::GeoIPDatabaseError(db_path.clone(), e))?; + let db_path = app_config.load().acl.geoip_db_name.clone(); + let reader = Reader::open_readfile(&db_path).map_err(|e| MiscError::GeoIPDatabaseError(db_path.clone(), e))?; let index = Self::build_index(&reader)?; @@ -53,8 +53,8 @@ impl GeoBlock { /// the GeoIP index so the frontend can list what *would* be enforced; /// mutating calls (`block_countries`, `unblock_countries`) return /// `EbpfError::NotLoaded`. - pub fn unavailable(app_config: &AppConfig) -> Self { - let index = Reader::open_readfile(&app_config.misc.geoip_db_name) + pub fn unavailable(app_config: Arc>) -> Self { + let index = Reader::open_readfile(&app_config.load().acl.geoip_db_name) .ok() .and_then(|reader| Self::build_index(&reader).ok()) .unwrap_or(GeoIndex { diff --git a/net-guardia/src/adapter/ebpf/mod.rs b/net-guardia/src/adapter/ebpf/mod.rs index d7c3aa8..61bc53e 100644 --- a/net-guardia/src/adapter/ebpf/mod.rs +++ b/net-guardia/src/adapter/ebpf/mod.rs @@ -8,6 +8,7 @@ pub mod xsk_manager; use std::sync::Arc; +use arc_swap::ArcSwap; use aya::Ebpf; use aya::maps::{MapData, RingBuf}; use crossbeam::queue::SegQueue; @@ -22,9 +23,9 @@ use crate::adapter::ebpf::geo_block::GeoBlock; use crate::adapter::ebpf::protocol_filter::ProtocolFilter; use crate::adapter::ebpf::rate_limit::RateLimitConfig; use crate::adapter::ebpf::xsk_manager::XskManager; -use crate::infrastructure::app_config::AppConfig; use crate::interface::port::dns_query_filter::DnsQueryFilter; use crate::interface::port::packet_sink::PacketSinkFactory; +use crate::model::config::AppConfig; use crate::model::error::Error; use crate::model::error::ebpf::EbpfError; use crate::model::error::system::SystemError; @@ -42,14 +43,18 @@ pub struct EbpfServices { } impl EbpfServices { - pub fn new(app_config: Arc, ingress_ebpf: &mut Ebpf, egress_ebpf: &mut Ebpf) -> Result { + pub fn new( + app_config: Arc>, + ingress_ebpf: &mut Ebpf, + egress_ebpf: &mut Ebpf, + ) -> Result { let xsk_manager = XskManager::new(app_config.clone(), ingress_ebpf, egress_ebpf)?; let access_control = AccessControl::new(ingress_ebpf)?; let protocol_filter = ProtocolFilter::new(ingress_ebpf)?; let dns_filter = DnsFilter::new(); - let geo_block = GeoBlock::new(ingress_ebpf, &app_config)?; + let geo_block = GeoBlock::new(ingress_ebpf, app_config.clone())?; let rate_limit = RateLimitConfig::new(ingress_ebpf)?; - let drop_monitor = Arc::new(DropMonitor::new()); + let drop_monitor = Arc::new(DropMonitor::new(app_config.load().observability.drop_channel_capacity)); let drop_ring_buf = { let map = ingress_ebpf.take_map("DROP_EVENTS").ok_or(EbpfError::MapNotFound)?; RingBuf::try_from(map).map_err(EbpfError::MapOperationError)? @@ -70,15 +75,15 @@ impl EbpfServices { /// Build an EbpfServices with every eBPF-backed subservice in the /// "unavailable" state. Used when eBPF failed to load at startup. /// Queries return empty results; mutating calls return `EbpfError::NotLoaded`. - pub fn unavailable(app_config: Arc) -> Self { + pub fn unavailable(app_config: Arc>) -> Self { Self { xsk_manager: Arc::new(XskManager::unavailable(app_config.clone())), access_control: Arc::new(AccessControl::unavailable()), protocol_filter: Arc::new(ProtocolFilter::unavailable()), dns_filter: Arc::new(DnsFilter::new()), - geo_block: Arc::new(GeoBlock::unavailable(&app_config)), + geo_block: Arc::new(GeoBlock::unavailable(app_config.clone())), rate_limit: Arc::new(RateLimitConfig::unavailable()), - drop_monitor: Arc::new(DropMonitor::new()), + drop_monitor: Arc::new(DropMonitor::new(app_config.load().observability.drop_channel_capacity)), drop_ring_buf: Mutex::new(None), shutdowns: SegQueue::new(), } diff --git a/net-guardia/src/adapter/ebpf/xsk_manager.rs b/net-guardia/src/adapter/ebpf/xsk_manager.rs index a33cd8c..ac0be2b 100644 --- a/net-guardia/src/adapter/ebpf/xsk_manager.rs +++ b/net-guardia/src/adapter/ebpf/xsk_manager.rs @@ -6,6 +6,7 @@ use std::sync::Arc; use std::thread; use std::time::Duration; +use arc_swap::ArcSwap; use aya::Ebpf; use aya::maps::{MapData, XskMap}; use common::define::drop_reason::DROP_REASON_DNS_BLACKLIST; @@ -18,15 +19,15 @@ use xsk_rs::config::{BindFlags, FrameSize, Interface, LibxdpFlags, QueueSize, So use xsk_rs::{CompQueue, FillQueue, FrameDesc, RxQueue, Socket, TxQueue, Umem}; use crate::adapter::ebpf::drop_monitor::DropMonitor; -use crate::infrastructure::app_config::AppConfig; use crate::interface::port::dns_query_filter::DnsQueryFilter; use crate::interface::port::packet_sink::{PacketSink, PacketSinkFactory}; +use crate::model::config::AppConfig; +use crate::model::config::ebpf::EbpfConfig; use crate::model::error::Error; use crate::model::error::ebpf::EbpfError; use crate::model::error::system::SystemError; use crate::model::log::ebpf::EbpfLog; use crate::model::monitoring::direction::Direction; -use crate::model::system::config::NetworkConfig; use crate::utils::packet_parser::parse_packet; /// Pre-allocated buffer pool to avoid per-packet malloc. @@ -61,13 +62,17 @@ impl BufferPool { } pub struct XskManager { - app_config: Arc, + app_config: Arc>, xsk_map: Mutex>>, egress_xsk_map: Mutex>>, } impl XskManager { - pub fn new(app_config: Arc, ingress_ebpf: &mut Ebpf, egress_ebpf: &mut Ebpf) -> Result { + pub fn new( + app_config: Arc>, + ingress_ebpf: &mut Ebpf, + egress_ebpf: &mut Ebpf, + ) -> Result { let map = ingress_ebpf .take_map("INGRESS_XSKS_MAP") .ok_or(EbpfError::MapNotFound)?; @@ -83,7 +88,7 @@ impl XskManager { }) } - pub fn unavailable(app_config: Arc) -> Self { + pub fn unavailable(app_config: Arc>) -> Self { Self { app_config, xsk_map: Mutex::new(None), @@ -105,7 +110,7 @@ impl XskManager { return Ok(()); } - let network = self.app_config.network.clone(); + let network = self.app_config.load().ebpf.clone(); let combined_queue_count = network.combined_queue_count; for queue_id in 0..combined_queue_count { @@ -183,9 +188,8 @@ pub struct XskPair { } impl XskPair { - #[allow(clippy::too_many_arguments)] pub fn new( - config: NetworkConfig, + config: EbpfConfig, queue_id: u32, rx_ifname: &str, _tx_ifname: &str, diff --git a/net-guardia/src/adapter/http/acl.rs b/net-guardia/src/adapter/http/acl.rs index 0f09b1f..e31a0d1 100644 --- a/net-guardia/src/adapter/http/acl.rs +++ b/net-guardia/src/adapter/http/acl.rs @@ -3,6 +3,7 @@ use std::net::{SocketAddrV4, SocketAddrV6}; use actix_web::{HttpResponse, Responder, Scope, web}; use serde::Deserialize; +use crate::adapter::http::response::ok_or_error; use crate::core::acl_service::AclService; use crate::model::access_control::list_type::ListType; use crate::model::monitoring::direction::FlowDirection; @@ -43,10 +44,7 @@ async fn add_ipv4_list( acl: web::Data, ) -> impl Responder { let (direction, list_type) = path.into_inner(); - match acl.add_ipv4(direction, list_type, address.into_inner()) { - Ok(_) => HttpResponse::Ok().finish(), - Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})), - } + ok_or_error(acl.add_ipv4(direction, list_type, address.into_inner())) } async fn add_ipv6_list( @@ -55,10 +53,7 @@ async fn add_ipv6_list( acl: web::Data, ) -> impl Responder { let (direction, list_type) = path.into_inner(); - match acl.add_ipv6(direction, list_type, address.into_inner()) { - Ok(_) => HttpResponse::Ok().finish(), - Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})), - } + ok_or_error(acl.add_ipv6(direction, list_type, address.into_inner())) } async fn remove_ipv4_list( @@ -67,10 +62,7 @@ async fn remove_ipv4_list( acl: web::Data, ) -> impl Responder { let (direction, list_type) = path.into_inner(); - match acl.remove_ipv4(direction, list_type, address.into_inner()) { - Ok(_) => HttpResponse::Ok().finish(), - Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})), - } + ok_or_error(acl.remove_ipv4(direction, list_type, address.into_inner())) } async fn remove_ipv6_list( @@ -79,10 +71,7 @@ async fn remove_ipv6_list( acl: web::Data, ) -> impl Responder { let (direction, list_type) = path.into_inner(); - match acl.remove_ipv6(direction, list_type, address.into_inner()) { - Ok(_) => HttpResponse::Ok().finish(), - Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})), - } + ok_or_error(acl.remove_ipv6(direction, list_type, address.into_inner())) } async fn get_geo_blocked(acl: web::Data) -> impl Responder { diff --git a/net-guardia/src/adapter/http/auth.rs b/net-guardia/src/adapter/http/auth.rs index ee6420a..f1f5eaa 100644 --- a/net-guardia/src/adapter/http/auth.rs +++ b/net-guardia/src/adapter/http/auth.rs @@ -2,6 +2,7 @@ use actix_web::{HttpResponse, Responder, Scope, web}; use macros::log; use serde::Deserialize; +use crate::adapter::http::response::ok_or_error; use crate::core::auth::extractor::AuthClaims; use crate::core::auth::jwt::JwtService; use crate::core::auth::password; @@ -114,9 +115,9 @@ async fn login(body: web::Json, db: web::Data, jwt: web::Dat } // Permissions come exclusively from groups — no role-based fallback - let permissions = db.get_user_permissions(id).unwrap_or_default(); + let permissions = db.list_user_permissions(id).unwrap_or_default(); - let groups = db.get_user_groups(id).unwrap_or_default(); + let groups = db.list_groups_for_user(id).unwrap_or_default(); let role = if groups.iter().any(|(_id, name, _desc, _perms)| name == "Administrator") { "admin".to_string() } else { @@ -180,7 +181,7 @@ async fn register(auth: AuthClaims, body: web::Json, db: web::D } async fn me(auth: AuthClaims, db: web::Data) -> impl Responder { - let user_groups = db.get_user_groups(auth.sub).unwrap_or_default(); + let user_groups = db.list_groups_for_user(auth.sub).unwrap_or_default(); let group_names: Vec = user_groups .iter() .map(|(_id, name, _desc, _perms)| name.clone()) @@ -190,7 +191,7 @@ async fn me(auth: AuthClaims, db: web::Data) -> impl Responder { } else { "viewer" }; - let permissions = db.get_user_permissions(auth.sub).unwrap_or_default(); + let permissions = db.list_user_permissions(auth.sub).unwrap_or_default(); HttpResponse::Ok().json(serde_json::json!({ "id": auth.sub, "username": auth.username, @@ -379,10 +380,7 @@ async fn reset_password( } }; - match db.reset_user_password(user_id, &hash) { - Ok(_) => HttpResponse::Ok().json(serde_json::json!({"message": "Password reset successfully"})), - Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})), - } + ok_or_error(db.reset_user_password(user_id, &hash)) } // --- User Group Management (users:admin required) --- @@ -395,7 +393,7 @@ async fn list_groups(_auth: AuthClaims, db: web::Data) -> impl Responder { .map(|(id, name, description, permissions, created_at)| { let perms: serde_json::Value = serde_json::from_str(&permissions).unwrap_or(serde_json::json!([])); let members: Vec = db - .get_group_members(id) + .list_group_members(id) .unwrap_or_default() .into_iter() .map(|(uid, username)| serde_json::json!({"id": uid, "username": username})) @@ -447,7 +445,7 @@ async fn get_group(_auth: AuthClaims, path: web::Path, db: web::Data) match db.get_user_group(group_id) { Ok(Some((id, name, description, permissions, created_at))) => { let perms: serde_json::Value = serde_json::from_str(&permissions).unwrap_or(serde_json::json!([])); - let members = db.get_group_member_ids(group_id).unwrap_or_default(); + let members = db.list_group_member_ids(group_id).unwrap_or_default(); HttpResponse::Ok().json(serde_json::json!({ "id": id, "name": name, diff --git a/net-guardia/src/adapter/http/filter.rs b/net-guardia/src/adapter/http/filter.rs index c9eb1f2..1c148df 100644 --- a/net-guardia/src/adapter/http/filter.rs +++ b/net-guardia/src/adapter/http/filter.rs @@ -1,4 +1,3 @@ -use std::fmt; use std::net::{Ipv4Addr, Ipv6Addr, SocketAddrV4, SocketAddrV6}; use actix_web::{HttpResponse, Responder, Scope, web}; @@ -6,16 +5,9 @@ use common::model::http_method::HttpMethod; use serde::Deserialize; use crate::adapter::ebpf::protocol_filter::ProtocolFilter; +use crate::adapter::http::response::ok_or_error; use crate::core::dns_filter_service::DnsFilterService; -/// Convert a fallible result into an Ok (200) or InternalServerError (500) response. -fn ok_or_error(result: Result) -> HttpResponse { - match result { - Ok(_) => HttpResponse::Ok().finish(), - Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})), - } -} - pub fn initialize() -> Scope { web::scope("/filter") .service(http_scope()) diff --git a/net-guardia/src/adapter/http/fusion.rs b/net-guardia/src/adapter/http/fusion.rs index 1751f5d..d372b52 100644 --- a/net-guardia/src/adapter/http/fusion.rs +++ b/net-guardia/src/adapter/http/fusion.rs @@ -7,19 +7,14 @@ //! analysts can answer "why was this IP blocked?" without parsing //! logs by hand. +use std::sync::Arc; + use actix_web::{HttpRequest, HttpResponse, Responder, Scope, web}; +use arc_swap::ArcSwap; use crate::core::detection::metrics::FusionMetrics; use crate::interface::port::audit::{AuditLogEntry, AuditRepo}; - -/// Maximum audit rows scanned per explain request. Caps DB work in -/// case the audit chain grows large enough that a naive full-table -/// scan would be noticeable. -const FUSION_EXPLAIN_SCAN_LIMIT: i64 = 5_000; - -/// Upper cap on entries returned to the client per explain request. -/// Guards against a UI rendering path that chokes on enormous JSON. -const FUSION_EXPLAIN_RESPONSE_CAP: usize = 200; +use crate::model::config::AppConfig; /// Stable audit action string the fusion engine emits — kept in sync /// with `core::detection::orchestrator::FUSION_AUDIT_ACTION`. If that @@ -44,7 +39,11 @@ async fn get_metrics(metrics: web::Data) -> impl Responder { /// Scans the WORM audit chain for `fused_threat_emitted` entries that /// match `src_ip`, returning them oldest-first so the UI can render a /// chronological "why was this IP blocked" view. -async fn explain_ip(req: HttpRequest, audit: web::Data) -> impl Responder { +async fn explain_ip( + req: HttpRequest, + audit: web::Data, + app_config: web::Data>>, +) -> impl Responder { let src_ip = match req.match_info().get("src_ip") { Some(ip) => ip.to_string(), None => { @@ -54,7 +53,8 @@ async fn explain_ip(req: HttpRequest, audit: web::Data) -> impl R } }; - let entries = match audit.list_audit_logs_by_action(FUSION_AUDIT_ACTION, FUSION_EXPLAIN_SCAN_LIMIT) { + let obs = app_config.load().observability.clone(); + let entries = match audit.list_audit_logs_by_action(FUSION_AUDIT_ACTION, obs.fusion_explain_scan_limit) { Ok(e) => e, Err(e) => { return HttpResponse::InternalServerError().json(serde_json::json!({ @@ -63,7 +63,7 @@ async fn explain_ip(req: HttpRequest, audit: web::Data) -> impl R } }; - let (matches, truncated) = filter_fusion_evidence_for_ip(&entries, &src_ip, FUSION_EXPLAIN_RESPONSE_CAP); + let (matches, truncated) = filter_fusion_evidence_for_ip(&entries, &src_ip, obs.fusion_explain_response_cap); HttpResponse::Ok().json(serde_json::json!({ "src_ip": src_ip, "match_count": matches.len(), diff --git a/net-guardia/src/adapter/http/logs.rs b/net-guardia/src/adapter/http/logs.rs index fdce5a3..9008325 100644 --- a/net-guardia/src/adapter/http/logs.rs +++ b/net-guardia/src/adapter/http/logs.rs @@ -1,28 +1,19 @@ use std::fs; use std::io::ErrorKind; use std::path::Path; +use std::sync::Arc; use std::time::UNIX_EPOCH; use actix_web::{HttpResponse, Scope, web}; +use arc_swap::ArcSwap; use serde::{Deserialize, Serialize}; use crate::core::observability::log_buffer::{self, LogEntry}; +use crate::model::config::AppConfig; /// Hardcoded log directory — not configurable via API to prevent directory traversal. const LOG_DIR: &str = "logs"; -/// Maximum downloadable log file size (50 MB). Prevents OOM from reading huge files. -const MAX_DOWNLOAD_SIZE: u64 = 50 * 1024 * 1024; - -/// Default page size for `/live` when the client does not specify `limit`. -/// Chosen so a 2 s poll against a DEBUG-chatty deployment catches up in -/// one round-trip without being absurd payload-wise. -const LIVE_DEFAULT_LIMIT: usize = 500; - -/// Hard cap on `/live?limit=` — prevents pathological clients from asking -/// for the entire buffer at once. -const LIVE_MAX_LIMIT: usize = 2_000; - /// Validate log filename: only alphanumeric, dots, underscores, hyphens. /// Prevents path traversal. fn is_valid_log_filename(name: &str) -> bool { @@ -58,9 +49,13 @@ struct LiveResponse { dropped_oldest: bool, } -async fn live_logs(query: web::Query) -> HttpResponse { +async fn live_logs(query: web::Query, app_config: web::Data>>) -> HttpResponse { let since_id = query.since_id.unwrap_or(0); - let limit = query.limit.unwrap_or(LIVE_DEFAULT_LIMIT).clamp(1, LIVE_MAX_LIMIT); + let obs = app_config.load().observability.clone(); + let limit = query + .limit + .unwrap_or(obs.log_live_default_limit) + .clamp(1, obs.log_live_max_limit.max(1)); let min_severity = query .min_level .as_deref() @@ -118,7 +113,8 @@ async fn list_logs() -> HttpResponse { HttpResponse::Ok().json(serde_json::json!({ "files": entries })) } -async fn download_log(path: web::Path) -> HttpResponse { +async fn download_log(path: web::Path, app_config: web::Data>>) -> HttpResponse { + let max_download_size = app_config.load().observability.log_max_download_size; let filename = path.into_inner(); if !is_valid_log_filename(&filename) { @@ -148,9 +144,9 @@ async fn download_log(path: web::Path) -> HttpResponse { // Check file size before reading to prevent OOM on large logs match fs::metadata(&canonical) { - Ok(meta) if meta.len() > MAX_DOWNLOAD_SIZE => { + Ok(meta) if meta.len() > max_download_size => { return HttpResponse::PayloadTooLarge().json(serde_json::json!({ - "error": format!("Log file exceeds maximum download size ({}MB)", MAX_DOWNLOAD_SIZE / 1024 / 1024) + "error": format!("Log file exceeds maximum download size ({}MB)", max_download_size / 1024 / 1024) })); } Err(e) if e.kind() == ErrorKind::NotFound => { diff --git a/net-guardia/src/adapter/http/ml.rs b/net-guardia/src/adapter/http/ml.rs index f23f9fa..47e9c29 100644 --- a/net-guardia/src/adapter/http/ml.rs +++ b/net-guardia/src/adapter/http/ml.rs @@ -1,5 +1,6 @@ use actix_web::{HttpResponse, Responder, Scope, web}; +use crate::adapter::http::AUDIT_ACTOR_SECURITY_ADMIN_PREFIX; use crate::core::auth::extractor::AuthClaims; use crate::core::ml::adapter::ModelSourceState; use crate::core::ml::engine::Engine; @@ -13,11 +14,6 @@ use crate::model::event::AuditEvent; /// detector silently while the upload path required `users:admin`. const DORMANT_REQUIRED_PERMISSION: &str = "users:admin"; -/// Actor prefix recorded on the WORM chain when an admin reverts the ML -/// source. Matches the prefix used by `model_swap` so downstream filters -/// see both events in the same admin-action stream. -const AUDIT_ACTOR_SECURITY_ADMIN_PREFIX: &str = "SecurityAdmin"; - /// Action recorded on the WORM chain when the ML source is forced /// dormant via this endpoint. Stable wire string — UI/audit tooling /// filters on it, paired with `model_swap` from the upload path. diff --git a/net-guardia/src/adapter/http/mod.rs b/net-guardia/src/adapter/http/mod.rs index 11813d8..5905545 100644 --- a/net-guardia/src/adapter/http/mod.rs +++ b/net-guardia/src/adapter/http/mod.rs @@ -14,7 +14,13 @@ pub mod model_upload; pub mod notification; pub mod rate_limit; pub mod report; +pub mod response; pub mod setup; pub mod soar; pub mod stats; pub mod system; + +/// Shared audit actor prefix for security-admin triggered events. Stable +/// wire string — WORM consumers filter on `SecurityAdmin@` so +/// do not rename without coordinating audit-chain readers. +pub const AUDIT_ACTOR_SECURITY_ADMIN_PREFIX: &str = "SecurityAdmin"; diff --git a/net-guardia/src/adapter/http/model_upload.rs b/net-guardia/src/adapter/http/model_upload.rs index 1def6c0..7310f7e 100644 --- a/net-guardia/src/adapter/http/model_upload.rs +++ b/net-guardia/src/adapter/http/model_upload.rs @@ -25,6 +25,7 @@ use std::time::{Duration, SystemTime}; use actix_multipart::Multipart; use actix_web::{HttpResponse, Responder, Scope, web}; +use arc_swap::ArcSwap; use futures_util::TryStreamExt; use serde_json::Value as JsonValue; use sha2::{Digest, Sha256}; @@ -37,11 +38,11 @@ use crate::core::auth::extractor::AuthClaims; use crate::core::ml::inference::Inference; use crate::core::ml::manifest::{AdapterKind, ModelManifest}; use crate::core::ml::model_loader::build_adapter; -use crate::infrastructure::app_config::AppConfig; use crate::infrastructure::communication_manager::CommunicationManager; +use crate::model::config::AppConfig; use crate::model::config::constants::{MANIFEST_FILENAME, MODELS_DIR, STAGING_SUBDIR}; +use crate::model::detection::ml_inference_config::MLInferenceConfig; use crate::model::event::AuditEvent; -use crate::model::system::config::MLInferenceConfig; /// Multipart field names the client must use. Stable wire contract — /// the frontend form generator depends on these exact strings. @@ -67,7 +68,7 @@ const PROMOTE_REQUIRED_PERMISSION: &str = "users:admin"; /// system-driven entries (actor="system") from human-driven ones /// without parsing free-form text. Stable across releases — renaming /// breaks downstream audit tooling that filters on this prefix. -const AUDIT_ACTOR_SECURITY_ADMIN_PREFIX: &str = "SecurityAdmin"; +use crate::adapter::http::AUDIT_ACTOR_SECURITY_ADMIN_PREFIX; /// Action recorded on the WORM chain when a promote succeeds. Stable /// wire string — fusion-explain tooling and future "who swapped the @@ -136,7 +137,7 @@ pub fn initialize() -> Scope { /// pre-swap ML source state. The staging directory is always torn /// down on the way out, even on success (post-promote it's empty). async fn upload( - app_config: web::Data, + app_config: web::Data>, inference: web::Data, comm: web::Data, promote_lock: web::Data, @@ -153,11 +154,15 @@ async fn upload( let staging_id = Uuid::new_v4().to_string(); let staging_dir = staging_root.join(&staging_id); + let config = app_config.load(); let caps = UploadCaps { - manifest: app_config.inference.model_upload_max_manifest_bytes, - onnx: app_config.inference.model_upload_max_onnx_bytes, - scaler: app_config.inference.model_upload_max_scaler_bytes, + manifest: config.ml.model_upload_max_manifest_bytes, + onnx: config.ml.model_upload_max_onnx_bytes, + scaler: config.ml.model_upload_max_scaler_bytes, }; + let batch_size = config.ml.inference_batch_size; + let onnx_load_timeout = Duration::from_secs(config.ml.onnx_load_timeout_secs); + drop(config); let summary = match ingest_multipart(payload, &staging_dir, caps).await { Ok(s) => s, Err(e) => { @@ -165,8 +170,6 @@ async fn upload( return e.into_response(); } }; - - let batch_size = app_config.inference.inference_batch_size; let outcome = validate_and_promote( &staging_dir, &summary, @@ -175,6 +178,7 @@ async fn upload( promote_lock.get_ref(), &claims.username, batch_size, + onnx_load_timeout, ) .await; @@ -522,6 +526,7 @@ async fn validate_and_promote( promote_lock: &PromoteGate, actor_username: &str, batch_size: usize, + onnx_load_timeout: Duration, ) -> Result { let staging_manifest = staging_dir.join(MANIFEST_FILENAME); @@ -558,8 +563,14 @@ async fn validate_and_promote( // features, tract optimize+runnable under the 5s load budget. let (config, manifest) = MLInferenceConfig::from_manifest_with_sidecar(&staging_manifest) .map_err(|e| PromoteError::ValidationFailed(e.to_string()))?; - let _adapter = build_adapter(&manifest, Some(&staging_manifest), &config, batch_size) - .map_err(|e| PromoteError::ValidationFailed(e.to_string()))?; + let _adapter = build_adapter( + &manifest, + Some(&staging_manifest), + &config, + batch_size, + onnx_load_timeout, + ) + .map_err(|e| PromoteError::ValidationFailed(e.to_string()))?; let manifest_sha256 = sha256_file(&staging_manifest) .await diff --git a/net-guardia/src/adapter/http/notification.rs b/net-guardia/src/adapter/http/notification.rs index 04b7f1a..550058c 100644 --- a/net-guardia/src/adapter/http/notification.rs +++ b/net-guardia/src/adapter/http/notification.rs @@ -1,6 +1,7 @@ use actix_web::{HttpResponse, Scope, web}; use serde::Deserialize; +use crate::adapter::http::response::{ok_json_or_error, ok_or_error}; use crate::core::auth::extractor::AuthClaims; use crate::core::notification_service::NotificationService; @@ -13,10 +14,7 @@ pub fn initialize() -> Scope { } async fn get_telegram_config(_auth: AuthClaims, svc: web::Data) -> HttpResponse { - match svc.get_telegram_config() { - Ok(config) => HttpResponse::Ok().json(config), - Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})), - } + ok_json_or_error(svc.get_telegram_config()) } #[derive(Deserialize)] @@ -30,10 +28,7 @@ async fn set_telegram_config( svc: web::Data, body: web::Json, ) -> HttpResponse { - match svc.set_telegram_config(&body.bot_token, &body.chat_id) { - Ok(()) => HttpResponse::Ok().json(serde_json::json!({"saved": true})), - Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})), - } + ok_or_error(svc.set_telegram_config(&body.bot_token, &body.chat_id)) } async fn test_telegram(_auth: AuthClaims, svc: web::Data) -> HttpResponse { diff --git a/net-guardia/src/adapter/http/rate_limit.rs b/net-guardia/src/adapter/http/rate_limit.rs index 708416f..190c4a5 100644 --- a/net-guardia/src/adapter/http/rate_limit.rs +++ b/net-guardia/src/adapter/http/rate_limit.rs @@ -1,6 +1,7 @@ use actix_web::{HttpResponse, Responder, Scope, web}; use common::define::setting::*; +use crate::adapter::http::response::ok_or_error; use crate::core::rate_limit_service::RateLimitService; use crate::model::system::rate_limit_settings::RateLimitSettings; @@ -21,8 +22,5 @@ async fn get_config(service: web::Data) -> impl Responder { } async fn set_config(settings: web::Json, service: web::Data) -> impl Responder { - match service.update(&settings.into_inner()) { - Ok(()) => HttpResponse::Ok().json(serde_json::json!({"status": "ok"})), - Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})), - } + ok_or_error(service.update(&settings.into_inner())) } diff --git a/net-guardia/src/adapter/http/report.rs b/net-guardia/src/adapter/http/report.rs index 3c50057..60a87ce 100644 --- a/net-guardia/src/adapter/http/report.rs +++ b/net-guardia/src/adapter/http/report.rs @@ -1,9 +1,11 @@ use std::fs; use actix_web::{HttpResponse, Scope, web}; +use arc_swap::ArcSwap; use chrono::Local; use tokio::task::spawn_blocking; +use crate::adapter::http::response::ok_json_or_error; use crate::adapter::persistence::Database; use crate::core::auth::extractor::AuthClaims; use crate::core::email::report::generate_weekly_report; @@ -12,6 +14,9 @@ use crate::core::report::engine; use crate::infrastructure::secret_store::SecretStore; use crate::interface::port::secret_store::SecretStorePort; use crate::interface::port::setting::SettingRepo; +use crate::model::config::AppConfig; +use crate::model::error::misc::MiscError; + pub fn initialize() -> Scope { web::scope("/report") .route("/generate", web::post().to(generate_report)) @@ -19,12 +24,12 @@ pub fn initialize() -> Scope { .route("/send", web::post().to(send_report)) } -async fn generate_report(_auth: AuthClaims, db: web::Data) -> HttpResponse { - let report_dir = db - .get_setting("report_dir") - .ok() - .flatten() - .unwrap_or_else(|| "/var/lib/netguardia/reports".to_string()); +async fn generate_report( + _auth: AuthClaims, + db: web::Data, + config: web::Data>, +) -> HttpResponse { + let report_dir = config.load().system.report_dir.clone(); if let Err(e) = fs::create_dir_all(&report_dir) { return HttpResponse::InternalServerError().json(serde_json::json!({ "error": format!("Failed to create report directory: {}", e) @@ -57,18 +62,21 @@ async fn generate_report(_auth: AuthClaims, db: web::Data) -> HttpResp async fn report_data(_auth: AuthClaims, db: web::Data) -> HttpResponse { let db_ref = db.get_ref(); - match engine::generate_report_json(db_ref as &dyn SettingRepo) { - Ok(data) => HttpResponse::Ok().json(data), - Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})), - } + ok_json_or_error(engine::generate_report_json(db_ref as &dyn SettingRepo)) } /// Manually trigger: generate the weekly report and send it via SMTP now. -async fn send_report(_auth: AuthClaims, db: web::Data, secrets: web::Data) -> HttpResponse { +async fn send_report( + _auth: AuthClaims, + db: web::Data, + config: web::Data>, + secrets: web::Data, +) -> HttpResponse { let db_ref = db.get_ref() as &dyn SettingRepo; let secrets_ref = secrets.get_ref() as &dyn SecretStorePort; + let smtp_cfg = config.load().notification.smtp.clone(); - let smtp = match SmtpClient::from_database(db_ref, Some(secrets_ref)) { + let smtp = match SmtpClient::from_config(&smtp_cfg, Some(secrets_ref)) { Ok(Some(client)) => client, Ok(None) => { return HttpResponse::BadRequest().json(serde_json::json!({ @@ -84,15 +92,13 @@ async fn send_report(_auth: AuthClaims, db: web::Data, secrets: web::D } }; - let recipient = match db_ref.get_setting("smtp_recipient") { - Ok(Some(r)) if !r.is_empty() => r, - _ => { - return HttpResponse::BadRequest().json(serde_json::json!({ - "success": false, - "error": "No smtp_recipient configured." - })); - } - }; + let recipient = smtp_cfg.recipient; + if recipient.is_empty() { + return HttpResponse::BadRequest().json(serde_json::json!({ + "success": false, + "error": MiscError::ValidationError("No smtp_recipient configured.").to_string() + })); + } let html = match generate_weekly_report(db_ref) { Ok(h) => h, diff --git a/net-guardia/src/adapter/http/response.rs b/net-guardia/src/adapter/http/response.rs new file mode 100644 index 0000000..c906e29 --- /dev/null +++ b/net-guardia/src/adapter/http/response.rs @@ -0,0 +1,18 @@ +use std::fmt; + +use actix_web::HttpResponse; +use serde::Serialize; + +pub fn ok_or_error(result: Result) -> HttpResponse { + match result { + Ok(_) => HttpResponse::Ok().finish(), + Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})), + } +} + +pub fn ok_json_or_error(result: Result) -> HttpResponse { + match result { + Ok(value) => HttpResponse::Ok().json(value), + Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})), + } +} diff --git a/net-guardia/src/adapter/http/soar.rs b/net-guardia/src/adapter/http/soar.rs index e495696..cbaa1bf 100644 --- a/net-guardia/src/adapter/http/soar.rs +++ b/net-guardia/src/adapter/http/soar.rs @@ -3,6 +3,7 @@ use std::str::FromStr; use actix_web::{HttpResponse, Scope, web}; use serde::Deserialize; +use crate::adapter::http::response::{ok_json_or_error, ok_or_error}; use crate::core::auth::extractor::AuthClaims; use crate::core::playbook_service::PlaybookService; use crate::core::soar::engine::SoarEngine; @@ -126,18 +127,12 @@ async fn create_playbook( .unwrap_or_default() .iter() .map(|c| { - let default_op = match c.condition_type.as_str() { - "threshold" | "frequency" => ">=", - "source_country" | "ip_pattern" => "in", - "repeat_offender" => "==", - _ => ">=", - }; - CreateConditionInput { - condition_type: c.condition_type.clone(), - operator: c.operator.clone().unwrap_or_else(|| default_op.to_string()), - value: c.value.clone(), - value2: c.value2.clone(), - } + CreateConditionInput::new( + c.condition_type.clone(), + c.operator.clone(), + c.value.clone(), + c.value2.clone(), + ) }) .collect(); @@ -185,18 +180,12 @@ async fn update_playbook( .unwrap_or_default() .iter() .map(|c| { - let default_op = match c.condition_type.as_str() { - "threshold" | "frequency" => ">=", - "source_country" | "ip_pattern" => "in", - "repeat_offender" => "==", - _ => ">=", - }; - CreateConditionInput { - condition_type: c.condition_type.clone(), - operator: c.operator.clone().unwrap_or_else(|| default_op.to_string()), - value: c.value.clone(), - value2: c.value2.clone(), - } + CreateConditionInput::new( + c.condition_type.clone(), + c.operator.clone(), + c.value.clone(), + c.value2.clone(), + ) }) .collect(); @@ -265,10 +254,7 @@ async fn list_active_blocks(_auth: AuthClaims, svc: web::Data) } async fn manual_unblock(_auth: AuthClaims, svc: web::Data, path: web::Path) -> HttpResponse { - match svc.manual_unblock(path.into_inner()).await { - Ok(()) => HttpResponse::Ok().json(serde_json::json!({"unblocked": true})), - Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})), - } + ok_or_error(svc.manual_unblock(path.into_inner()).await) } async fn list_executions(_auth: AuthClaims, svc: web::Data) -> HttpResponse { @@ -294,10 +280,7 @@ async fn list_executions(_auth: AuthClaims, svc: web::Data) -> } async fn list_whitelist(_auth: AuthClaims, svc: web::Data) -> HttpResponse { - match svc.list_whitelist() { - Ok(ips) => HttpResponse::Ok().json(ips), - Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})), - } + ok_json_or_error(svc.list_whitelist()) } #[derive(Deserialize)] @@ -317,10 +300,7 @@ async fn add_whitelist( } async fn remove_whitelist(_auth: AuthClaims, svc: web::Data, path: web::Path) -> HttpResponse { - match svc.remove_whitelist(&path.into_inner()) { - Ok(()) => HttpResponse::Ok().json(serde_json::json!({"removed": true})), - Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})), - } + ok_or_error(svc.remove_whitelist(&path.into_inner())) } /// Client shape for `POST /api/soar/dry-run`. Only the fields a SOAR diff --git a/net-guardia/src/adapter/persistence/acl.rs b/net-guardia/src/adapter/persistence/acl.rs index 9c64e81..096291b 100644 --- a/net-guardia/src/adapter/persistence/acl.rs +++ b/net-guardia/src/adapter/persistence/acl.rs @@ -37,7 +37,7 @@ impl Database { Ok(()) } - pub fn load_acl_rules(&self) -> Result, Error> { + pub fn list_acl_rules(&self) -> Result, Error> { let conn = self.conn()?; let mut stmt = conn.prepare("SELECT ip_version, direction, list_type, ip_address, port FROM acl_rules")?; let rows = stmt.query_map([], |row| { @@ -66,7 +66,7 @@ impl Database { Ok(count > 0) } - pub fn load_admin_whitelist(&self) -> Result, Error> { + pub fn list_admin_whitelist(&self) -> Result, Error> { let conn = self.conn()?; let mut stmt = conn.prepare("SELECT ip FROM admin_whitelist")?; let rows = stmt.query_map([], |row| row.get::<_, String>(0))?; @@ -117,8 +117,8 @@ impl AclRepo for Database { self.has_manual_acl_rule(ip_address) } - fn load_admin_whitelist(&self) -> Result, Error> { - self.load_admin_whitelist() + fn list_admin_whitelist(&self) -> Result, Error> { + self.list_admin_whitelist() } fn insert_admin_whitelist(&self, ip: &str) -> Result<(), Error> { @@ -138,7 +138,7 @@ mod tests { fn test_acl_crud() { let db = test_db(); db.insert_acl_rule(4, "source", "blacklist", "192.168.1.1", 80).unwrap(); - let rules = db.load_acl_rules().unwrap(); + let rules = db.list_acl_rules().unwrap(); assert_eq!(rules.len(), 1); assert_eq!( rules[0], @@ -152,7 +152,7 @@ mod tests { ); db.delete_acl_rule(4, "source", "blacklist", "192.168.1.1", 80).unwrap(); - let rules = db.load_acl_rules().unwrap(); + let rules = db.list_acl_rules().unwrap(); assert!(rules.is_empty()); } } diff --git a/net-guardia/src/adapter/persistence/api_key.rs b/net-guardia/src/adapter/persistence/api_key.rs index 00ae325..bf3b88c 100644 --- a/net-guardia/src/adapter/persistence/api_key.rs +++ b/net-guardia/src/adapter/persistence/api_key.rs @@ -106,7 +106,6 @@ impl Database { Ok(conn.last_insert_rowid()) } - #[allow(clippy::type_complexity)] pub fn list_api_keys(&self) -> Result)>, Error> { let conn = self.conn()?; let mut stmt = conn.prepare("SELECT id, name, permission_level, created_at, last_used_at FROM api_keys")?; diff --git a/net-guardia/src/adapter/persistence/mod.rs b/net-guardia/src/adapter/persistence/mod.rs index c64ae6a..8ee9afe 100644 --- a/net-guardia/src/adapter/persistence/mod.rs +++ b/net-guardia/src/adapter/persistence/mod.rs @@ -435,7 +435,7 @@ mod tests { acl.insert_acl_rule(4, "source", "blacklist", "10.0.0.1", 443).unwrap(); // load_acl_rules is an inherent Database method (not on AclRepo), // so go through `&db` directly for this read-back assertion. - let rules = db.load_acl_rules().unwrap(); + let rules = db.list_acl_rules().unwrap(); assert_eq!(rules.len(), 1); let identity: &dyn IdentityRepo = &db; diff --git a/net-guardia/src/adapter/persistence/setting.rs b/net-guardia/src/adapter/persistence/setting.rs index 61b654b..e6410ae 100644 --- a/net-guardia/src/adapter/persistence/setting.rs +++ b/net-guardia/src/adapter/persistence/setting.rs @@ -1,4 +1,4 @@ -use rusqlite::{Error as RusqliteError, params}; +use rusqlite::{Error as RusqliteError, Transaction, params}; use super::Database; use crate::interface::port::setting::SettingRepo; @@ -95,11 +95,103 @@ impl SettingRepo for Database { fn set_notification_config(&self, channel: &str, config_json: &str) -> Result<(), Error> { self.set_notification_config(channel, config_json) } + + fn transaction(&self, f: &mut dyn FnMut(&dyn SettingRepo) -> Result<(), Error>) -> Result<(), Error> { + let mut conn = self.conn()?; + let tx = conn.transaction()?; + let view = SettingTxView { tx: &tx }; + match f(&view) { + Ok(()) => { + tx.commit()?; + Ok(()) + } + Err(e) => { + let _ = tx.rollback(); + Err(e) + } + } + } +} + +struct SettingTxView<'a> { + tx: &'a Transaction<'a>, +} + +impl SettingRepo for SettingTxView<'_> { + fn transaction(&self, f: &mut dyn FnMut(&dyn SettingRepo) -> Result<(), Error>) -> Result<(), Error> { + f(self) + } + + fn get_setting(&self, key: &str) -> Result, Error> { + let result = self + .tx + .query_row("SELECT value FROM settings WHERE key = ?1", params![key], |row| { + row.get(0) + }); + match result { + Ok(val) => Ok(Some(val)), + Err(RusqliteError::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e)?, + } + } + + fn set_setting(&self, key: &str, value: &str) -> Result<(), Error> { + self.tx.execute( + "INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)", + params![key, value], + )?; + Ok(()) + } + + fn get_app_secret(&self, key: &str) -> Result, Error> { + let result = self + .tx + .query_row("SELECT value FROM app_secrets WHERE key = ?1", params![key], |row| { + row.get(0) + }); + match result { + Ok(val) => Ok(Some(val)), + Err(RusqliteError::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e)?, + } + } + + fn set_app_secret(&self, key: &str, plaintext: &str) -> Result<(), Error> { + self.tx.execute( + "INSERT OR REPLACE INTO app_secrets (key, value) VALUES (?1, ?2)", + params![key, plaintext], + )?; + Ok(()) + } + + fn get_notification_config(&self, channel: &str) -> Result, Error> { + match self.tx.query_row( + "SELECT config_json FROM notification_config WHERE channel = ?1 AND enabled = 1", + params![channel], + |row| row.get::<_, String>(0), + ) { + Ok(json) => Ok(Some(json)), + Err(RusqliteError::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e)?, + } + } + + fn set_notification_config(&self, channel: &str, config_json: &str) -> Result<(), Error> { + self.tx.execute( + "INSERT INTO notification_config (channel, config_json) VALUES (?1, ?2) \ + ON CONFLICT(channel) DO UPDATE SET config_json = ?2, updated_at = datetime('now')", + params![channel, config_json], + )?; + Ok(()) + } } #[cfg(test)] mod tests { use super::super::tests::test_db; + use crate::interface::port::setting::SettingRepo; + use crate::model::error::Error; + use crate::model::error::misc::MiscError; #[test] fn test_settings_crud() { @@ -112,4 +204,58 @@ mod tests { db.set_setting("foo", "baz").unwrap(); assert_eq!(db.get_setting("foo").unwrap(), Some("baz".to_string())); } + + #[test] + fn tx_commits_on_ok() { + let db = test_db(); + db.transaction(&mut |repo| { + repo.set_setting("a", "1")?; + repo.set_setting("b", "2")?; + Ok(()) + }) + .unwrap(); + assert_eq!(db.get_setting("a").unwrap().as_deref(), Some("1")); + assert_eq!(db.get_setting("b").unwrap().as_deref(), Some("2")); + } + + #[test] + fn tx_rolls_back_on_err() { + let db = test_db(); + db.set_setting("a", "initial").unwrap(); + let result: Result<(), Error> = db.transaction(&mut |repo| { + repo.set_setting("a", "changed")?; + repo.set_setting("b", "new")?; + Err(MiscError::ValidationError("bail".to_string()))? + }); + assert!(result.is_err()); + assert_eq!(db.get_setting("a").unwrap().as_deref(), Some("initial")); + assert_eq!(db.get_setting("b").unwrap(), None); + } + + #[test] + fn tx_reads_see_pending_writes() { + let db = test_db(); + db.transaction(&mut |repo| { + repo.set_setting("k", "v")?; + assert_eq!(repo.get_setting("k")?.as_deref(), Some("v")); + Ok(()) + }) + .unwrap(); + } + + #[test] + fn tx_covers_app_secrets_table() { + let db = test_db(); + db.transaction(&mut |repo| { + repo.set_app_secret("smtp_password", "envelope_blob")?; + repo.set_setting("smtp_password", "")?; + Ok(()) + }) + .unwrap(); + assert_eq!( + db.get_app_secret("smtp_password").unwrap().as_deref(), + Some("envelope_blob") + ); + assert_eq!(db.get_setting("smtp_password").unwrap().as_deref(), Some("")); + } } diff --git a/net-guardia/src/adapter/persistence/soar.rs b/net-guardia/src/adapter/persistence/soar.rs index 001505a..8f06e54 100644 --- a/net-guardia/src/adapter/persistence/soar.rs +++ b/net-guardia/src/adapter/persistence/soar.rs @@ -3,7 +3,7 @@ use rusqlite::params; use super::Database; use crate::interface::port::soar::{PlaybookRow, SoarExecutionRow, SoarRepo}; use crate::model::error::Error; -use crate::model::soar::playbook_data::UpdatePlaybookRow; +use crate::model::soar::playbook_data::UpdatePlaybookInput; impl Database { pub fn insert_playbook( @@ -40,8 +40,7 @@ impl Database { /// Load all playbooks with their actions in a single JOIN query (avoids N+1). /// Returns Vec of (playbook fields..., action fields...). - #[allow(clippy::type_complexity)] - pub fn load_playbooks_with_actions( + pub fn list_playbooks_with_actions( &self, ) -> Result< Vec<( @@ -124,8 +123,7 @@ impl Database { Ok(conn.last_insert_rowid()) } - #[allow(clippy::type_complexity)] - pub fn load_all_playbook_conditions( + pub fn list_all_playbook_conditions( &self, ) -> Result)>, Error> { let conn = self.conn()?; @@ -165,7 +163,6 @@ impl Database { Ok(conn.last_insert_rowid()) } - #[allow(clippy::type_complexity)] pub fn list_soar_executions( &self, limit: i64, @@ -239,8 +236,8 @@ impl Database { } impl SoarRepo for Database { - fn load_playbooks_with_actions(&self) -> Result, Error> { - self.load_playbooks_with_actions() + fn list_playbooks_with_actions(&self) -> Result, Error> { + self.list_playbooks_with_actions() } fn update_playbook_enabled(&self, id: i64, enabled: bool) -> Result { @@ -255,24 +252,24 @@ impl SoarRepo for Database { self.seed_default_playbooks() } - fn load_all_playbook_conditions(&self) -> Result)>, Error> { - self.load_all_playbook_conditions() + fn list_all_playbook_conditions(&self) -> Result)>, Error> { + self.list_all_playbook_conditions() } fn count_active_soar_blocks(&self) -> Result { self.count_active_soar_blocks() } - fn get_active_soar_blocks(&self) -> Result, Error> { - self.get_active_soar_blocks() + fn list_active_soar_blocks(&self) -> Result, Error> { + self.list_active_soar_blocks() } - fn get_soar_block_by_id(&self, id: i64) -> Result, Error> { - self.get_soar_block_by_id(id) + fn find_soar_block_by_id(&self, id: i64) -> Result, Error> { + self.find_soar_block_by_id(id) } - fn get_expired_soar_blocks(&self) -> Result, Error> { - self.get_expired_soar_blocks() + fn list_expired_soar_blocks(&self) -> Result, Error> { + self.list_expired_soar_blocks() } fn mark_soar_block_unblocked(&self, id: i64) -> Result<(), Error> { @@ -283,8 +280,8 @@ impl SoarRepo for Database { self.insert_pending_unblock(source_ip) } - fn load_pending_unblocks(&self) -> Result, Error> { - self.load_pending_unblocks() + fn list_pending_unblocks(&self) -> Result, Error> { + self.list_pending_unblocks() } fn delete_pending_unblock(&self, id: i64) -> Result<(), Error> { @@ -346,7 +343,7 @@ impl SoarRepo for Database { fn update_playbook_atomic( &self, id: i64, - row: &UpdatePlaybookRow, + row: &UpdatePlaybookInput, actions: &[(i64, String, String)], conditions: &[(String, String, String, Option)], ) -> Result { @@ -405,9 +402,9 @@ mod tests { .insert_playbook_atomic("atom_pb", "threat", Some(0.8), None, None, 300, &actions, &conditions) .unwrap(); assert!(id > 0); - let loaded = db.load_playbooks_with_actions().unwrap(); + let loaded = db.list_playbooks_with_actions().unwrap(); assert!(!loaded.is_empty()); - let cond_rows = db.load_all_playbook_conditions().unwrap(); + let cond_rows = db.list_all_playbook_conditions().unwrap(); assert_eq!(cond_rows.len(), 1); } } diff --git a/net-guardia/src/adapter/persistence/soar_block.rs b/net-guardia/src/adapter/persistence/soar_block.rs index b68652b..05bb3c7 100644 --- a/net-guardia/src/adapter/persistence/soar_block.rs +++ b/net-guardia/src/adapter/persistence/soar_block.rs @@ -28,7 +28,7 @@ impl Database { Ok(count) } - pub fn get_expired_soar_blocks(&self) -> Result, Error> { + pub fn list_expired_soar_blocks(&self) -> Result, Error> { let conn = self.conn()?; let mut stmt = conn.prepare( "SELECT id, source_ip, playbook_id FROM soar_block_rules WHERE expires_at <= datetime('now') AND unblocked_at IS NULL" @@ -44,7 +44,7 @@ impl Database { } /// Get a single SOAR block rule by ID, returning (id, source_ip, playbook_id, expires_at). - pub fn get_soar_block_by_id(&self, id: i64) -> Result, Error> { + pub fn find_soar_block_by_id(&self, id: i64) -> Result, Error> { let conn = self.conn()?; let mut stmt = conn.prepare("SELECT id, source_ip, playbook_id, expires_at FROM soar_block_rules WHERE id = ?1")?; @@ -71,7 +71,7 @@ impl Database { Ok(()) } - pub fn get_active_soar_blocks(&self) -> Result, Error> { + pub fn list_active_soar_blocks(&self) -> Result, Error> { let conn = self.conn()?; let mut stmt = conn.prepare( "SELECT id, source_ip, playbook_id, expires_at FROM soar_block_rules WHERE unblocked_at IS NULL AND expires_at > datetime('now')" @@ -100,7 +100,7 @@ impl Database { Ok(conn.last_insert_rowid()) } - pub fn load_pending_unblocks(&self) -> Result, Error> { + pub fn list_pending_unblocks(&self) -> Result, Error> { let conn = self.conn()?; let mut stmt = conn.prepare("SELECT id, source_ip, retry_count FROM pending_unblock ORDER BY id")?; let rows = stmt.query_map([], |row| { @@ -197,12 +197,12 @@ mod tests { assert!(soar_block_id > 0); // soar_block_rules has the row - let active = db.get_active_soar_blocks().unwrap(); + let active = db.list_active_soar_blocks().unwrap(); assert_eq!(active.len(), 1); assert_eq!(active[0].1, "10.0.0.99"); // acl_rules has the matching row - let rules = db.load_acl_rules().unwrap(); + let rules = db.list_acl_rules().unwrap(); assert_eq!(rules.len(), 1); assert_eq!(rules[0].3, "10.0.0.99"); } @@ -222,8 +222,8 @@ mod tests { db.commit_soar_unblock_to_db(soar_block_id, 4, "10.0.0.99").unwrap(); // acl_rules row gone - assert!(db.load_acl_rules().unwrap().is_empty()); + assert!(db.list_acl_rules().unwrap().is_empty()); // soar_block_rules row no longer in "active" view (unblocked_at is set) - assert!(db.get_active_soar_blocks().unwrap().is_empty()); + assert!(db.list_active_soar_blocks().unwrap().is_empty()); } } diff --git a/net-guardia/src/adapter/persistence/user.rs b/net-guardia/src/adapter/persistence/user.rs index f4c09b2..ea193c4 100644 --- a/net-guardia/src/adapter/persistence/user.rs +++ b/net-guardia/src/adapter/persistence/user.rs @@ -224,7 +224,7 @@ impl Database { } } - pub fn get_user_groups(&self, user_id: i64) -> Result, Error> { + pub fn list_groups_for_user(&self, user_id: i64) -> Result, Error> { let conn = self.conn()?; let mut stmt = conn.prepare( "SELECT g.id, g.name, g.description, g.permissions FROM user_groups g \ @@ -258,8 +258,8 @@ impl Database { Ok(()) } - pub fn get_user_permissions(&self, user_id: i64) -> Result, Error> { - let groups = self.get_user_groups(user_id)?; + pub fn list_user_permissions(&self, user_id: i64) -> Result, Error> { + let groups = self.list_groups_for_user(user_id)?; let mut all_perms = HashSet::new(); for (_id, _name, _desc, perms_json) in groups { if let Ok(perms) = serde_json::from_str::>(&perms_json) { @@ -279,7 +279,7 @@ impl Database { Ok(()) } - pub fn get_group_member_ids(&self, group_id: i64) -> Result, Error> { + pub fn list_group_member_ids(&self, group_id: i64) -> Result, Error> { let conn = self.conn()?; let mut stmt = conn.prepare("SELECT user_id FROM user_group_members WHERE group_id = ?1")?; let rows = stmt.query_map(params![group_id], |row| row.get::<_, i64>(0))?; @@ -290,7 +290,7 @@ impl Database { Ok(results) } - pub fn get_group_members(&self, group_id: i64) -> Result, Error> { + pub fn list_group_members(&self, group_id: i64) -> Result, Error> { let conn = self.conn()?; let mut stmt = conn.prepare( "SELECT u.id, u.username FROM users u \ @@ -419,24 +419,24 @@ impl IdentityRepo for Database { self.get_user_group(id) } - fn get_user_groups(&self, user_id: i64) -> Result, Error> { - self.get_user_groups(user_id) + fn list_groups_for_user(&self, user_id: i64) -> Result, Error> { + self.list_groups_for_user(user_id) } fn set_user_groups(&self, user_id: i64, group_ids: &[i64]) -> Result<(), Error> { self.set_user_groups(user_id, group_ids) } - fn get_user_permissions(&self, user_id: i64) -> Result, Error> { - self.get_user_permissions(user_id) + fn list_user_permissions(&self, user_id: i64) -> Result, Error> { + self.list_user_permissions(user_id) } - fn get_group_member_ids(&self, group_id: i64) -> Result, Error> { - self.get_group_member_ids(group_id) + fn list_group_member_ids(&self, group_id: i64) -> Result, Error> { + self.list_group_member_ids(group_id) } - fn get_group_members(&self, group_id: i64) -> Result, Error> { - self.get_group_members(group_id) + fn list_group_members(&self, group_id: i64) -> Result, Error> { + self.list_group_members(group_id) } fn record_login_failure(&self, username: &str) -> Result<(u32, Option), Error> { diff --git a/net-guardia/src/adapter/telegram.rs b/net-guardia/src/adapter/telegram.rs index ab56748..d8eb89c 100644 --- a/net-guardia/src/adapter/telegram.rs +++ b/net-guardia/src/adapter/telegram.rs @@ -2,16 +2,16 @@ use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use arc_swap::ArcSwap; use async_trait::async_trait; use macros::log; use reqwest::Client; use tokio::time::sleep; -use crate::interface::port::app_repo::AppRepo; use crate::interface::port::notification::{AlertNotifier, AlertNotifierFactory, AlertPayload}; use crate::interface::port::secret_store::SecretStorePort; use crate::interface::port::setting::SettingRepo; -use crate::model::config::constants::TELEGRAM_MAX_RETRIES; +use crate::model::config::AppConfig; use crate::model::error::Error; use crate::model::error::notification::NotificationError; use crate::model::log::system::SystemLog; @@ -19,8 +19,8 @@ use crate::model::log::system::SystemLog; /// Telegram Bot API adapter implementing AlertNotifier. pub struct TelegramAdapter { client: Client, - notif: Arc, - repo: Arc, + notif: Arc, + config: Arc>, secrets: Option>, /// Packed rate-limit state: high 32 bits = window-start unix seconds, /// low 32 bits = count consumed in this window. Updated via CAS so the @@ -30,8 +30,8 @@ pub struct TelegramAdapter { impl TelegramAdapter { pub fn new( - notif: Arc, - repo: Arc, + notif: Arc, + config: Arc>, secrets: Option>, ) -> Result { let client = Client::builder() @@ -42,7 +42,7 @@ impl TelegramAdapter { Ok(Self { client, notif, - repo, + config, secrets, rate_state: AtomicU64::new(0), }) @@ -75,24 +75,13 @@ impl TelegramAdapter { } } - /// 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) + self.config.load().notification.telegram.rate_limit_max_messages } - /// Read the configured window length (seconds) from settings. + /// Read the configured window length (seconds) from the live config. 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) + self.config.load().notification.telegram.rate_limit_window_secs } /// Check rate limit. Returns true if send is allowed. @@ -134,8 +123,9 @@ impl TelegramAdapter { /// Send a message via Telegram Bot API with retry on 429. async fn send_message(&self, bot_token: &str, chat_id: &str, text: &str) -> Result<(), Error> { let url = format!("https://api.telegram.org/bot{}/sendMessage", bot_token); + let max_retries = self.config.load().notification.telegram.max_retries; - for attempt in 0..=TELEGRAM_MAX_RETRIES { + for attempt in 0..=max_retries { let resp = self .client .post(&url) @@ -181,11 +171,11 @@ impl TelegramAdapter { .and_then(|r| r.as_u64()) .unwrap_or(5); - if attempt < TELEGRAM_MAX_RETRIES { + if attempt < max_retries { log!(SystemLog::TelegramRateLimitedRetry( retry_after, attempt + 1, - TELEGRAM_MAX_RETRIES, + max_retries, )); sleep(Duration::from_secs(retry_after)).await; continue; @@ -268,20 +258,24 @@ impl AlertNotifier for TelegramAdapter { /// call instantiates a fresh `TelegramAdapter` so the test path observes /// whatever config the user just saved. pub struct TelegramAdapterFactory { - notif: Arc, - repo: Arc, + notif: Arc, + config: Arc>, secrets: Option>, } impl TelegramAdapterFactory { - pub fn new(notif: Arc, repo: Arc, secrets: Option>) -> Self { - Self { notif, repo, secrets } + pub fn new( + notif: Arc, + config: Arc>, + secrets: Option>, + ) -> Self { + Self { notif, config, secrets } } } impl AlertNotifierFactory for TelegramAdapterFactory { fn create(&self) -> Result, Error> { - let adapter = TelegramAdapter::new(self.notif.clone(), self.repo.clone(), self.secrets.clone())?; + let adapter = TelegramAdapter::new(self.notif.clone(), self.config.clone(), self.secrets.clone())?; Ok(Arc::new(adapter)) } } diff --git a/net-guardia/src/core/config_service.rs b/net-guardia/src/core/config_service.rs index 8c4052d..2a90739 100644 --- a/net-guardia/src/core/config_service.rs +++ b/net-guardia/src/core/config_service.rs @@ -1,9 +1,10 @@ use std::sync::Arc; -use serde_json::Value; +use arc_swap::ArcSwap; use crate::interface::port::app_repo::AppRepo; use crate::interface::port::secret_store::SecretStorePort; +use crate::model::config::AppConfig; use crate::model::error::Error; use crate::model::error::misc::MiscError; @@ -54,25 +55,153 @@ const SETTINGS_MAP: &[(&str, &[&str])] = &[ // report_dir and log_dir intentionally NOT configurable via API to prevent // arbitrary directory write/read. They use hardcoded safe defaults. ("misc", &["geoip_db_name"]), - ("soar", &["soar_max_auto_block_cap", "soar_max_ttl_secs"]), - ("ml", &["ml_drift_window_secs"]), + ( + "soar", + &[ + "soar_max_auto_block_cap", + "soar_max_ttl_secs", + "soar_handle_concurrency", + "soar_max_pending_unblock_retries", + "soar_default_block_ttl_secs", + "soar_default_rate_limit_factor", + "soar_default_rate_limit_ttl_secs", + "soar_default_webhook_timeout_secs", + "soar_default_frequency_window_secs", + "soar_default_single_source_high_min_confidence", + "soar_default_cooldown_expiry_secs", + "soar_rate_limit_cmd_channel_capacity", + "soar_frequency_max_tracked_keys", + "soar_fallback_cooldown_secs", + ], + ), + ( + "ml", + &[ + "ml_drift_window_secs", + "ml_min_packets_floor", + "ml_confirmation_window_fraction", + "ml_drift_max_snapshots", + "ml_drift_channel_capacity", + "ml_alert_channel_capacity", + "ml_circuit_breaker_threshold", + "ml_circuit_breaker_window_secs", + "ml_circuit_breaker_cooldown_secs", + "ml_onnx_load_timeout_secs", + "ml_model_watcher_debounce_secs", + "ml_flow_max_packets_per_direction", + "ml_flow_max_periods", + "ml_flow_idle_threshold_us", + "ml_flow_bulk_min_packets", + "ml_flow_bulk_min_bytes", + "ml_flow_idle_timeout_us", + "ml_flow_terminated_timeout_us", + ], + ), + ( + "flow_trace", + &[ + "flow_trace_max_file_bytes", + "flow_trace_max_file_age_secs", + "flow_trace_total_budget_bytes", + "traffic_logger_channel_capacity", + ], + ), + ( + "model_upload", + &[ + "model_upload_max_onnx_bytes", + "model_upload_max_manifest_bytes", + "model_upload_max_scaler_bytes", + ], + ), ( "telegram", - &["telegram_rate_limit_max_messages", "telegram_rate_limit_window_secs"], + &[ + "telegram_rate_limit_max_messages", + "telegram_rate_limit_window_secs", + "telegram_max_retries", + ], ), ("dns", &["dns_max_domains_per_request"]), ("smtp", &["smtp_host", "smtp_port", "smtp_username", "smtp_recipient"]), + ( + "suricata", + &[ + "suricata_enabled", + "suricata_binary_path", + "suricata_config_path", + "suricata_eve_log_path", + "suricata_auto_restart_on_crash", + "suricata_restart_backoff_secs", + "suricata_poll_interval_ms", + "suricata_file_wait_interval_secs", + "suricata_confidence_high", + "suricata_confidence_medium", + "suricata_confidence_low", + "suricata_confidence_info", + ], + ), + ("detection", &["detection_cleanup_interval_secs"]), + ( + "fusion", + &[ + "fusion_dedup_window_secs", + "fusion_repeat_offender_window_secs", + "fusion_max_dedup_entries", + ], + ), + ( + "beaconing", + &[ + "beaconing_analysis_interval_secs", + "beaconing_min_observations", + "beaconing_cv_threshold", + "beaconing_max_cache_entries", + "beaconing_expiry_secs", + "beaconing_alert_cooldown_secs", + ], + ), + ( + "correlation", + &[ + "correlation_scan_window_secs", + "correlation_scan_threshold", + "correlation_lateral_window_secs", + "correlation_lateral_threshold", + "correlation_botnet_window_secs", + "correlation_botnet_threshold", + "correlation_max_tracked_entries", + ], + ), + ( + "observability", + &[ + "log_buffer_capacity", + "log_buffer_max_message_bytes", + "log_max_download_size", + "log_live_default_limit", + "log_live_max_limit", + "fusion_explain_scan_limit", + "fusion_explain_response_cap", + "default_event_channel_capacity", + "drop_channel_capacity", + ], + ), ]; -/// Domain service for system configuration read/write. pub struct ConfigService { db: Arc, secrets: Option>, + app_config: Arc>, } impl ConfigService { - pub fn new(db: Arc) -> Self { - Self { db, secrets: None } + pub fn new(db: Arc, app_config: Arc>) -> Self { + Self { + db, + secrets: None, + app_config, + } } pub fn with_secret_store(mut self, secrets: Arc) -> Self { @@ -80,148 +209,98 @@ impl ConfigService { self } - /// Read all user-configurable settings from DB as structured JSON. pub fn get_config(&self) -> serde_json::Value { let get = |key: &str| -> String { self.db.get_setting(key).ok().flatten().unwrap_or_default() }; - serde_json::json!({ - "network": { - "ingress_interface": get("ingress_interface"), - "egress_interface": get("egress_interface"), - "refresh_interval": get("refresh_interval"), - }, - "http": { - "http_port": get("http_port"), - "jwt_expiry_hours": get("jwt_expiry_hours"), - "force_https": get("force_https"), - }, - "inference": { - "max_concurrent_flows": get("max_concurrent_flows"), - "min_packets_for_inference": get("min_packets_for_inference"), - "inference_interval_secs": get("inference_interval_secs"), - "aggregator_window_secs": get("aggregator_window_secs"), - "inference_batch_size": get("inference_batch_size"), - "traffic_logging_mode": get("traffic_logging_mode"), - "traffic_log_csv_path": get("traffic_log_csv_path"), - }, - "xdp": { - "combined_queue_count": get("combined_queue_count"), - "channel_size": get("channel_size"), - "fill_queue_size": get("fill_queue_size"), - "comp_queue_size": get("comp_queue_size"), - "tx_queue_size": get("tx_queue_size"), - "rx_queue_size": get("rx_queue_size"), - "frame_size": get("frame_size"), - "frame_count": get("frame_count"), - "packet_buffer_size": get("packet_buffer_size"), - "buffer_pool_capacity": get("buffer_pool_capacity"), - }, - "models": { - "deep_autoencoder_name": get("deep_autoencoder_name"), - "classifier_name": get("classifier_name"), - "models_config_name": get("models_config_name"), - }, - "misc": { - "geoip_db_name": get("geoip_db_name"), - }, - "soar": { - "soar_max_auto_block_cap": get("soar_max_auto_block_cap"), - "soar_max_ttl_secs": get("soar_max_ttl_secs"), - }, - "ml": { - "ml_drift_window_secs": get("ml_drift_window_secs"), - }, - "telegram": { - "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"), - }, - "pipeline": { + let mut root = serde_json::Map::new(); + for (section, keys) in SETTINGS_MAP { + let mut section_obj = serde_json::Map::new(); + for key in *keys { + section_obj.insert(key.to_string(), serde_json::Value::String(get(key))); + } + root.insert(section.to_string(), serde_json::Value::Object(section_obj)); + } + root.insert( + "pipeline".to_string(), + serde_json::json!({ "ingress": get("pipeline_ingress"), "egress": get("pipeline_egress"), - }, - "smtp": { - "smtp_host": get("smtp_host"), - "smtp_port": get("smtp_port"), - "smtp_username": get("smtp_username"), - "smtp_recipient": get("smtp_recipient"), - }, - }) + }), + ); + serde_json::Value::Object(root) } - /// Update settings from a JSON body. Returns list of updated keys. - /// Validates pipeline stage names. Only writes non-empty values. pub fn update_config(&self, body: &serde_json::Value) -> Result, Error> { - let mut updated = Vec::new(); + let mut updated: Vec = Vec::new(); + let mut new_cfg: Option = None; - // Standard key-value settings - for (section, keys) in SETTINGS_MAP { - if let Some(section_obj) = body.get(section).and_then(|v| v.as_object()) { - for key in *keys { - if let Some(val) = section_obj.get(*key).and_then(json_value_as_string) { - self.db.set_setting(key, &val)?; + self.db.transaction(&mut |repo| { + for (section, keys) in SETTINGS_MAP { + if let Some(section_obj) = body.get(section).and_then(|v| v.as_object()) { + for key in *keys { + if let Some(val) = section_obj.get(*key).and_then(json_value_as_string) { + repo.set_setting(key, &val)?; + updated.push(key.to_string()); + } + } + } + } + + if let Some(ref secrets) = self.secrets { + for key in SECRET_KEYS { + let section = key.split('_').next().unwrap_or(""); + if let Some(val) = body + .get(section) + .and_then(|v| v.as_object()) + .and_then(|obj| obj.get(*key)) + .and_then(json_value_as_string) + { + let envelope = secrets.encrypt_envelope(&val)?; + repo.set_app_secret(key, &envelope)?; + repo.set_setting(key, "")?; updated.push(key.to_string()); } } } - } - // Route secret keys through SecretStore (encrypted storage). - // After writing to SecretStore, scrub the plaintext row in `settings` - // so a legacy plaintext value from pre-SecretStore deployments cannot - // linger — readers fall back to SecretStore when the plaintext row - // is empty. - if let Some(ref secrets) = self.secrets { - for key in SECRET_KEYS { - // Secret keys live under their parent section (e.g., smtp_password under smtp) - let section = key.split('_').next().unwrap_or(""); - if let Some(val) = body - .get(section) - .and_then(|v| v.as_object()) - .and_then(|obj| obj.get(*key)) - .and_then(json_value_as_string) - { - secrets.set_secret(key, &val)?; - self.db.set_setting(key, "")?; - updated.push(key.to_string()); - } - } - } - - // Pipeline settings — validate stage names - if let Some(pipeline_obj) = body.get("pipeline").and_then(|v| v.as_object()) { - for (field, db_key) in [("ingress", "pipeline_ingress"), ("egress", "pipeline_egress")] { - if let Some(val) = pipeline_obj.get(field).and_then(|v| v.as_str()) { - if !val.is_empty() { - let stages: Vec<&str> = val.split(',').map(|s| s.trim()).collect(); - for stage in &stages { - if !stage.is_empty() && !VALID_PIPELINE_STAGES.contains(stage) { - Err(MiscError::ValidationError(format!( - "Invalid pipeline stage '{}'. Valid stages: {}", - stage, - VALID_PIPELINE_STAGES.join(", ") - )))?; + if let Some(pipeline_obj) = body.get("pipeline").and_then(|v| v.as_object()) { + for (field, db_key) in [("ingress", "pipeline_ingress"), ("egress", "pipeline_egress")] { + if let Some(val) = pipeline_obj.get(field).and_then(|v| v.as_str()) { + if !val.is_empty() { + let stages: Vec<&str> = val.split(',').map(|s| s.trim()).collect(); + for stage in &stages { + if !stage.is_empty() && !VALID_PIPELINE_STAGES.contains(stage) { + Err(MiscError::ValidationError(format!( + "Invalid pipeline stage '{}'. Valid stages: {}", + stage, + VALID_PIPELINE_STAGES.join(", ") + )))?; + } } } + repo.set_setting(db_key, val)?; + updated.push(db_key.to_string()); } - self.db.set_setting(db_key, val)?; - updated.push(db_key.to_string()); } } + + new_cfg = Some(AppConfig::from_settings(repo)?); + Ok(()) + })?; + + if let Some(cfg) = new_cfg { + self.app_config.store(Arc::new(cfg)); } Ok(updated) } } -/// Extract a JSON value as a non-empty string, handling string, boolean, and number types. fn json_value_as_string(v: &serde_json::Value) -> Option { match v { - Value::String(s) if !s.is_empty() => Some(s.clone()), - Value::Bool(b) => Some(b.to_string()), - Value::Number(n) => Some(n.to_string()), + serde_json::Value::String(s) if !s.is_empty() => Some(s.clone()), + serde_json::Value::Bool(b) => Some(b.to_string()), + serde_json::Value::Number(n) => Some(n.to_string()), _ => None, } } diff --git a/net-guardia/src/core/correlation/botnet.rs b/net-guardia/src/core/correlation/botnet.rs index ff732df..f3084bd 100644 --- a/net-guardia/src/core/correlation/botnet.rs +++ b/net-guardia/src/core/correlation/botnet.rs @@ -5,19 +5,11 @@ use dashmap::DashMap; use macros::log; use tokio::sync::mpsc; +use crate::model::config::correlation::CorrelationDetectorParams; use crate::model::detection::ml_detection::AlertMessage; use crate::model::event::{DetectionEvent, DetectionSource}; use crate::model::log::detection::DetectionLog; -/// Window within which unique sources are counted toward a single destination. -const BOTNET_WINDOW_SECS: u64 = 300; // 5 minutes - -/// Minimum unique source IPs targeting the same destination to trigger a botnet alert. -const BOTNET_THRESHOLD: usize = 10; - -/// Maximum tracked destination IPs to bound memory. -const MAX_TRACKED_DSTS: usize = 10_000; - struct TimedSourceSet { sources: HashSet, window_start: Instant, @@ -31,15 +23,19 @@ pub struct BotnetDetector { /// dst_ip → set of unique src_ips within the time window state: DashMap, window: Duration, + window_secs: u64, threshold: usize, + max_tracked: usize, } impl BotnetDetector { - pub fn new() -> Self { + pub fn new(params: &CorrelationDetectorParams, max_tracked: usize) -> Self { Self { state: DashMap::new(), - window: Duration::from_secs(BOTNET_WINDOW_SECS), - threshold: BOTNET_THRESHOLD, + window: Duration::from_secs(params.window_secs), + window_secs: params.window_secs, + threshold: params.threshold, + max_tracked, } } @@ -77,7 +73,7 @@ impl BotnetDetector { log!(DetectionLog::BotnetDetected( key.clone(), unique_sources, - BOTNET_WINDOW_SECS, + self.window_secs, )); // source_ip = the latest attacker; dest_ip = the victim being targeted. @@ -116,8 +112,8 @@ impl BotnetDetector { .retain(|_, set| now.duration_since(set.window_start) < window); // Enforce max capacity by removing oldest entries if over limit - if self.state.len() > MAX_TRACKED_DSTS { - let excess = self.state.len() - MAX_TRACKED_DSTS; + if self.state.len() > self.max_tracked { + let excess = self.state.len() - self.max_tracked; let keys_to_remove: Vec = self.state.iter().take(excess).map(|e| e.key().clone()).collect(); for key in keys_to_remove { self.state.remove(&key); @@ -154,9 +150,16 @@ mod tests { } } + fn test_params() -> CorrelationDetectorParams { + CorrelationDetectorParams { + window_secs: 300, + threshold: 10, + } + } + #[tokio::test] async fn botnet_threshold_triggers_alert() { - let detector = BotnetDetector::new(); + let detector = BotnetDetector::new(&test_params(), 10_000); let (tx, mut rx) = mpsc::channel(64); // Send alerts from 9 different sources (below threshold) @@ -181,7 +184,9 @@ mod tests { let detector = BotnetDetector { state: DashMap::new(), window: Duration::from_millis(10), - threshold: BOTNET_THRESHOLD, + window_secs: 0, + threshold: 10, + max_tracked: 10_000, }; let (tx, _rx) = mpsc::channel(64); diff --git a/net-guardia/src/core/correlation/engine.rs b/net-guardia/src/core/correlation/engine.rs index 98c4aeb..8a503d8 100644 --- a/net-guardia/src/core/correlation/engine.rs +++ b/net-guardia/src/core/correlation/engine.rs @@ -1,5 +1,7 @@ +use std::sync::Arc; use std::time::Duration; +use arc_swap::ArcSwap; use macros::log; use tokio::sync::broadcast::error::RecvError; use tokio::sync::{broadcast, mpsc}; @@ -8,13 +10,11 @@ use tokio::time::interval; use crate::core::correlation::botnet::BotnetDetector; use crate::core::correlation::lateral::LateralMovementDetector; use crate::core::correlation::scan::ScanDetector; +use crate::model::config::AppConfig; use crate::model::detection::ml_detection::AlertMessage; use crate::model::event::DetectionEvent; use crate::model::log::detection::DetectionLog; -/// How often to sweep expired correlation state. -const CLEANUP_INTERVAL_SECS: u64 = 60; - /// Coordinates cross-flow correlation detectors (botnet, scan, lateral movement). /// Subscribes to ML AlertMessage broadcast and feeds enriched DetectionEvents /// to the DetectionOrchestrator for dedup and SOAR routing. @@ -24,16 +24,25 @@ pub struct CorrelationEngine { lateral: LateralMovementDetector, alert_rx: broadcast::Receiver, detection_tx: mpsc::Sender, + cleanup_interval_secs: u64, } impl CorrelationEngine { - pub fn new(alert_rx: broadcast::Receiver, detection_tx: mpsc::Sender) -> Self { + pub fn new( + app_config: &Arc>, + alert_rx: broadcast::Receiver, + detection_tx: mpsc::Sender, + ) -> Self { + let cfg = app_config.load(); + let correlation = &cfg.correlation; + let max_tracked = correlation.max_tracked_entries; Self { - botnet: BotnetDetector::new(), - scan: ScanDetector::new(), - lateral: LateralMovementDetector::new(), + botnet: BotnetDetector::new(&correlation.botnet, max_tracked), + scan: ScanDetector::new(&correlation.scan, max_tracked), + lateral: LateralMovementDetector::new(&correlation.lateral, max_tracked), alert_rx, detection_tx, + cleanup_interval_secs: cfg.detection.cleanup_interval_secs, } } @@ -45,7 +54,7 @@ impl CorrelationEngine { async fn run(mut self) { log!(DetectionLog::CorrelationEngineStarted); - let mut cleanup_interval = interval(Duration::from_secs(CLEANUP_INTERVAL_SECS)); + let mut cleanup_interval = interval(Duration::from_secs(self.cleanup_interval_secs)); loop { tokio::select! { diff --git a/net-guardia/src/core/correlation/lateral.rs b/net-guardia/src/core/correlation/lateral.rs index 572fec6..3f2e4f6 100644 --- a/net-guardia/src/core/correlation/lateral.rs +++ b/net-guardia/src/core/correlation/lateral.rs @@ -6,19 +6,11 @@ use dashmap::DashMap; use macros::log; use tokio::sync::mpsc; +use crate::model::config::correlation::CorrelationDetectorParams; use crate::model::detection::ml_detection::AlertMessage; use crate::model::event::{DetectionEvent, DetectionSource}; use crate::model::log::detection::DetectionLog; -/// Window within which unique internal destinations are counted per source. -const LATERAL_WINDOW_SECS: u64 = 300; // 5 minutes - -/// Minimum unique internal destination IPs to trigger a lateral movement alert. -const LATERAL_THRESHOLD: usize = 5; - -/// Maximum tracked source IPs to bound memory. -const MAX_TRACKED_SRCS: usize = 10_000; - struct TimedDestSet { dests: HashSet, window_start: Instant, @@ -29,15 +21,19 @@ pub struct LateralMovementDetector { /// src_ip → set of unique internal dst_ips within the time window state: DashMap, window: Duration, + window_secs: u64, threshold: usize, + max_tracked: usize, } impl LateralMovementDetector { - pub fn new() -> Self { + pub fn new(params: &CorrelationDetectorParams, max_tracked: usize) -> Self { Self { state: DashMap::new(), - window: Duration::from_secs(LATERAL_WINDOW_SECS), - threshold: LATERAL_THRESHOLD, + window: Duration::from_secs(params.window_secs), + window_secs: params.window_secs, + threshold: params.threshold, + max_tracked, } } @@ -77,7 +73,7 @@ impl LateralMovementDetector { log!(DetectionLog::LateralMovementDetected( key.clone(), unique_dests, - LATERAL_WINDOW_SECS, + self.window_secs, )); let event = DetectionEvent { @@ -113,8 +109,8 @@ impl LateralMovementDetector { self.state .retain(|_, set| now.duration_since(set.window_start) < window); - if self.state.len() > MAX_TRACKED_SRCS { - let excess = self.state.len() - MAX_TRACKED_SRCS; + if self.state.len() > self.max_tracked { + let excess = self.state.len() - self.max_tracked; let keys_to_remove: Vec = self.state.iter().take(excess).map(|e| e.key().clone()).collect(); for key in keys_to_remove { self.state.remove(&key); @@ -211,9 +207,16 @@ mod tests { } } + fn test_params() -> CorrelationDetectorParams { + CorrelationDetectorParams { + window_secs: 300, + threshold: 5, + } + } + #[tokio::test] async fn lateral_threshold_triggers_for_internal_only() { - let detector = LateralMovementDetector::new(); + let detector = LateralMovementDetector::new(&test_params(), 10_000); let (tx, mut rx) = mpsc::channel(64); // Internal → external should be ignored diff --git a/net-guardia/src/core/correlation/scan.rs b/net-guardia/src/core/correlation/scan.rs index b9ec5b0..d88ebf2 100644 --- a/net-guardia/src/core/correlation/scan.rs +++ b/net-guardia/src/core/correlation/scan.rs @@ -5,19 +5,11 @@ use dashmap::DashMap; use macros::log; use tokio::sync::mpsc; +use crate::model::config::correlation::CorrelationDetectorParams; use crate::model::detection::ml_detection::AlertMessage; use crate::model::event::{DetectionEvent, DetectionSource}; use crate::model::log::detection::DetectionLog; -/// Window within which unique destination ports are counted per source. -const SCAN_WINDOW_SECS: u64 = 120; // 2 minutes - -/// Minimum unique destination ports to trigger a scan alert. -const SCAN_THRESHOLD: usize = 20; - -/// Maximum tracked source IPs to bound memory. -const MAX_TRACKED_SRCS: usize = 10_000; - struct TimedPortSet { ports: HashSet, window_start: Instant, @@ -29,15 +21,19 @@ pub struct ScanDetector { /// src_ip → set of unique dst_ports within the time window state: DashMap, window: Duration, + window_secs: u64, threshold: usize, + max_tracked: usize, } impl ScanDetector { - pub fn new() -> Self { + pub fn new(params: &CorrelationDetectorParams, max_tracked: usize) -> Self { Self { state: DashMap::new(), - window: Duration::from_secs(SCAN_WINDOW_SECS), - threshold: SCAN_THRESHOLD, + window: Duration::from_secs(params.window_secs), + window_secs: params.window_secs, + threshold: params.threshold, + max_tracked, } } @@ -72,7 +68,7 @@ impl ScanDetector { }; if let Some((unique_ports, last_dst_ip)) = should_alert { - log!(DetectionLog::ScanDetected(key.clone(), unique_ports, SCAN_WINDOW_SECS,)); + log!(DetectionLog::ScanDetected(key.clone(), unique_ports, self.window_secs,)); let event = DetectionEvent { source: DetectionSource::Correlation, @@ -107,8 +103,8 @@ impl ScanDetector { self.state .retain(|_, set| now.duration_since(set.window_start) < window); - if self.state.len() > MAX_TRACKED_SRCS { - let excess = self.state.len() - MAX_TRACKED_SRCS; + if self.state.len() > self.max_tracked { + let excess = self.state.len() - self.max_tracked; let keys_to_remove: Vec = self.state.iter().take(excess).map(|e| e.key().clone()).collect(); for key in keys_to_remove { self.state.remove(&key); @@ -123,6 +119,13 @@ impl ScanDetector { mod tests { use super::*; + fn test_params() -> CorrelationDetectorParams { + CorrelationDetectorParams { + window_secs: 120, + threshold: 20, + } + } + fn make_alert(src_ip: &str, dst_port: u16) -> AlertMessage { AlertMessage { timestamp: 0, @@ -145,7 +148,7 @@ mod tests { #[tokio::test] async fn scan_threshold_triggers_alert() { - let detector = ScanDetector::new(); + let detector = ScanDetector::new(&test_params(), 10_000); let (tx, mut rx) = mpsc::channel(64); for port in 0..19 { @@ -162,7 +165,7 @@ mod tests { #[tokio::test] async fn different_sources_tracked_independently() { - let detector = ScanDetector::new(); + let detector = ScanDetector::new(&test_params(), 10_000); let (tx, mut rx) = mpsc::channel(64); for port in 0..15 { diff --git a/net-guardia/src/core/detection/beaconing.rs b/net-guardia/src/core/detection/beaconing.rs index 0ea305f..4db9beb 100644 --- a/net-guardia/src/core/detection/beaconing.rs +++ b/net-guardia/src/core/detection/beaconing.rs @@ -1,34 +1,18 @@ +use std::sync::Arc; use std::time::{Duration, Instant}; +use arc_swap::ArcSwap; use dashmap::DashMap; use macros::log; use tokio::sync::broadcast::error::RecvError; use tokio::sync::{broadcast, mpsc}; use tokio::time::interval; +use crate::model::config::AppConfig; use crate::model::detection::ml_detection::AlertMessage; use crate::model::event::{DetectionEvent, DetectionSource}; use crate::model::log::detection::DetectionLog; -/// How often to analyze cached flows for beaconing patterns. -const ANALYSIS_INTERVAL_SECS: u64 = 30; - -/// Minimum number of flow observations before computing CV. -const MIN_OBSERVATIONS: usize = 5; - -/// CV threshold: values below this indicate periodic (beaconing) behavior. -/// 0 = perfectly periodic, 1 = random. C2 beacons typically have CV < 0.3. -const CV_THRESHOLD: f64 = 0.3; - -/// Maximum entries in the flow cache to bound memory. -const MAX_CACHE_ENTRIES: usize = 50_000; - -/// Expire entries not seen within this window. -const EXPIRY_SECS: u64 = 600; // 10 minutes - -/// Cooldown between re-alerting on the same (src, dst, port) tuple. -const ALERT_COOLDOWN_SECS: u64 = 300; // 5 minutes - /// Key for tracking flow timing: (src_ip, dst_ip, dst_port). type FlowTuple = (String, String, u16); @@ -39,19 +23,38 @@ struct CachedFlow { /// Detects C2 beaconing by analyzing the periodicity of flows between /// (src_ip, dst_ip, dst_port) tuples. Uses coefficient of variation (CV) -/// of inter-arrival times: CV < 0.3 with sufficient observations = beaconing. +/// of inter-arrival times: CV below `cv_threshold` with sufficient +/// observations = beaconing. pub struct BeaconingDetector { flow_cache: DashMap, detection_tx: mpsc::Sender, alert_rx: broadcast::Receiver, + analysis_interval_secs: u64, + min_observations: usize, + cv_threshold: f64, + max_cache_entries: usize, + expiry_secs: u64, + alert_cooldown_secs: u64, } impl BeaconingDetector { - pub fn new(alert_rx: broadcast::Receiver, detection_tx: mpsc::Sender) -> Self { + pub fn new( + app_config: &Arc>, + alert_rx: broadcast::Receiver, + detection_tx: mpsc::Sender, + ) -> Self { + let cfg = app_config.load(); + let beaconing = &cfg.detection.beaconing; Self { flow_cache: DashMap::new(), detection_tx, alert_rx, + analysis_interval_secs: beaconing.analysis_interval_secs, + min_observations: beaconing.min_observations, + cv_threshold: beaconing.cv_threshold, + max_cache_entries: beaconing.max_cache_entries, + expiry_secs: beaconing.expiry_secs, + alert_cooldown_secs: beaconing.alert_cooldown_secs, } } @@ -63,7 +66,7 @@ impl BeaconingDetector { async fn run(mut self) { log!(DetectionLog::BeaconingDetectorStarted); - let mut analysis_interval = interval(Duration::from_secs(ANALYSIS_INTERVAL_SECS)); + let mut analysis_interval = interval(Duration::from_secs(self.analysis_interval_secs)); loop { tokio::select! { @@ -102,14 +105,14 @@ impl BeaconingDetector { fn analyze_and_alert(&self) { let now = Instant::now(); - let cooldown = Duration::from_secs(ALERT_COOLDOWN_SECS); + let cooldown = Duration::from_secs(self.alert_cooldown_secs); // Phase 1: read-lock scan to find beaconing candidates (avoids holding write locks // across the entire 50K-entry iteration, reducing contention with record_flow). let mut alerts: Vec<(FlowTuple, f64, usize)> = Vec::new(); for entry in self.flow_cache.iter() { let flow = entry.value(); - if flow.timestamps.len() < MIN_OBSERVATIONS { + if flow.timestamps.len() < self.min_observations { continue; } if let Some(last) = flow.last_alerted @@ -118,7 +121,7 @@ impl BeaconingDetector { continue; } let cv = compute_cv(&flow.timestamps); - if cv < CV_THRESHOLD { + if cv < self.cv_threshold { alerts.push((entry.key().clone(), cv, flow.timestamps.len())); } } @@ -137,7 +140,7 @@ impl BeaconingDetector { let event = DetectionEvent { source: DetectionSource::Beaconing, attack_type: "c2_communication".to_string(), - confidence: (1.0 - cv / CV_THRESHOLD) as f32 * 0.5 + 0.5, + confidence: (1.0 - cv / self.cv_threshold) as f32 * 0.5 + 0.5, source_ip: src_ip.clone(), dest_ip: dst_ip.clone(), protocol: 6, @@ -157,7 +160,7 @@ impl BeaconingDetector { fn cleanup(&self) { let now = Instant::now(); - let expiry = Duration::from_secs(EXPIRY_SECS); + let expiry = Duration::from_secs(self.expiry_secs); self.flow_cache.retain(|_, flow| { flow.timestamps @@ -166,8 +169,8 @@ impl BeaconingDetector { }); // Enforce max capacity - if self.flow_cache.len() > MAX_CACHE_ENTRIES { - let excess = self.flow_cache.len() - MAX_CACHE_ENTRIES; + if self.flow_cache.len() > self.max_cache_entries { + let excess = self.flow_cache.len() - self.max_cache_entries; let keys_to_remove: Vec = self.flow_cache.iter().take(excess).map(|e| e.key().clone()).collect(); for key in keys_to_remove { self.flow_cache.remove(&key); @@ -205,6 +208,28 @@ fn compute_cv(timestamps: &[Instant]) -> f64 { mod tests { use super::*; + fn test_app_config() -> Arc> { + Arc::new(ArcSwap::from_pointee(AppConfig { + acl: crate::model::config::acl::AclConfig::defaults(), + auth: crate::model::config::auth::AuthConfig::defaults(), + correlation: crate::model::config::correlation::CorrelationConfig::defaults(), + detection: crate::model::config::detection::DetectionConfig::defaults(), + dns_filter: crate::model::config::dns_filter::DnsFilterConfig::defaults(), + ebpf: crate::model::config::ebpf::EbpfConfig::defaults(), + http_server: crate::model::config::http_server::HttpServerConfig::defaults(), + ml: crate::model::config::ml::MlConfig::defaults(), + notification: crate::model::config::notification::NotificationConfig { + telegram: crate::model::config::notification::TelegramConfig::defaults(), + smtp: crate::model::config::notification::SmtpConfig::defaults(), + }, + observability: crate::model::config::observability::ObservabilityConfig::defaults(), + pipeline: crate::model::config::pipeline::PipelineConfig::defaults(), + soar: crate::model::config::soar::SoarConfig::defaults(), + suricata: crate::model::config::suricata::SuricataConfig::defaults(), + system: crate::model::config::system::SystemConfig::defaults(), + })) + } + #[test] fn cv_perfectly_periodic() { // Perfectly periodic: CV should be ~0 @@ -258,7 +283,7 @@ mod tests { let (alert_tx, alert_rx) = broadcast::channel(64); let (detection_tx, mut detection_rx) = mpsc::channel(64); - let detector = BeaconingDetector::new(alert_rx, detection_tx); + let detector = BeaconingDetector::new(&test_app_config(), alert_rx, detection_tx); // Manually record periodic flows let base = Instant::now(); diff --git a/net-guardia/src/core/detection/orchestrator.rs b/net-guardia/src/core/detection/orchestrator.rs index 9467aa6..416aebc 100644 --- a/net-guardia/src/core/detection/orchestrator.rs +++ b/net-guardia/src/core/detection/orchestrator.rs @@ -2,6 +2,7 @@ use std::num::NonZero; use std::sync::Arc; use std::time::{Duration, Instant}; +use arc_swap::ArcSwap; use lru::LruCache; use macros::log; use tokio::sync::mpsc; @@ -10,29 +11,13 @@ use tokio::time::interval; use super::fusion_math::{FusionWindowLengths, fused_confidence}; use super::metrics::FusionMetrics; use crate::infrastructure::communication_manager::CommunicationManager; -use crate::infrastructure::geoip::GeoIpService; +use crate::interface::port::geo_lookup::GeoLookup; +use crate::model::config::AppConfig; use crate::model::detection::attack_type::translate; use crate::model::error::system::SystemError; use crate::model::event::{AuditEvent, DetectionEvent, DetectionSource, ThreatDetectedEvent}; use crate::model::log::detection::DetectionLog; -/// Dedup window: detections for the same `(source_ip, canonical_attack_type)` -/// within this window are suppressed after initial fusion-window expiry. -const DEDUP_WINDOW_SECS: u64 = 30; - -/// How often to sweep expired dedup entries. -const CLEANUP_INTERVAL_SECS: u64 = 60; - -/// Repeat offender detection: same IP within this duration counts as repeat. -const REPEAT_OFFENDER_WINDOW_SECS: u64 = 2 * 60 * 60; - -/// Maximum dedup entries to prevent unbounded memory growth under sustained attack. -/// Declared as `NonZero` at compile time so `LruCache::new` never needs a -/// runtime unwrap — if this ever goes to zero, the const expression fails -/// to compile, not the running server. -// SAFETY: NonZero::new on a non-zero literal is infallible; const-evaluated. -const MAX_DEDUP_ENTRIES: NonZero = NonZero::new(50_000).unwrap(); - /// Actor recorded on every fusion-chain WORM entry. Stable across releases — /// downstream audit tooling filters on this string. const FUSION_AUDIT_ACTOR: &str = "FusionEngine"; @@ -71,14 +56,16 @@ struct DedupEntry { pub struct DetectionOrchestrator { rx: mpsc::Receiver, comm: Arc, - geoip: Option>, + geoip: Option>, metrics: Arc, // Enrichment state - src_ip_counts: lru::LruCache, - repeat_tracker: lru::LruCache, + src_ip_counts: LruCache, + repeat_tracker: LruCache, // Dedup state — LRU-bounded to prevent unbounded growth under sustained attack. dedup: lru::LruCache<(String, String), DedupEntry>, dedup_window: Duration, + repeat_offender_window: Duration, + cleanup_interval_secs: u64, /// Per-source fusion window lengths. Future versions may read overrides /// from DB; the defaults live in `FusionWindowLengths::default`. fusion_windows: FusionWindowLengths, @@ -86,22 +73,27 @@ pub struct DetectionOrchestrator { impl DetectionOrchestrator { pub fn new( + app_config: &Arc>, rx: mpsc::Receiver, comm: Arc, - geoip: Option>, + geoip: Option>, metrics: Arc, ) -> Self { + let cfg = app_config.load(); + let fusion = &cfg.detection.fusion; + let max_dedup = NonZero::new(fusion.max_dedup_entries.max(1)).unwrap_or(NonZero::::MIN); Self { rx, comm, geoip, metrics, - // SAFETY: NonZero::new on non-zero literals; MAX_DEDUP_ENTRIES is - // already a NonZero const so no unwrap needed for that one. + // SAFETY: NonZero::new on non-zero literals. src_ip_counts: LruCache::new(NonZero::new(10_000).unwrap()), repeat_tracker: LruCache::new(NonZero::new(5_000).unwrap()), - dedup: LruCache::new(MAX_DEDUP_ENTRIES), - dedup_window: Duration::from_secs(DEDUP_WINDOW_SECS), + dedup: LruCache::new(max_dedup), + dedup_window: Duration::from_secs(fusion.dedup_window_secs), + repeat_offender_window: Duration::from_secs(fusion.repeat_offender_window_secs), + cleanup_interval_secs: cfg.detection.cleanup_interval_secs, fusion_windows: FusionWindowLengths::default(), } } @@ -113,7 +105,7 @@ impl DetectionOrchestrator { async fn run(mut self) { log!(DetectionLog::OrchestratorStarted); - let mut cleanup_interval = interval(Duration::from_secs(CLEANUP_INTERVAL_SECS)); + let mut cleanup_interval = interval(Duration::from_secs(self.cleanup_interval_secs)); loop { tokio::select! { @@ -305,17 +297,15 @@ impl DetectionOrchestrator { } }; - let repeat_window = Duration::from_secs(REPEAT_OFFENDER_WINDOW_SECS); let now = Instant::now(); - let is_repeat = self - .repeat_tracker - .get(src_ip) - .is_some_and(|last| now.checked_duration_since(*last).unwrap_or(Duration::ZERO) < repeat_window); + let is_repeat = self.repeat_tracker.get(src_ip).is_some_and(|last| { + now.checked_duration_since(*last).unwrap_or(Duration::ZERO) < self.repeat_offender_window + }); self.repeat_tracker.put(src_ip.clone(), now); let geoip_country = if let Some(ref svc) = self.geoip { if let Ok(ip) = src_ip.parse() { - svc.lookup(ip).await.ok().flatten().and_then(|loc| loc.country_code) + svc.lookup(ip).await.and_then(|loc| loc.country_code) } else { None } diff --git a/net-guardia/src/core/dns_filter_service.rs b/net-guardia/src/core/dns_filter_service.rs index 8d5ce50..5739c83 100644 --- a/net-guardia/src/core/dns_filter_service.rs +++ b/net-guardia/src/core/dns_filter_service.rs @@ -1,7 +1,10 @@ use std::sync::Arc; +use arc_swap::ArcSwap; + use crate::interface::port::app_repo::AppRepo; use crate::interface::port::dns_filter_api::DnsFilterPort; +use crate::model::config::AppConfig; use crate::model::error::Error; use crate::model::error::misc::MiscError; @@ -10,11 +13,12 @@ use crate::model::error::misc::MiscError; pub struct DnsFilterService { db: Arc, dns_filter: Arc, + config: Arc>, } impl DnsFilterService { - pub fn new(db: Arc, dns_filter: Arc) -> Self { - Self { db, dns_filter } + pub fn new(db: Arc, dns_filter: Arc, config: Arc>) -> Self { + Self { db, dns_filter, config } } pub fn list_domains(&self) -> Vec { @@ -22,13 +26,7 @@ impl DnsFilterService { } pub fn add_domains(&self, domains: &[String]) -> Result { - let max_domains: usize = self - .db - .get_setting("dns_max_domains_per_request") - .ok() - .flatten() - .and_then(|v| v.parse().ok()) - .unwrap_or(1000); + let max_domains = self.config.load().dns_filter.max_domains_per_request; if domains.len() > max_domains { Err(MiscError::ValidationError(format!( "too many domains (max {})", diff --git a/net-guardia/src/core/email/scheduler.rs b/net-guardia/src/core/email/scheduler.rs index 10bff78..6ffac27 100644 --- a/net-guardia/src/core/email/scheduler.rs +++ b/net-guardia/src/core/email/scheduler.rs @@ -1,5 +1,6 @@ use std::sync::Arc; +use arc_swap::ArcSwap; use chrono::{Local, Weekday}; use lettre::message::header::ContentType; use lettre::transport::smtp::authentication::Credentials; @@ -11,12 +12,12 @@ use tokio::time::{self, Duration}; use super::report; use crate::interface::port::secret_store::SecretStorePort; use crate::interface::port::setting::SettingRepo; +use crate::model::config::AppConfig; +use crate::model::config::notification::SmtpConfig; use crate::model::error::Error; use crate::model::error::notification::NotificationError; use crate::model::log::system::SystemLog; -/// SMTP client wrapper that builds a `lettre::SmtpTransport` from Database -/// settings and sends an email. pub struct SmtpClient { host: String, port: u16, @@ -27,80 +28,20 @@ pub struct SmtpClient { } impl SmtpClient { - /// Try to construct an `SmtpClient` from Database settings. - /// - /// Returns `None` if any required setting (`smtp_host`, `smtp_port`, - /// `smtp_username`, `smtp_password`) is missing. - /// If a `SecretStorePort` is provided, reads the password from the secret store. - pub fn from_database(db: &dyn SettingRepo, secrets: Option<&dyn SecretStorePort>) -> Result, Error> { - let host = match db.get_setting("smtp_host")? { - Some(v) if !v.is_empty() => v, - _ => return Ok(None), - }; - let port_str = match db.get_setting("smtp_port")? { - Some(v) if !v.is_empty() => v, - _ => return Ok(None), - }; - let username = match db.get_setting("smtp_username")? { - Some(v) if !v.is_empty() => v, - _ => return Ok(None), - }; - - let password = Self::resolve_smtp_password(secrets)?; - let password = match password { - Some(v) if !v.is_empty() => v, - _ => return Ok(None), - }; - - let port: u16 = port_str.parse().unwrap_or(587); - - // smtp_sender overrides username as the From address. - // Fall back to username if smtp_sender is not configured. - let sender = match db.get_setting("smtp_sender")? { - Some(v) if !v.is_empty() => v, - _ => username.clone(), - }; - - // Validate that the sender looks like an email address - if !sender.contains('@') { + pub fn from_config(cfg: &SmtpConfig, secrets: Option<&dyn SecretStorePort>) -> Result, Error> { + if cfg.host.is_empty() || cfg.username.is_empty() { return Ok(None); } - Ok(Some(Self { - host, - port, - username, - password, - sender, - })) - } - - /// Try to construct an `SmtpClient` from any SettingRepo implementation. - /// Kept as a separate method name for call-site clarity (SOAR actions). - pub fn from_soar_port(db: &dyn SettingRepo, secrets: Option<&dyn SecretStorePort>) -> Result, Error> { - let host = match db.get_setting("smtp_host")? { - Some(v) if !v.is_empty() => v, - _ => return Ok(None), - }; - let port_str = match db.get_setting("smtp_port")? { - Some(v) if !v.is_empty() => v, - _ => return Ok(None), - }; - let username = match db.get_setting("smtp_username")? { - Some(v) if !v.is_empty() => v, - _ => return Ok(None), - }; - let password = match secrets.and_then(|ss| ss.get_secret("smtp_password").ok().flatten()) { Some(pw) if !pw.is_empty() => pw, _ => return Ok(None), }; - let port: u16 = port_str.parse().unwrap_or(587); - - let sender = match db.get_setting("smtp_sender")? { - Some(v) if !v.is_empty() => v, - _ => username.clone(), + let sender = if cfg.sender.is_empty() { + cfg.username.clone() + } else { + cfg.sender.clone() }; if !sender.contains('@') { @@ -108,22 +49,14 @@ impl SmtpClient { } Ok(Some(Self { - host, - port, - username, + host: cfg.host.clone(), + port: cfg.port, + username: cfg.username.clone(), password, sender, })) } - /// Resolve SMTP password: try secret store first, fall back to settings. - fn resolve_smtp_password(secrets: Option<&dyn SecretStorePort>) -> Result, Error> { - match secrets { - Some(ss) => Ok(ss.get_secret("smtp_password")?.filter(|pw| !pw.is_empty())), - None => Ok(None), - } - } - /// Send an HTML email using the configured SMTP transport. pub fn send(&self, to: &str, subject: &str, html_body: &str) -> Result<(), Error> { let from_addr = self @@ -174,21 +107,25 @@ impl SmtpClient { } } -/// Scheduler that checks once per hour whether it is time to send the weekly -/// report (Monday 08:00 local time) and dispatches it via SMTP. pub struct ReportScheduler { - db: Arc, + db: Arc, + config: Arc>, secrets: Option>, } impl ReportScheduler { - pub fn new(db: Arc, secrets: Option>) -> Self { - Self { db, secrets } + pub fn new( + db: Arc, + config: Arc>, + secrets: Option>, + ) -> Self { + Self { db, config, secrets } } /// Spawn a background tokio task that runs the weekly check loop. pub fn run(&self) -> JoinHandle<()> { let db = Arc::clone(&self.db); + let config = Arc::clone(&self.config); let secrets = self.secrets.clone(); tokio::spawn(async move { log!(SystemLog::WeeklyReportSchedulerStarted); @@ -202,7 +139,9 @@ impl ReportScheduler { log!(SystemLog::WeeklyReportWindowReached); - let smtp = match SmtpClient::from_database(&*db, secrets.as_deref()) { + let smtp_cfg = config.load().notification.smtp.clone(); + + let smtp = match SmtpClient::from_config(&smtp_cfg, secrets.as_deref()) { Ok(Some(client)) => client, Ok(None) => { log!(SystemLog::SmtpNotConfigured); @@ -214,13 +153,11 @@ impl ReportScheduler { } }; - let recipient = match db.get_setting("smtp_recipient") { - Ok(Some(r)) if !r.is_empty() => r, - _ => { - log!(SystemLog::SmtpRecipientMissing); - continue; - } - }; + let recipient = smtp_cfg.recipient; + if recipient.is_empty() { + log!(SystemLog::SmtpRecipientMissing); + continue; + } let html = match report::generate_weekly_report(&*db) { Ok(h) => h, diff --git a/net-guardia/src/core/ml/alert.rs b/net-guardia/src/core/ml/alert.rs index e2e0aeb..70da85d 100644 --- a/net-guardia/src/core/ml/alert.rs +++ b/net-guardia/src/core/ml/alert.rs @@ -1,7 +1,6 @@ use macros::log; use tokio::sync::broadcast; -use crate::model::config::constants::ML_ALERT_CHANNEL_CAPACITY; use crate::model::detection::ml_detection::{AlertMessage, DetectionResult}; use crate::model::log::ml::MLLog; @@ -10,8 +9,8 @@ pub struct MLAlert { } impl MLAlert { - pub fn new() -> Self { - let (broadcast_tx, _) = broadcast::channel(ML_ALERT_CHANNEL_CAPACITY); + pub fn new(channel_capacity: usize) -> Self { + let (broadcast_tx, _) = broadcast::channel(channel_capacity.max(1)); MLAlert { broadcast_tx } } @@ -29,9 +28,3 @@ impl MLAlert { } } } - -impl Default for MLAlert { - fn default() -> Self { - Self::new() - } -} diff --git a/net-guardia/src/core/ml/config_loader.rs b/net-guardia/src/core/ml/config_loader.rs index 05d64ef..10efa4a 100644 --- a/net-guardia/src/core/ml/config_loader.rs +++ b/net-guardia/src/core/ml/config_loader.rs @@ -3,8 +3,8 @@ use std::fs; use std::path::{Path, PathBuf}; use super::manifest::{AdapterKind, LabelSpec, ModelManifest}; +use crate::model::detection::ml_inference_config::MLInferenceConfig; use crate::model::error::ml::MLError; -use crate::model::system::config::MLInferenceConfig; impl MLInferenceConfig { pub fn load_file(file: &str) -> Result { diff --git a/net-guardia/src/core/ml/drift_detector.rs b/net-guardia/src/core/ml/drift_detector.rs index 1a4d6ac..7feb30c 100644 --- a/net-guardia/src/core/ml/drift_detector.rs +++ b/net-guardia/src/core/ml/drift_detector.rs @@ -5,18 +5,10 @@ use tokio::sync::{mpsc, oneshot}; use crate::model::detection::drift::{DriftReport, FeatureBaselines}; -/// Maximum number of snapshots to retain, preventing unbounded memory growth. -const MAX_SNAPSHOTS: usize = 10_000; - -/// Channel depth for the owner-task command queue. With a typical inference -/// batch of 100 flows per second, 1024 gives ~10s of cushion before the -/// hot path begins shedding samples. -const DRIFT_CMD_CHANNEL_CAPACITY: usize = 1024; - /// Tracks rolling mean/stddev of normalized input features over a configurable window. /// Compares against training-time baselines to detect data drift. pub struct DriftDetector { - /// Recent feature snapshots within the rolling window, capped at MAX_SNAPSHOTS. + /// Recent feature snapshots within the rolling window, capped at `max_snapshots`. snapshots: VecDeque<(Instant, Vec)>, /// Number of features expected per snapshot. num_features: usize, @@ -24,18 +16,20 @@ pub struct DriftDetector { baselines: Option, /// Rolling window duration (runtime-configurable via DB `ml_drift_window_secs`). drift_window: Duration, + /// Upper bound on retained snapshots (from `MlConfig::drift_max_snapshots`). + max_snapshots: usize, } impl DriftDetector { - /// Create a new detector with a configurable drift window duration. - /// Default window is 3600s (1 hour) when not specified via DB setting `ml_drift_window_secs`. - pub fn new(baselines: Option, drift_window: Duration) -> Self { + /// Create a new detector with a configurable drift window and snapshot cap. + pub fn new(baselines: Option, drift_window: Duration, max_snapshots: usize) -> Self { let num_features = baselines.as_ref().map_or(0, |b| b.names.len()); Self { snapshots: VecDeque::new(), num_features, baselines, drift_window, + max_snapshots, } } @@ -45,7 +39,7 @@ impl DriftDetector { self.snapshots.push_back((now, features.to_vec())); self.evict_stale(now); // Cap total snapshots to prevent unbounded memory growth - while self.snapshots.len() > MAX_SNAPSHOTS { + while self.snapshots.len() > self.max_snapshots { self.snapshots.pop_front(); } } @@ -138,10 +132,15 @@ pub struct DriftDetectorHandle { impl DriftDetectorHandle { /// Spawn the owner task on the current tokio runtime and return a handle. - pub fn spawn(baselines: Option, drift_window: Duration) -> Self { - let (tx, mut rx) = mpsc::channel::(DRIFT_CMD_CHANNEL_CAPACITY); + pub fn spawn( + baselines: Option, + drift_window: Duration, + max_snapshots: usize, + channel_capacity: usize, + ) -> Self { + let (tx, mut rx) = mpsc::channel::(channel_capacity); tokio::spawn(async move { - let mut detector = DriftDetector::new(baselines, drift_window); + let mut detector = DriftDetector::new(baselines, drift_window, max_snapshots); while let Some(cmd) = rx.recv().await { match cmd { DriftCmd::Update(features) => detector.update(&features), @@ -186,7 +185,7 @@ mod tests { #[test] fn no_drift_when_within_threshold() { let baselines = make_baselines(3); - let mut detector = DriftDetector::new(Some(baselines), Duration::from_secs(3600)); + let mut detector = DriftDetector::new(Some(baselines), Duration::from_secs(3600), 10_000); // Values within 3σ of baseline mean 0.0 with std 1.0 detector.update(&[1.0, -1.0, 2.0]); detector.update(&[0.5, -0.5, 1.5]); @@ -196,7 +195,7 @@ mod tests { #[test] fn drift_detected_when_exceeds_threshold() { let baselines = make_baselines(3); - let mut detector = DriftDetector::new(Some(baselines), Duration::from_secs(3600)); + let mut detector = DriftDetector::new(Some(baselines), Duration::from_secs(3600), 10_000); // Mean of 5.0 exceeds 3σ from baseline mean 0.0 detector.update(&[5.0, 0.0, 0.0]); detector.update(&[5.0, 0.0, 0.0]); @@ -207,7 +206,7 @@ mod tests { #[test] fn no_baselines_means_no_drift() { - let mut detector = DriftDetector::new(None, Duration::from_secs(3600)); + let mut detector = DriftDetector::new(None, Duration::from_secs(3600), 10_000); detector.update(&[100.0, 200.0]); assert!(detector.check_drift().is_none()); } @@ -215,7 +214,7 @@ mod tests { #[test] fn empty_snapshots_no_drift() { let baselines = make_baselines(3); - let detector = DriftDetector::new(Some(baselines), Duration::from_secs(3600)); + let detector = DriftDetector::new(Some(baselines), Duration::from_secs(3600), 10_000); assert!(detector.check_drift().is_none()); } } diff --git a/net-guardia/src/core/ml/engine.rs b/net-guardia/src/core/ml/engine.rs index 315d154..e7c44d0 100644 --- a/net-guardia/src/core/ml/engine.rs +++ b/net-guardia/src/core/ml/engine.rs @@ -15,7 +15,7 @@ use tokio::time::interval; use super::aggregator::AttackAggregator; use super::alert::MLAlert; use super::drift_detector::DriftDetectorHandle; -use super::flow_tracker::{FlowData, FlowTracker}; +use super::flow_tracker::{FlowData, FlowLimits, FlowTracker}; use super::inference::Inference; use super::traffic_logger::TrafficLogger; use crate::interface::port::packet_sink::{PacketSink, PacketSinkFactory}; @@ -24,20 +24,6 @@ use crate::model::detection::ml_detection::{EngineConfig, FlowKey, InferenceStat use crate::model::log::ml::MLLog; use crate::model::monitoring::user_packet::UserPacket; -/// Divisor applied to "ticks per aggregator window" to derive the fallback -/// confirmations count: 2 means a default-behaviour detection must fire -/// across at least half the window's ticks before alerting. -const DEFAULT_CONFIRMATION_WINDOW_FRACTION: u64 = 2; - -/// Hard floor on the per-flow packet count that gates ML inference for any -/// protocol / port combination that lacks an explicit low-packet override. -/// Prevents a misconfigured `min_packets` (0..=4) from feeding two-packet -/// flows into the model where the features carry almost no signal and the -/// false-alarm rate dominates. Protocols and ports that are meaningful at -/// very low packet counts (ICMP scans, DNS tunneling, C2 beacons) bypass -/// this floor through explicit overrides in `effective_min_packets`. -const ML_MIN_PACKETS_FLOOR: usize = 5; - /// Per-queue tracker. With symmetric hash in eBPF, both directions of a flow /// land on the same queue, so per-queue trackers correctly see bidirectional flows. /// `FlowTracker` itself is internally synchronized (DashMap), so the @@ -51,6 +37,7 @@ pub struct Engine { drift_detector: DriftDetectorHandle, ml_alert: Arc, min_packets: usize, + min_packets_floor: usize, /// Confirmations count used when the active manifest's label has no /// explicit `confirmations` override. default_confirmations: usize, @@ -67,17 +54,19 @@ impl Engine { ml_alert: Arc, drift_detector: DriftDetectorHandle, engine_config: EngineConfig, + flow_limits: FlowLimits, traffic_logger: Option>, num_threads: u32, ) -> Self { let interval_secs = engine_config.inference_interval_secs.max(1); let ticks_per_window = engine_config.aggregator_window_secs / interval_secs; - let default_confirmations = (ticks_per_window / DEFAULT_CONFIRMATION_WINDOW_FRACTION).max(1) as usize; + let default_confirmations = + (ticks_per_window / engine_config.confirmation_window_fraction.max(1)).max(1) as usize; let aggregator = AttackAggregator::new(engine_config.aggregator_window_secs); let max_flows_per_thread = engine_config.max_flows / (num_threads as usize).max(1); let trackers: Vec = (0..num_threads) - .map(|_| Arc::new(FlowTracker::new(max_flows_per_thread))) + .map(|_| Arc::new(FlowTracker::new(max_flows_per_thread, flow_limits))) .collect(); Self { @@ -87,6 +76,7 @@ impl Engine { drift_detector, ml_alert, min_packets: engine_config.min_packets, + min_packets_floor: engine_config.min_packets_floor.max(1), default_confirmations, batch_size: engine_config.batch_size, inference_interval_secs: engine_config.inference_interval_secs, @@ -156,8 +146,8 @@ impl Engine { /// threshold. Paths that fall through to `global` are additionally /// floored at `ML_MIN_PACKETS_FLOOR` so a misconfigured global setting /// can't feed near-empty flows into inference. - fn effective_min_packets(flow_key: &FlowKey, global: usize) -> usize { - let floored = global.max(ML_MIN_PACKETS_FLOOR); + fn effective_min_packets(flow_key: &FlowKey, global: usize, floor: usize) -> usize { + let floored = global.max(floor.max(1)); match flow_key.protocol { // ICMP: single-packet SYN scans, ping sweeps. 1 => 1, @@ -224,7 +214,7 @@ impl Engine { total_count += tracker.flow_count(); all_flows.extend(tracker.get_uninferred_flows().into_iter().filter(|flow| { let total_packets = flow.packet_count(); - total_packets >= Self::effective_min_packets(&flow.flow_key, self.min_packets) + total_packets >= Self::effective_min_packets(&flow.flow_key, self.min_packets, self.min_packets_floor) && !Self::is_strong_benign( &flow.flow_key, flow.fwd_packets.len(), @@ -377,56 +367,58 @@ mod tests { } } + const TEST_FLOOR: usize = 5; + #[test] fn floor_applies_when_global_below_five() { // Bulk TCP / UDP with no low-packet override must not drop below 5 // even if the operator sets a permissive global. assert_eq!( - Engine::effective_min_packets(&flow_key(6, 443), 2), - ML_MIN_PACKETS_FLOOR + Engine::effective_min_packets(&flow_key(6, 443), 2, TEST_FLOOR), + TEST_FLOOR ); assert_eq!( - Engine::effective_min_packets(&flow_key(17, 500), 0), - ML_MIN_PACKETS_FLOOR + Engine::effective_min_packets(&flow_key(17, 500), 0, TEST_FLOOR), + TEST_FLOOR ); // Uncommon protocol (SCTP) also honors the floor. assert_eq!( - Engine::effective_min_packets(&flow_key(132, 9), 1), - ML_MIN_PACKETS_FLOOR + Engine::effective_min_packets(&flow_key(132, 9), 1, TEST_FLOOR), + TEST_FLOOR ); } #[test] fn floor_respects_higher_global() { // A stricter global wins — the floor is a lower bound, not a clamp. - assert_eq!(Engine::effective_min_packets(&flow_key(6, 443), 12), 12); - assert_eq!(Engine::effective_min_packets(&flow_key(17, 500), 8), 8); + assert_eq!(Engine::effective_min_packets(&flow_key(6, 443), 12, TEST_FLOOR), 12); + assert_eq!(Engine::effective_min_packets(&flow_key(17, 500), 8, TEST_FLOOR), 8); } #[test] fn icmp_override_bypasses_floor() { // Single-packet ICMP scans must remain visible regardless of the floor. - assert_eq!(Engine::effective_min_packets(&flow_key(1, 0), 100), 1); + assert_eq!(Engine::effective_min_packets(&flow_key(1, 0), 100, TEST_FLOOR), 1); } #[test] fn low_packet_overrides_preserved() { // Every explicit low-packet override keeps its tuned value. - assert_eq!(Engine::effective_min_packets(&flow_key(17, 53), 100), 1); // UDP DNS - assert_eq!(Engine::effective_min_packets(&flow_key(17, 123), 100), 2); // UDP NTP - assert_eq!(Engine::effective_min_packets(&flow_key(17, 3333), 100), 2); // UDP C2 - assert_eq!(Engine::effective_min_packets(&flow_key(17, 45700), 100), 2); // UDP C2 - assert_eq!(Engine::effective_min_packets(&flow_key(6, 53), 100), 2); // TCP DNS + assert_eq!(Engine::effective_min_packets(&flow_key(17, 53), 100, TEST_FLOOR), 1); // UDP DNS + assert_eq!(Engine::effective_min_packets(&flow_key(17, 123), 100, TEST_FLOOR), 2); // UDP NTP + assert_eq!(Engine::effective_min_packets(&flow_key(17, 3333), 100, TEST_FLOOR), 2); // UDP C2 + assert_eq!(Engine::effective_min_packets(&flow_key(17, 45700), 100, TEST_FLOOR), 2); // UDP C2 + assert_eq!(Engine::effective_min_packets(&flow_key(6, 53), 100, TEST_FLOOR), 2); // TCP DNS for port in [4444u16, 8443, 8080, 1337, 31337] { - assert_eq!(Engine::effective_min_packets(&flow_key(6, port), 100), 2); + assert_eq!(Engine::effective_min_packets(&flow_key(6, port), 100, TEST_FLOOR), 2); } - assert_eq!(Engine::effective_min_packets(&flow_key(6, 3333), 100), 2); // TCP C2 + assert_eq!(Engine::effective_min_packets(&flow_key(6, 3333), 100, TEST_FLOOR), 2); // TCP C2 } #[test] fn exact_floor_value_passes_through() { // At the floor boundary, no bump applied. - assert_eq!(Engine::effective_min_packets(&flow_key(6, 443), 5), 5); + assert_eq!(Engine::effective_min_packets(&flow_key(6, 443), 5, TEST_FLOOR), 5); } #[test] diff --git a/net-guardia/src/core/ml/flow_tracker.rs b/net-guardia/src/core/ml/flow_tracker.rs index 1aa2aa8..ad5b115 100644 --- a/net-guardia/src/core/ml/flow_tracker.rs +++ b/net-guardia/src/core/ml/flow_tracker.rs @@ -4,14 +4,23 @@ use common::define::tcp_flags::*; use moka::sync::Cache; use parking_lot::Mutex; -use crate::model::config::constants::{ - FLOW_BULK_MIN_BYTES, FLOW_BULK_MIN_PACKETS, FLOW_IDLE_THRESHOLD_US, FLOW_IDLE_TIMEOUT_US, - FLOW_MAX_PACKETS_PER_DIRECTION, FLOW_MAX_PERIODS, FLOW_TERMINATED_TIMEOUT_US, -}; use crate::model::detection::ml_detection::{BulkState, FlowKey, PacketData}; use crate::model::monitoring::direction::Direction; use crate::model::monitoring::user_packet::UserPacket; +/// Bundle of per-flow tuning parameters. Snapshotted at `FlowTracker::new` +/// time so the add-packet hot path does not need to re-read config. +#[derive(Debug, Clone, Copy)] +pub struct FlowLimits { + pub max_packets_per_direction: usize, + pub max_periods: usize, + pub idle_threshold_us: u64, + pub bulk_min_packets: u64, + pub bulk_min_bytes: u64, + pub idle_timeout_us: u64, + pub terminated_timeout_us: u64, +} + #[derive(Debug, Clone)] pub struct FlowData { pub flow_key: FlowKey, @@ -88,7 +97,7 @@ impl FlowData { } } - pub fn add_packet(&mut self, packet: &UserPacket) { + pub fn add_packet(&mut self, packet: &UserPacket, limits: &FlowLimits) { let packet_data = PacketData { timestamp_us: packet.timestamp_us, length: packet.packet_length, @@ -124,11 +133,11 @@ impl FlowData { let iat = packet.timestamp_us.saturating_sub(self.last_packet_time); - if iat > FLOW_IDLE_THRESHOLD_US { - if self.idle_periods.len() < FLOW_MAX_PERIODS { + if iat > limits.idle_threshold_us { + if self.idle_periods.len() < limits.max_periods { self.idle_periods.push(iat); } - } else if iat > 0 && self.active_periods.len() < FLOW_MAX_PERIODS { + } else if iat > 0 && self.active_periods.len() < limits.max_periods { self.active_periods.push(iat); } @@ -142,7 +151,7 @@ impl FlowData { } if packet.is_forward { - if self.fwd_packets.len() < FLOW_MAX_PACKETS_PER_DIRECTION { + if self.fwd_packets.len() < limits.max_packets_per_direction { self.fwd_packets.push(packet_data.clone()); } self.fwd_total_bytes += packet.packet_length as u64; @@ -150,9 +159,9 @@ impl FlowData { if self.init_win_bytes_fwd == 0 { self.init_win_bytes_fwd = packet.tcp_window_size; } - Self::update_bulk_state(&mut self.fwd_bulk_state, &packet_data); + Self::update_bulk_state(&mut self.fwd_bulk_state, &packet_data, limits); } else { - if self.bwd_packets.len() < FLOW_MAX_PACKETS_PER_DIRECTION { + if self.bwd_packets.len() < limits.max_packets_per_direction { self.bwd_packets.push(packet_data.clone()); } self.bwd_total_bytes += packet.packet_length as u64; @@ -160,11 +169,11 @@ impl FlowData { if self.init_win_bytes_bwd == 0 { self.init_win_bytes_bwd = packet.tcp_window_size; } - Self::update_bulk_state(&mut self.bwd_bulk_state, &packet_data); + Self::update_bulk_state(&mut self.bwd_bulk_state, &packet_data, limits); } } - fn update_bulk_state(bulk_state: &mut BulkState, packet: &PacketData) { + fn update_bulk_state(bulk_state: &mut BulkState, packet: &PacketData, limits: &FlowLimits) { if packet.payload_length > 0 { if !bulk_state.in_bulk { bulk_state.in_bulk = true; @@ -179,8 +188,8 @@ impl FlowData { } } else { if bulk_state.in_bulk - && bulk_state.last_bulk_packets >= FLOW_BULK_MIN_PACKETS - && bulk_state.last_bulk_bytes >= FLOW_BULK_MIN_BYTES + && bulk_state.last_bulk_packets >= limits.bulk_min_packets + && bulk_state.last_bulk_bytes >= limits.bulk_min_bytes { bulk_state.bulk_count += 1; bulk_state.total_bytes += bulk_state.last_bulk_bytes; @@ -224,13 +233,15 @@ type FlowEntry = Arc>; /// policy would mishandle. pub struct FlowTracker { active: Cache, + limits: FlowLimits, } impl FlowTracker { - pub fn new(max_flows: usize) -> Self { + pub fn new(max_flows: usize, limits: FlowLimits) -> Self { let cap = max_flows.max(1) as u64; Self { active: Cache::builder().max_capacity(cap).build(), + limits, } } @@ -288,7 +299,7 @@ impl FlowTracker { let entry = self.active.get_with(actual_key, || { Arc::new(Mutex::new(FlowData::new(key_for_init, &packet, initiator_direction))) }); - entry.lock().add_packet(&packet); + entry.lock().add_packet(&packet, &self.limits); } /// Get all active flows (clone, no drain). Used by WebSocket. @@ -325,9 +336,9 @@ impl FlowTracker { let idle = now_us.saturating_sub(flow.last_time_us); let is_terminated = flow.fin_count > 0 || flow.rst_count > 0; let stale = if is_terminated { - idle >= FLOW_TERMINATED_TIMEOUT_US + idle >= self.limits.terminated_timeout_us } else { - idle >= FLOW_IDLE_TIMEOUT_US + idle >= self.limits.idle_timeout_us }; if stale { keys_to_remove.push((*key).clone()); @@ -346,6 +357,18 @@ impl FlowTracker { mod tests { use super::*; + fn test_limits() -> FlowLimits { + FlowLimits { + max_packets_per_direction: 1000, + max_periods: 1000, + idle_threshold_us: 1_000_000, + bulk_min_packets: 4, + bulk_min_bytes: 1000, + idle_timeout_us: 120_000_000, + terminated_timeout_us: 5_000_000, + } + } + fn make_packet(timestamp_us: u64, tcp_flags: u8) -> UserPacket { UserPacket { ip_version: 4, @@ -371,7 +394,7 @@ mod tests { #[test] fn cleanup_removes_idle_flows() { - let tracker = FlowTracker::new(10000); + let tracker = FlowTracker::new(10000, test_limits()); let base_ts = 1_000_000_000u64; // 1000 seconds let pkt = make_packet(base_ts, 0x02); // SYN @@ -386,7 +409,7 @@ mod tests { #[test] fn cleanup_keeps_active_flows() { - let tracker = FlowTracker::new(10000); + let tracker = FlowTracker::new(10000, test_limits()); let base_ts = 1_000_000_000u64; let pkt = make_packet(base_ts, 0x02); @@ -400,7 +423,7 @@ mod tests { #[test] fn cleanup_removes_terminated_flows_after_short_idle() { - let tracker = FlowTracker::new(10000); + let tracker = FlowTracker::new(10000, test_limits()); let base_ts = 1_000_000_000u64; let pkt1 = make_packet(base_ts, 0x02); @@ -417,7 +440,7 @@ mod tests { #[test] fn cleanup_keeps_recently_terminated_flows() { - let tracker = FlowTracker::new(10000); + let tracker = FlowTracker::new(10000, test_limits()); let base_ts = 1_000_000_000u64; let pkt1 = make_packet(base_ts, 0x02); diff --git a/net-guardia/src/core/ml/inference.rs b/net-guardia/src/core/ml/inference.rs index 6d3617e..adf6247 100644 --- a/net-guardia/src/core/ml/inference.rs +++ b/net-guardia/src/core/ml/inference.rs @@ -23,25 +23,20 @@ use tract_onnx::prelude::*; use super::adapter::{MLModelAdapter, ModelSourceState}; use super::flow_tracker::FlowData; use super::manifest::LabelSpec; +use crate::model::config::AppConfig; use crate::model::detection::flow_features::FlowFeatures; use crate::model::detection::ml_detection::{DetectionResult, RunnableModel}; +use crate::model::detection::ml_inference_config::MLInferenceConfig; use crate::model::detection::model_source::ModelSourceStatus; use crate::model::log::ml::MLLog; -use crate::model::system::config::MLInferenceConfig; /// (anomaly_scores, per_class_probs, c2_scores) — MultiTask batch output. type ClassifierBatchOutput = (Vec, Vec>, Vec); -/// Consecutive failures to trip the circuit breaker. -const CIRCUIT_BREAKER_THRESHOLD: u32 = 5; -/// Window in seconds: failures older than this are forgotten. -const CIRCUIT_BREAKER_WINDOW_SECS: u64 = 60; -/// Cooldown in seconds before re-enabling inference after circuit break. -const CIRCUIT_BREAKER_COOLDOWN_SECS: u64 = 120; - pub struct Inference { state: ArcSwap, pub config: Arc, + app_config: Arc>, /// Rolling-window QPS estimate, published in `ModelInfo.qps_recent`. /// Stored as u32 (integer QPS) for lock-free update; fractional QPS /// information is not useful at the UI grain we're publishing. @@ -54,10 +49,15 @@ pub struct Inference { impl Inference { /// Build a new Inference with the given initial state. Use /// `ModelSourceState::Dormant` when no model is loaded (Day 1 default). - pub fn new(initial_state: ModelSourceState, config: Arc) -> Self { + pub fn new( + initial_state: ModelSourceState, + config: Arc, + app_config: Arc>, + ) -> Self { Self { state: ArcSwap::from_pointee(initial_state), config, + app_config, qps_recent: AtomicU32::new(0), failure_count: AtomicU32::new(0), failure_window_start: AtomicU64::new(0), @@ -177,7 +177,6 @@ impl Inference { /// MultiTask path. Runs the AE batch → computes per-flow MSE → feeds the /// classifier over (ae_features ++ ae_score) → fires on anomaly OR /// non-Normal classifier agreement OR elevated C2 head. - #[allow(clippy::too_many_arguments)] fn infer_multitask( &self, ae: &RunnableModel, @@ -467,12 +466,13 @@ impl Inference { if open_since == 0 { return false; } + let cooldown = self.app_config.load().ml.circuit_breaker_cooldown_secs; let elapsed = Self::now_secs().saturating_sub(open_since); - if elapsed >= CIRCUIT_BREAKER_COOLDOWN_SECS { + if elapsed >= cooldown { self.circuit_open_since.store(0, Ordering::Relaxed); self.failure_count.store(0, Ordering::Relaxed); self.failure_window_start.store(0, Ordering::Relaxed); - log!(MLLog::CircuitBreakerReset(CIRCUIT_BREAKER_COOLDOWN_SECS)); + log!(MLLog::CircuitBreakerReset(cooldown)); return false; } true @@ -481,17 +481,20 @@ impl Inference { fn record_failure(&self) { let now = Self::now_secs(); let window_start = self.failure_window_start.load(Ordering::Relaxed); + let cfg = self.app_config.load(); + let window_secs = cfg.ml.circuit_breaker_window_secs; + let threshold = cfg.ml.circuit_breaker_threshold; - if window_start == 0 || now.saturating_sub(window_start) > CIRCUIT_BREAKER_WINDOW_SECS { + if window_start == 0 || now.saturating_sub(window_start) > window_secs { self.failure_window_start.store(now, Ordering::Relaxed); self.failure_count.store(1, Ordering::Relaxed); return; } let count = self.failure_count.fetch_add(1, Ordering::Relaxed) + 1; - if count >= CIRCUIT_BREAKER_THRESHOLD { + if count >= threshold { self.circuit_open_since.store(now, Ordering::Relaxed); - log!(MLLog::CircuitBreakerOpen(count, CIRCUIT_BREAKER_WINDOW_SECS)); + log!(MLLog::CircuitBreakerOpen(count, window_secs)); } } } diff --git a/net-guardia/src/core/ml/model_loader.rs b/net-guardia/src/core/ml/model_loader.rs index 2ca6dfa..6d1469e 100644 --- a/net-guardia/src/core/ml/model_loader.rs +++ b/net-guardia/src/core/ml/model_loader.rs @@ -20,25 +20,24 @@ use super::adapter::MLModelAdapter; use super::manifest::{AdapterKind, LabelSpec, ModelManifest}; use crate::model::config::constants::MODELS_DIR; use crate::model::detection::ml_detection::RunnableModel; +use crate::model::detection::ml_inference_config::MLInferenceConfig; use crate::model::error::ml::MLError; use crate::model::log::ml::MLLog; -use crate::model::system::config::MLInferenceConfig; - -/// Wall-clock budget for a single ONNX parse + optimize + runnable chain. -/// A malformed or maliciously-crafted model can wedge tract's graph solver; -/// the timeout keeps an admin-triggered upload from blocking the watcher -/// indefinitely. Five seconds is generous for the models that currently -/// ship (<10MB) while still bounding pathological inputs. -const ONNX_LOAD_TIMEOUT: Duration = Duration::from_secs(5); /// Build an `MLModelAdapter` by loading the ONNX file(s) the manifest names, /// validating shape against the inference config's feature counts, and /// wrapping the underlying `RunnableModel`s in `Arc` for zero-copy swap. +/// +/// `onnx_load_timeout` caps tract's parse/optimize/runnable chain — a +/// malformed or maliciously-crafted model can wedge the graph solver, so +/// this keeps the watcher / upload path bounded. Sourced from +/// `MlConfig::onnx_load_timeout_secs` at the call site. pub fn build_adapter( manifest: &ModelManifest, manifest_path: Option<&Path>, inference_config: &MLInferenceConfig, batch_size: usize, + onnx_load_timeout: Duration, ) -> Result { let resolve = |rel: &str| -> PathBuf { match manifest_path { @@ -57,7 +56,7 @@ pub fn build_adapter( }; let path = resolve(model_name); let n_features = inference_config.num_ae_features(); - let model = Arc::new(loader(&path, model_name, n_features, batch_size)?); + let model = Arc::new(loader(&path, model_name, n_features, batch_size, onnx_load_timeout)?); Ok(MLModelAdapter::AutoencoderOnly { model, batch_size, @@ -73,7 +72,7 @@ pub fn build_adapter( }; let path = resolve(model_name); let n_features = inference_config.num_classifier_features(); - let model = Arc::new(loader(&path, model_name, n_features, batch_size)?); + let model = Arc::new(loader(&path, model_name, n_features, batch_size, onnx_load_timeout)?); let labels = manifest.labels.clone(); let normal_idx = find_label_index(&labels, "Normal"); Ok(MLModelAdapter::ClassifierOnly { @@ -99,8 +98,14 @@ pub fn build_adapter( }; let n_ae = inference_config.num_ae_features(); let n_cls = inference_config.num_classifier_features(); - let ae = Arc::new(loader(&resolve(ae_name), ae_name, n_ae, batch_size)?); - let classifier = Arc::new(loader(&resolve(cls_name), cls_name, n_cls, batch_size)?); + let ae = Arc::new(loader(&resolve(ae_name), ae_name, n_ae, batch_size, onnx_load_timeout)?); + let classifier = Arc::new(loader( + &resolve(cls_name), + cls_name, + n_cls, + batch_size, + onnx_load_timeout, + )?); let labels = manifest.labels.clone(); let normal_idx = find_label_index(&labels, "Normal"); let c2_idx = find_label_index(&labels, "C2 Communication"); @@ -128,13 +133,19 @@ fn find_label_index(labels: &BTreeMap, target: &str) -> Optio .and_then(|(k, _)| k.parse::().ok()) } -fn loader(model_path: &Path, model_name: &str, features: usize, batch_size: usize) -> Result { +fn loader( + model_path: &Path, + model_name: &str, + features: usize, + batch_size: usize, + onnx_load_timeout: Duration, +) -> Result { log!(MLLog::ModelLoading(model_name.to_string(), features, batch_size)); let start = Instant::now(); let path_for_thread = model_path.to_path_buf(); let name_for_thread = model_name.to_string(); - let result = load_with_timeout(model_path.to_path_buf(), ONNX_LOAD_TIMEOUT, move || { + let result = load_with_timeout(model_path.to_path_buf(), onnx_load_timeout, move || { loader_inner(&path_for_thread, &name_for_thread, features, batch_size) }); let elapsed_ms = start.elapsed().as_millis() as u64; @@ -296,7 +307,8 @@ mod tests { return; } let (cfg, manifest) = MLInferenceConfig::from_manifest_with_sidecar(manifest_path).expect("config load"); - let adapter = build_adapter(&manifest, Some(manifest_path), &cfg, 8).expect("build adapter"); + let adapter = + build_adapter(&manifest, Some(manifest_path), &cfg, 8, Duration::from_secs(5)).expect("build adapter"); match adapter { MLModelAdapter::MultiTask { n_ae, n_cls, .. } => { assert_eq!(n_ae, 31); diff --git a/net-guardia/src/core/ml/model_watcher.rs b/net-guardia/src/core/ml/model_watcher.rs index 6e17462..5b34a85 100644 --- a/net-guardia/src/core/ml/model_watcher.rs +++ b/net-guardia/src/core/ml/model_watcher.rs @@ -8,6 +8,7 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::{Duration, SystemTime}; +use arc_swap::ArcSwap; use macros::log; use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; use tokio::sync::mpsc; @@ -16,24 +17,21 @@ use tokio::time::sleep; use super::adapter::ModelSourceState; use super::inference::Inference; use super::model_loader::build_adapter; -use crate::infrastructure::app_config::AppConfig; +use crate::model::config::AppConfig; use crate::model::config::constants::{MANIFEST_FILENAME, MODELS_DIR, STAGING_SUBDIR}; +use crate::model::detection::ml_inference_config::MLInferenceConfig; use crate::model::detection::model_source::ModelInfo; use crate::model::error::ml::MLError; use crate::model::log::ml::MLLog; -use crate::model::system::config::MLInferenceConfig; - -/// Debounce window: wait for both manifest and ONNX to land before reloading. -const DEBOUNCE_SECS: u64 = 5; pub struct ModelWatcher { inference: Arc, - app_config: Arc, + config: Arc>, } impl ModelWatcher { - pub fn new(inference: Arc, app_config: Arc) -> Self { - Self { inference, app_config } + pub fn new(inference: Arc, config: Arc>) -> Self { + Self { inference, config } } pub fn start(self) { @@ -66,14 +64,14 @@ impl ModelWatcher { if rx.recv().await.is_none() { break; } - sleep(Duration::from_secs(DEBOUNCE_SECS)).await; + sleep(Duration::from_secs(self.config.load().ml.model_watcher_debounce_secs)).await; while rx.try_recv().is_ok() {} // `try_reload` loads the manifest + sidecar + ONNX off disk, any // of which can block for >10ms on a cold cache — move it off the // tokio worker so the rest of the async runtime keeps turning. let inference = Arc::clone(&self.inference); - let app_config = Arc::clone(&self.app_config); - let _ = tokio::task::spawn_blocking(move || try_reload(&inference, &app_config)).await; + let config = Arc::clone(&self.config); + let _ = tokio::task::spawn_blocking(move || try_reload(&inference, &config)).await; } Ok(()) @@ -107,7 +105,7 @@ impl ModelWatcher { /// than crashing. Runs under `spawn_blocking` because manifest + sidecar + /// ONNX loads are synchronous disk I/O plus a `tract` graph solve that /// routinely takes >10 ms. -fn try_reload(inference: &Inference, app_config: &AppConfig) { +fn try_reload(inference: &Inference, config: &ArcSwap) { let manifest_path = PathBuf::from(MODELS_DIR).join(MANIFEST_FILENAME); // Path 1 — manifest disappeared: transition to Dormant. @@ -120,22 +118,27 @@ fn try_reload(inference: &Inference, app_config: &AppConfig) { // Path 2 — manifest present: re-read + rebuild adapter. log!(MLLog::ModelReloadStarting); - let batch_size = app_config.inference.inference_batch_size; + let app_cfg = config.load(); + let batch_size = app_cfg.ml.inference_batch_size; + let onnx_load_timeout = Duration::from_secs(app_cfg.ml.onnx_load_timeout_secs); + drop(app_cfg); match MLInferenceConfig::from_manifest_with_sidecar(&manifest_path) { - Ok((config, manifest)) => match build_adapter(&manifest, Some(&manifest_path), &config, batch_size) { - Ok(adapter) => { - let info = ModelInfo::new( - manifest.name.clone(), - manifest.adapter.as_str().to_string(), - manifest.features.len(), - ); - inference.swap_state(ModelSourceState::Active { adapter, info }); - log!(MLLog::ModelReloadSuccess); + Ok((config, manifest)) => { + match build_adapter(&manifest, Some(&manifest_path), &config, batch_size, onnx_load_timeout) { + Ok(adapter) => { + let info = ModelInfo::new( + manifest.name.clone(), + manifest.adapter.as_str().to_string(), + manifest.features.len(), + ); + inference.swap_state(ModelSourceState::Active { adapter, info }); + log!(MLLog::ModelReloadSuccess); + } + Err(e) => { + record_error(inference, e.to_string(), Some(manifest_path.clone())); + } } - Err(e) => { - record_error(inference, e.to_string(), Some(manifest_path.clone())); - } - }, + } Err(e) => { record_error(inference, e.to_string(), Some(manifest_path.clone())); } diff --git a/net-guardia/src/core/ml/traffic_logger.rs b/net-guardia/src/core/ml/traffic_logger.rs index 7f31747..b8aa0b5 100644 --- a/net-guardia/src/core/ml/traffic_logger.rs +++ b/net-guardia/src/core/ml/traffic_logger.rs @@ -40,11 +40,6 @@ pub const DEFAULT_MAX_FILE_AGE: Duration = Duration::from_secs(3600); /// the oldest files to bring the sum back under the cap. pub const DEFAULT_TOTAL_BUDGET_BYTES: u64 = 10 * 1024 * 1024 * 1024; -/// Lossy-drop channel capacity. Inference throughput is spiky; if the -/// writer falls behind, callers get a `TrySendError::Full` back rather -/// than blocking the hot path. The lost rows are observable in logs. -const CHANNEL_CAPACITY: usize = 65_536; - /// Prefix literal baked into every rotated file's name so the HTTP /// file-list handler can recognize ours and skip unrelated files. pub const FLOW_TRACE_FILE_MARKER: &str = "flow-trace-"; @@ -95,6 +90,7 @@ impl TrafficLogger { base_path: &Path, header: Vec, policy: RotationPolicy, + channel_capacity: usize, comm: Option>, ) -> Result { let directory = base_path @@ -103,7 +99,7 @@ impl TrafficLogger { .unwrap_or_else(|| PathBuf::from(".")); std_fs::create_dir_all(&directory)?; - let (sender, receiver) = bounded::>(CHANNEL_CAPACITY); + let (sender, receiver) = bounded::>(channel_capacity.max(1)); let writer_dir = directory.clone(); let writer_header = header; @@ -420,7 +416,7 @@ mod tests { #[tokio::test] async fn stop_audit_reaches_subscriber_when_comm_provided() { - let comm = Arc::new(CommunicationManager::new()); + let comm = Arc::new(CommunicationManager::new(256)); comm.register_event_type::(); let mut rx = comm.subscribe_event::().unwrap(); diff --git a/net-guardia/src/core/notification_service.rs b/net-guardia/src/core/notification_service.rs index c868832..889b501 100644 --- a/net-guardia/src/core/notification_service.rs +++ b/net-guardia/src/core/notification_service.rs @@ -1,34 +1,35 @@ use std::sync::Arc; +use arc_swap::ArcSwap; use serde_json::Value; use crate::core::email::scheduler::SmtpClient; -use crate::interface::port::app_repo::AppRepo; use crate::interface::port::notification::AlertNotifierFactory; use crate::interface::port::secret_store::SecretStorePort; use crate::interface::port::setting::SettingRepo; +use crate::model::config::AppConfig; use crate::model::error::Error; use crate::model::error::misc::MiscError; /// Domain service for notification config (Telegram, SMTP). /// Coordinates DB persistence and external service testing. pub struct NotificationService { - notif: Arc, - repo: Arc, + notif: Arc, + config: Arc>, secrets: Arc, alert_notifier_factory: Arc, } impl NotificationService { pub fn new( - notif: Arc, - repo: Arc, + notif: Arc, + config: Arc>, secrets: Arc, alert_notifier_factory: Arc, ) -> Self { Self { notif, - repo, + config, secrets, alert_notifier_factory, } @@ -86,19 +87,18 @@ impl NotificationService { /// Send a test email using current SMTP config. pub fn test_smtp(&self) -> Result { - let smtp_client = SmtpClient::from_database(self.repo.as_ref(), Some(self.secrets.as_ref()))?; - let smtp = smtp_client.ok_or_else(|| { + let smtp_cfg = self.config.load().notification.smtp.clone(); + let smtp = SmtpClient::from_config(&smtp_cfg, Some(self.secrets.as_ref()))?.ok_or_else(|| { MiscError::ValidationError( "SMTP not configured. Set smtp_host, smtp_port, smtp_username, smtp_password first. \ If smtp_username is not an email address, also set smtp_sender.", ) })?; - let recipient = self - .repo - .get_setting("smtp_recipient")? - .filter(|r| !r.is_empty()) - .ok_or_else(|| MiscError::ValidationError("No smtp_recipient configured."))?; + if smtp_cfg.recipient.is_empty() { + Err(MiscError::ValidationError("No smtp_recipient configured."))?; + } + let recipient = smtp_cfg.recipient; smtp.send( &recipient, diff --git a/net-guardia/src/core/observability/log_buffer.rs b/net-guardia/src/core/observability/log_buffer.rs index f879b80..ad65a95 100644 --- a/net-guardia/src/core/observability/log_buffer.rs +++ b/net-guardia/src/core/observability/log_buffer.rs @@ -11,14 +11,6 @@ use tracing::{Event, Level, Subscriber}; use tracing_subscriber::Layer; use tracing_subscriber::layer::Context; -/// Ring-buffer capacity. Tuned for ~30 min of INFO traffic on a small SOC -/// deployment; DEBUG floods will churn faster. -const DEFAULT_CAPACITY: usize = 5_000; - -/// Per-entry payload cap. Guards against pathological debug logs from -/// bursting the buffer. -const MAX_MESSAGE_BYTES: usize = 8_192; - /// Monotonic id allocator. Clients use `since_id` to resume tailing. /// u64 never wraps in practice (2^64 events at 1 µs/event ≈ 584 000 years). static NEXT_ID: AtomicU64 = AtomicU64::new(1); @@ -46,13 +38,16 @@ pub struct LogEntry { struct LogRingBuffer { entries: Mutex>, capacity: usize, + max_message_bytes: usize, } impl LogRingBuffer { - fn new(capacity: usize) -> Self { + fn new(capacity: usize, max_message_bytes: usize) -> Self { + let cap = capacity.max(1); Self { - entries: Mutex::new(VecDeque::with_capacity(capacity)), - capacity, + entries: Mutex::new(VecDeque::with_capacity(cap)), + capacity: cap, + max_message_bytes: max_message_bytes.max(64), } } @@ -139,18 +134,12 @@ fn now_unix_ms() -> u64 { pub struct LogBufferLayer; impl LogBufferLayer { - pub fn new() -> Self { - let _ = BUFFER.set(LogRingBuffer::new(DEFAULT_CAPACITY)); + pub fn new(capacity: usize, max_message_bytes: usize) -> Self { + let _ = BUFFER.set(LogRingBuffer::new(capacity, max_message_bytes)); Self } } -impl Default for LogBufferLayer { - fn default() -> Self { - Self::new() - } -} - impl Layer for LogBufferLayer { fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) { let Some(buf) = BUFFER.get() else { @@ -160,8 +149,8 @@ impl Layer for LogBufferLayer { let mut visitor = MessageVisitor::default(); event.record(&mut visitor); let mut message = visitor.into_message(); - if message.len() > MAX_MESSAGE_BYTES { - message.truncate(MAX_MESSAGE_BYTES); + if message.len() > buf.max_message_bytes { + message.truncate(buf.max_message_bytes); message.push_str("…[truncated]"); } let entry = LogEntry { @@ -264,7 +253,7 @@ mod tests { #[test] fn ring_buffer_drops_oldest_at_capacity() { - let buf = LogRingBuffer::new(3); + let buf = LogRingBuffer::new(3, 8192); for id in 1..=5 { buf.push(make_entry(id, "INFO", "m")); } @@ -277,7 +266,7 @@ mod tests { #[test] fn snapshot_filters_since_id_and_severity() { - let buf = LogRingBuffer::new(16); + let buf = LogRingBuffer::new(16, 8192); buf.push(make_entry(1, "INFO", "first")); buf.push(make_entry(2, "DEBUG", "noisy")); buf.push(make_entry(3, "ERROR", "boom")); @@ -290,7 +279,7 @@ mod tests { #[test] fn snapshot_respects_limit() { - let buf = LogRingBuffer::new(16); + let buf = LogRingBuffer::new(16, 8192); for id in 1..=10 { buf.push(make_entry(id, "INFO", "m")); } diff --git a/net-guardia/src/core/playbook_service.rs b/net-guardia/src/core/playbook_service.rs index b666c6a..432d9c8 100644 --- a/net-guardia/src/core/playbook_service.rs +++ b/net-guardia/src/core/playbook_service.rs @@ -10,7 +10,7 @@ use crate::interface::port::app_repo::AppRepo; use crate::model::error::Error; use crate::model::error::soar::SoarError; use crate::model::soar::playbook_data::{ - ActionData, ActiveBlockData, ConditionData, CreatePlaybookInput, ExecutionData, PlaybookData, UpdatePlaybookRow, + ActionView, ActiveBlockView, ConditionView, CreatePlaybookInput, ExecutionView, PlaybookView, UpdatePlaybookInput, }; /// Domain service for SOAR playbook CRUD operations. @@ -30,9 +30,9 @@ impl PlaybookService { } } - pub fn list_playbooks(&self) -> Result, Error> { - let rows = self.db.load_playbooks_with_actions()?; - let mut result: Vec = Vec::new(); + pub fn list_playbooks(&self) -> Result, Error> { + let rows = self.db.list_playbooks_with_actions()?; + let mut result: Vec = Vec::new(); for ( pb_id, @@ -54,7 +54,7 @@ impl PlaybookService { if last.id == pb_id { last } else { - result.push(PlaybookData { + result.push(PlaybookView { id: pb_id, name, enabled, @@ -70,7 +70,7 @@ impl PlaybookService { result.last_mut().unwrap_or_else(|| unreachable!()) } } else { - result.push(PlaybookData { + result.push(PlaybookView { id: pb_id, name, enabled, @@ -90,7 +90,7 @@ impl PlaybookService { if let (Some(aid), Some(order), Some(atype), Some(params_str)) = (action_id, action_order, action_type, action_params) { - pb.actions.push(ActionData { + pb.actions.push(ActionView { id: aid, action_order: order, action_type: atype, @@ -100,10 +100,10 @@ impl PlaybookService { } // Load conditions and attach to playbooks - let cond_rows = self.db.load_all_playbook_conditions()?; - let mut cond_map: HashMap> = HashMap::new(); + let cond_rows = self.db.list_all_playbook_conditions()?; + let mut cond_map: HashMap> = HashMap::new(); for (cid, pb_id, ctype, operator, value, value2) in cond_rows { - cond_map.entry(pb_id).or_default().push(ConditionData { + cond_map.entry(pb_id).or_default().push(ConditionView { id: cid, condition_type: ctype, operator, @@ -155,7 +155,7 @@ impl PlaybookService { } pub fn update_playbook(&self, id: i64, input: &CreatePlaybookInput) -> Result { - let row = UpdatePlaybookRow { + let row = UpdatePlaybookInput { name: input.name.clone(), trigger_event: input.trigger_event.clone(), condition_threshold: input.condition_threshold, @@ -206,11 +206,11 @@ impl PlaybookService { Ok(deleted) } - pub fn list_active_blocks(&self) -> Result, Error> { - let blocks = self.db.get_active_soar_blocks()?; + pub fn list_active_blocks(&self) -> Result, Error> { + let blocks = self.db.list_active_soar_blocks()?; Ok(blocks .into_iter() - .map(|(id, ip, pb_id, expires)| ActiveBlockData { + .map(|(id, ip, pb_id, expires)| ActiveBlockView { id, source_ip: ip, playbook_id: pb_id, @@ -226,7 +226,7 @@ impl PlaybookService { // Look up the block to get source_ip let block = self .db - .get_soar_block_by_id(id)? + .find_soar_block_by_id(id)? .ok_or_else(|| SoarError::UnblockRuleNotFound(id))?; let source_ip = &block.1; @@ -244,12 +244,12 @@ impl PlaybookService { Ok(()) } - pub fn list_executions(&self, limit: i64) -> Result, Error> { + pub fn list_executions(&self, limit: i64) -> Result, Error> { let rows = self.db.list_soar_executions(limit)?; Ok(rows .into_iter() .map( - |(id, pb_id, source_ip, trigger_event, actions, created_at)| ExecutionData { + |(id, pb_id, source_ip, trigger_event, actions, created_at)| ExecutionView { id, playbook_id: pb_id, source_ip, @@ -262,7 +262,7 @@ impl PlaybookService { } pub fn list_whitelist(&self) -> Result, Error> { - self.db.load_admin_whitelist() + self.db.list_admin_whitelist() } pub fn add_whitelist(&self, ip: &str) -> Result<(), Error> { diff --git a/net-guardia/src/core/soar/actions.rs b/net-guardia/src/core/soar/actions.rs index 06217f0..c7c9e60 100644 --- a/net-guardia/src/core/soar/actions.rs +++ b/net-guardia/src/core/soar/actions.rs @@ -29,26 +29,12 @@ use crate::model::event::ThreatDetectedEvent; use crate::model::log::soar::SoarLog; use crate::model::soar::playbook::{Playbook, PlaybookAction}; -/// Default block TTL when a `block_ip` action omits `ttl_secs`. -const DEFAULT_BLOCK_TTL_SECS: u64 = 1800; -/// DB-overridable cap on per-block TTL — see setting `soar_max_ttl_secs`. -const DEFAULT_SOAR_MAX_TTL_SECS: u64 = 86_400; -/// DB-overridable cap on concurrent SOAR-driven blocks — see setting -/// `soar_max_auto_block_cap`. -const DEFAULT_SOAR_MAX_AUTO_BLOCK_CAP: u32 = 100; -/// Default rate-limit reduction factor when an `adjust_rate_limit` action -/// omits `factor`. 0.5 = halve the current rate. -const DEFAULT_RATE_LIMIT_FACTOR: f64 = 0.5; -/// Default rate-limit TTL when an `adjust_rate_limit` action omits `ttl_secs`. -const DEFAULT_RATE_LIMIT_TTL_SECS: u64 = 600; /// Lower bound on the rate-limit factor — anything below 1% of current /// would brick traffic flow. const RATE_LIMIT_FACTOR_MIN: f64 = 0.01; /// Upper bound on the rate-limit factor — `1.0` is a no-op; values above /// would *raise* the limit, which isn't a SOAR mitigation. const RATE_LIMIT_FACTOR_MAX: f64 = 1.0; -/// Default webhook timeout when an action omits `timeout_secs`. -const DEFAULT_WEBHOOK_TIMEOUT_SECS: u64 = 10; /// Fallback port when the webhook URL has no explicit port and no /// well-known scheme port. const DEFAULT_WEBHOOK_HTTPS_PORT: u16 = 443; @@ -56,9 +42,6 @@ const DEFAULT_WEBHOOK_HTTPS_PORT: u16 = 443; /// `-1` is reserved on the audit / cooldown maps and never assigned to a /// real DB playbook row. const FALLBACK_PLAYBOOK_ID: i64 = -1; -/// Cooldown applied to the fallback path so a single noisy IP doesn't -/// spam the WORM audit chain on every fused detection. -const FALLBACK_COOLDOWN_SECS: i64 = 300; impl SoarEngine { /// Check if the system is in enforce mode (as opposed to monitor mode). @@ -167,12 +150,11 @@ impl SoarEngine { .params .get("ttl_secs") .and_then(|v| v.as_u64()) - .unwrap_or(DEFAULT_BLOCK_TTL_SECS); + .unwrap_or_else(|| self.config.load().soar.default_block_ttl_secs); - // Two settings read together off the tokio worker thread — r2d2's - // pool.get() and rusqlite are blocking, so back-to-back calls inside - // an async fn can stall the executor under burst load. - let (max_ttl, max_cap) = read_block_caps(self.db.clone()).await?; + let soar_cfg = self.config.load().soar.clone(); + let max_ttl = soar_cfg.max_ttl_secs; + let max_cap = soar_cfg.max_auto_block_cap; if ttl_secs > max_ttl { Err(SoarError::InvalidTtl(ttl_secs, max_ttl))?; } @@ -252,22 +234,23 @@ impl SoarEngine { ) -> Result { let owner = self.rate_limit.as_ref().ok_or(SoarError::RateLimitUnavailable)?; + let soar_defaults = self.config.load().soar.clone(); let factor = action .params .get("factor") .and_then(|v| v.as_f64()) - .unwrap_or(DEFAULT_RATE_LIMIT_FACTOR); + .unwrap_or(soar_defaults.default_rate_limit_factor); let ttl_secs = action .params .get("ttl_secs") .and_then(|v| v.as_u64()) - .unwrap_or(DEFAULT_RATE_LIMIT_TTL_SECS); + .unwrap_or(soar_defaults.default_rate_limit_ttl_secs); if !(RATE_LIMIT_FACTOR_MIN..=RATE_LIMIT_FACTOR_MAX).contains(&factor) { Err(SoarError::InvalidRateLimitFactor(factor))?; } - let max_ttl = read_max_ttl(self.db.clone()).await; + let max_ttl = self.config.load().soar.max_ttl_secs; if ttl_secs > max_ttl { Err(SoarError::InvalidTtl(ttl_secs, max_ttl))?; } @@ -282,10 +265,7 @@ impl SoarEngine { if let Some(notifier) = &self.alert_notifier { let country = if let Some(geoip) = &self.geoip { if let Ok(ip_addr) = event.source_ip.parse::() { - match geoip.lookup(ip_addr).await { - Ok(Some(loc)) => loc.country, - _ => None, - } + geoip.lookup(ip_addr).await.and_then(|loc| loc.country) } else { None } @@ -315,7 +295,8 @@ impl SoarEngine { /// Send email alert. async fn action_send_email(&self, event: &ThreatDetectedEvent) -> Result { - match SmtpClient::from_soar_port(&*self.db, self.secrets.as_deref())? { + let smtp_cfg = self.config.load().notification.smtp.clone(); + match SmtpClient::from_config(&smtp_cfg, self.secrets.as_deref())? { Some(smtp) => { let subject = format!( "[NetGuardia] Threat Alert: {} from {}", @@ -332,7 +313,8 @@ impl SoarEngine { event.confidence * 100.0, Utc::now().format("%Y-%m-%d %H:%M:%S UTC"), ); - if let Some(recipient) = self.db.get_setting("smtp_recipient")? { + if !smtp_cfg.recipient.is_empty() { + let recipient = smtp_cfg.recipient; spawn_blocking(move || smtp.send(&recipient, &subject, &body)) .await .map_err(|e| SoarError::ActionFailed("send_email", e))??; @@ -358,7 +340,7 @@ impl SoarEngine { .params .get("timeout_secs") .and_then(|v| v.as_u64()) - .unwrap_or(DEFAULT_WEBHOOK_TIMEOUT_SECS); + .unwrap_or_else(|| self.config.load().soar.default_webhook_timeout_secs); // Parse URL and extract host let parsed_url = Url::parse(url_str).map_err(|e| SoarError::ActionFailed("webhook", e))?; @@ -460,7 +442,12 @@ impl SoarEngine { } // Check cooldown — uses FALLBACK_PLAYBOOK_ID as the synthetic key - if self.is_cooldown_active(FALLBACK_PLAYBOOK_ID, &event.source_ip, FALLBACK_COOLDOWN_SECS) { + let fallback_cfg = self.config.load().soar.clone(); + if self.is_cooldown_active( + FALLBACK_PLAYBOOK_ID, + &event.source_ip, + fallback_cfg.fallback_cooldown_secs, + ) { log!(SoarLog::CooldownActive("fallback".to_string(), event.source_ip.clone())); return Ok(()); } @@ -469,7 +456,7 @@ impl SoarEngine { let fake_action = PlaybookAction { action_order: 1, action_type: "block_ip".to_string(), - params: serde_json::json!({"ttl_secs": DEFAULT_BLOCK_TTL_SECS}), + params: serde_json::json!({"ttl_secs": fallback_cfg.default_block_ttl_secs}), }; let block_result = self.execute_action(&fake_action, event, FALLBACK_PLAYBOOK_ID).await; @@ -494,40 +481,6 @@ impl SoarEngine { } } -/// Read both block-related caps in a single offloaded blocking call so the -/// async caller pays one spawn_blocking hop instead of two. -async fn read_block_caps(db: Arc) -> Result<(u64, u32), Error> { - spawn_blocking(move || { - let max_ttl: u64 = db - .get_setting("soar_max_ttl_secs") - .ok() - .flatten() - .and_then(|v| v.parse().ok()) - .unwrap_or(DEFAULT_SOAR_MAX_TTL_SECS); - let max_cap: u32 = db - .get_setting("soar_max_auto_block_cap") - .ok() - .flatten() - .and_then(|v| v.parse().ok()) - .unwrap_or(DEFAULT_SOAR_MAX_AUTO_BLOCK_CAP); - Ok::<_, Error>((max_ttl, max_cap)) - }) - .await - .map_err(|e| SoarError::ActionFailed("read_block_caps", e))? -} - -async fn read_max_ttl(db: Arc) -> u64 { - spawn_blocking(move || { - db.get_setting("soar_max_ttl_secs") - .ok() - .flatten() - .and_then(|v| v.parse().ok()) - .unwrap_or(DEFAULT_SOAR_MAX_TTL_SECS) - }) - .await - .unwrap_or(DEFAULT_SOAR_MAX_TTL_SECS) -} - async fn commit_block_blocking( db: Arc, source_ip: String, diff --git a/net-guardia/src/core/soar/engine.rs b/net-guardia/src/core/soar/engine.rs index 1b97018..d536c5f 100644 --- a/net-guardia/src/core/soar/engine.rs +++ b/net-guardia/src/core/soar/engine.rs @@ -14,13 +14,13 @@ use tokio::sync::broadcast::error::RecvError; use crate::core::soar::frequency::FrequencyTracker; use crate::core::soar::rate_limit_owner::RateLimitOwnerHandle; use crate::infrastructure::communication_manager::CommunicationManager; -use crate::infrastructure::geoip::GeoIpService; use crate::interface::port::access_control::AccessControlPort; use crate::interface::port::app_repo::AppRepo; +use crate::interface::port::geo_lookup::GeoLookup; use crate::interface::port::notification::AlertNotifier; use crate::interface::port::rate_limit_api::RateLimitPort; use crate::interface::port::secret_store::SecretStorePort; -use crate::model::config::constants::MAX_PENDING_UNBLOCK_RETRIES; +use crate::model::config::AppConfig; use crate::model::detection::attack_type::canonical_from_str; use crate::model::error::Error; use crate::model::error::soar::SoarError; @@ -32,15 +32,6 @@ use crate::model::soar::playbook::{Playbook, PlaybookAction}; /// Cooldown key: (playbook_id, source_ip) type CooldownKey = (i64, String); -/// Maximum number of `handle_threat_event` futures allowed in flight at the -/// same time. Replaces the previous unbounded `tokio::spawn`-per-event -/// pattern, which under fusion-emit bursts could pile up faster than the -/// executor drains and starve other async work. When all permits are held, -/// the event loop blocks at `Semaphore::acquire_owned` — backpressure then -/// surfaces as broadcast `Lagged` (visible in the receiver-lag metric) -/// rather than as silent task-queue growth. -const SOAR_HANDLE_CONCURRENCY: usize = 16; - /// SOAR Engine — subscribes to ThreatDetectedEvent and executes matching playbooks. /// /// The engine is intentionally split across three files within `core::soar`: @@ -67,7 +58,7 @@ pub struct SoarEngine { /// Optional alert notifier (Telegram, etc.). pub(super) alert_notifier: Option>, /// Optional GeoIP service for country lookups. - pub(super) geoip: Option>, + pub(super) geoip: Option>, /// Owner-task handle that serializes the rate-limit DB+eBPF /// read-modify-write batch. `None` when no `RateLimitPort` was wired /// up (eBPF unavailable); SOAR actions that need rate-limit then @@ -77,32 +68,39 @@ pub struct SoarEngine { pub(super) enforce_level_cache: Arc, /// Secret store for decrypting SMTP passwords etc. pub(super) secrets: Option>, + pub(super) config: Arc>, } impl SoarEngine { pub fn new( db: Arc, + config: Arc>, access_control: Arc, alert_notifier: Option>, - geoip: Option>, + geoip: Option>, rate_limit: Option>, enforce_level_cache: Arc, secrets: Option>, ) -> Result { - let rate_limit_owner = rate_limit.map(|rl| RateLimitOwnerHandle::spawn(db.clone(), rl)); + let soar_cfg = config.load(); + let rate_limit_channel = soar_cfg.soar.rate_limit_cmd_channel_capacity; + let freq_max_keys = soar_cfg.soar.frequency_max_tracked_keys; + drop(soar_cfg); + let rate_limit_owner = rate_limit.map(|rl| RateLimitOwnerHandle::spawn(db.clone(), rl, rate_limit_channel)); let engine = Self { db, access_control, playbooks: ArcSwap::from_pointee(Vec::new()), admin_whitelist: ArcSwap::from_pointee(HashSet::new()), cooldowns: DashMap::new(), - frequency_tracker: FrequencyTracker::new(), + frequency_tracker: FrequencyTracker::new(freq_max_keys), active_block_count: AtomicU32::new(0), alert_notifier, geoip, rate_limit: rate_limit_owner, enforce_level_cache, secrets, + config, }; engine.reload_cache()?; Ok(engine) @@ -111,7 +109,7 @@ impl SoarEngine { /// Load playbooks and admin whitelist from DB into memory. pub fn reload_cache(&self) -> Result<(), Error> { // Load playbooks via single JOIN query (no N+1) - let rows = self.db.load_playbooks_with_actions()?; + let rows = self.db.list_playbooks_with_actions()?; let mut playbooks: Vec = Vec::new(); for ( @@ -164,7 +162,7 @@ impl SoarEngine { } // Load conditions and attach to playbooks - let condition_rows = self.db.load_all_playbook_conditions()?; + let condition_rows = self.db.list_all_playbook_conditions()?; for (_cid, pb_id, ctype_str, operator, value, value2) in condition_rows { if let Ok(ctype) = ctype_str.parse::() && let Some(pb) = playbooks.iter_mut().find(|p| p.id == pb_id) @@ -218,7 +216,7 @@ impl SoarEngine { self.playbooks.store(Arc::new(playbooks)); // Load admin whitelist - let whitelist = self.db.load_admin_whitelist()?; + let whitelist = self.db.list_admin_whitelist()?; let whitelist: HashSet = whitelist.into_iter().collect(); let whitelist_count = whitelist.len(); self.admin_whitelist.store(Arc::new(whitelist)); @@ -250,12 +248,13 @@ impl SoarEngine { async fn event_loop(self: Arc, mut rx: broadcast::Receiver) { log!(SoarLog::EngineStarted); - let semaphore = Arc::new(Semaphore::new(SOAR_HANDLE_CONCURRENCY)); + let concurrency = self.config.load().soar.handle_concurrency.max(1); + let semaphore = Arc::new(Semaphore::new(concurrency)); loop { match rx.recv().await { Ok(event) => { // Bounded fan-out: hold one permit per in-flight handler. - // When SOAR_HANDLE_CONCURRENCY are already running, this + // When handle_concurrency permits are already held, this // await blocks the recv loop, which is the backpressure // signal — broadcast surfaces it as Lagged on overflow. let permit = match Arc::clone(&semaphore).acquire_owned().await { @@ -308,7 +307,7 @@ impl SoarEngine { // First, retry any pending unblocks from previous orphan failures self.retry_pending_unblocks().await; - let active_blocks = self.db.get_active_soar_blocks()?; + let active_blocks = self.db.list_active_soar_blocks()?; let count = active_blocks.len(); for (_id, source_ip, _playbook_id, _expires_at) in &active_blocks { @@ -327,7 +326,7 @@ impl SoarEngine { /// Retry pending unblocks that failed during previous runs. async fn retry_pending_unblocks(&self) { - let pending = match self.db.load_pending_unblocks() { + let pending = match self.db.list_pending_unblocks() { Ok(p) => p, Err(e) => { log!(SoarLog::EventHandlingFailed(format!( @@ -339,7 +338,7 @@ impl SoarEngine { }; for (id, source_ip, retry_count) in pending { - if retry_count >= MAX_PENDING_UNBLOCK_RETRIES { + if retry_count >= self.config.load().soar.max_pending_unblock_retries { log!(SoarLog::EventHandlingFailed(format!( "Giving up on pending unblock for IP {} after {} retries", source_ip, retry_count @@ -439,7 +438,10 @@ mod tests { db.set_setting("enforce_mode", "enforce").ok(); // enforce=2 let cache = Arc::new(AtomicU8::new(2)); - SoarEngine::new(db as Arc, ac, None, None, None, cache, None) + AppConfig::seed_defaults(&*db).expect("seed config defaults"); + let cfg = AppConfig::from_settings(&*db).expect("load config"); + let config = Arc::new(ArcSwap::from_pointee(cfg)); + SoarEngine::new(db as Arc, config, ac, None, None, None, cache, None) .expect("Failed to create SOAR engine") } @@ -530,8 +532,20 @@ mod tests { db.insert_soar_block_rule("192.168.1.100", 1, &expires).ok(); let cache = Arc::new(AtomicU8::new(2)); - let engine = SoarEngine::new(db as Arc, mock.clone(), None, None, None, cache, None) - .expect("Failed to create engine"); + AppConfig::seed_defaults(&*db).expect("seed config defaults"); + let cfg = AppConfig::from_settings(&*db).expect("load config"); + let config = Arc::new(ArcSwap::from_pointee(cfg)); + let engine = SoarEngine::new( + db as Arc, + config, + mock.clone(), + None, + None, + None, + cache, + None, + ) + .expect("Failed to create engine"); engine.recover_active_blocks().await.expect("Recovery should succeed"); let blocked = mock.blocked_ips.lock(); @@ -552,8 +566,20 @@ mod tests { db.insert_soar_block_rule("10.0.0.1", 1, &expires).ok(); let cache = Arc::new(AtomicU8::new(2)); - let engine = SoarEngine::new(db as Arc, mock.clone(), None, None, None, cache, None) - .expect("Failed to create engine"); + AppConfig::seed_defaults(&*db).expect("seed config defaults"); + let cfg = AppConfig::from_settings(&*db).expect("load config"); + let config = Arc::new(ArcSwap::from_pointee(cfg)); + let engine = SoarEngine::new( + db as Arc, + config, + mock.clone(), + None, + None, + None, + cache, + None, + ) + .expect("Failed to create engine"); // Should not panic — errors are logged, not propagated let result = engine.recover_active_blocks().await; @@ -654,8 +680,20 @@ mod tests { db.insert_admin_whitelist("1.2.3.4").ok(); let cache = Arc::new(AtomicU8::new(2)); - let engine = SoarEngine::new(db as Arc, mock.clone(), None, None, None, cache, None) - .expect("Failed to create engine"); + AppConfig::seed_defaults(&*db).expect("seed config defaults"); + let cfg = AppConfig::from_settings(&*db).expect("load config"); + let config = Arc::new(ArcSwap::from_pointee(cfg)); + let engine = SoarEngine::new( + db as Arc, + config, + mock.clone(), + None, + None, + None, + cache, + None, + ) + .expect("Failed to create engine"); let event = ThreatDetectedEvent { source_ip: "1.2.3.4".to_string(), @@ -717,8 +755,20 @@ mod tests { db.seed_default_playbooks().ok(); let cache = Arc::new(AtomicU8::new(0)); - let engine = SoarEngine::new(db.clone() as Arc, mock, None, None, None, cache, None) - .expect("Failed to create engine"); + AppConfig::seed_defaults(&*db).expect("seed config defaults"); + let cfg = AppConfig::from_settings(&*db).expect("load config"); + let config = Arc::new(ArcSwap::from_pointee(cfg)); + let engine = SoarEngine::new( + db.clone() as Arc, + config, + mock, + None, + None, + None, + cache, + None, + ) + .expect("Failed to create engine"); // Should have loaded default playbooks let count = engine.playbooks.load().len(); diff --git a/net-guardia/src/core/soar/frequency.rs b/net-guardia/src/core/soar/frequency.rs index 4b3a5ea..51452df 100644 --- a/net-guardia/src/core/soar/frequency.rs +++ b/net-guardia/src/core/soar/frequency.rs @@ -6,20 +6,19 @@ use dashmap::DashMap; /// Key for frequency tracking: (playbook_id, source_ip). type FreqKey = (i64, String); -/// Maximum tracked keys to bound memory under DDoS. -const MAX_TRACKED_KEYS: usize = 50_000; - /// Lock-free frequency tracker using DashMap for concurrent per-IP event counting. pub struct FrequencyTracker { events: DashMap>, max_deque_size: usize, + max_tracked_keys: usize, } impl FrequencyTracker { - pub fn new() -> Self { + pub fn new(max_tracked_keys: usize) -> Self { Self { events: DashMap::new(), max_deque_size: 200, + max_tracked_keys: max_tracked_keys.max(1), } } @@ -73,8 +72,8 @@ impl FrequencyTracker { }); // Enforce max key cap to prevent unbounded growth under DDoS - if self.events.len() > MAX_TRACKED_KEYS { - let excess = self.events.len() - MAX_TRACKED_KEYS; + if self.events.len() > self.max_tracked_keys { + let excess = self.events.len() - self.max_tracked_keys; let keys_to_remove: Vec = self.events.iter().take(excess).map(|e| e.key().clone()).collect(); for key in keys_to_remove { self.events.remove(&key); diff --git a/net-guardia/src/core/soar/matcher.rs b/net-guardia/src/core/soar/matcher.rs index 2bbfb98..048e30a 100644 --- a/net-guardia/src/core/soar/matcher.rs +++ b/net-guardia/src/core/soar/matcher.rs @@ -20,17 +20,6 @@ use crate::model::soar::condition::{ConditionType, PlaybookCondition}; use crate::model::soar::dry_run::{DryRunAction, DryRunConditionResult, DryRunMatch}; use crate::model::soar::playbook::Playbook; -/// Default frequency-condition window when the playbook omits `value2`. -const DEFAULT_FREQUENCY_WINDOW_SECS: u64 = 60; - -/// Default minimum confidence for `SingleSourceHigh` when the playbook -/// omits `value2`. Conservative enough that ad-hoc solo playbooks don't -/// auto-block noisy single-source hits. -const DEFAULT_SINGLE_SOURCE_HIGH_MIN_CONFIDENCE: f32 = 0.95; - -/// Default expiry for cooldown cleanup when no playbook has a cooldown set. -const DEFAULT_COOLDOWN_EXPIRY_SECS: u64 = 3600; - impl SoarEngine { /// Find playbooks matching the event via trigger_event + multi-condition AND logic. /// Returns `Arc` so the per-event hot path bumps a refcount @@ -163,7 +152,7 @@ impl SoarEngine { .value2 .as_ref() .and_then(|s| s.parse::().ok()) - .unwrap_or(DEFAULT_FREQUENCY_WINDOW_SECS); + .unwrap_or_else(|| self.config.load().soar.default_frequency_window_secs); let count = self .frequency_tracker .record_and_count(pb.id, &event.source_ip, window_secs); @@ -199,7 +188,7 @@ impl SoarEngine { .value2 .as_ref() .and_then(|s| s.parse::().ok()) - .unwrap_or(DEFAULT_SINGLE_SOURCE_HIGH_MIN_CONFIDENCE); + .unwrap_or_else(|| self.config.load().soar.default_single_source_high_min_confidence); // Solo = exactly one contributing source AND it matches the // target source AND confidence clears the escape-hatch bar. let solo_match = @@ -261,14 +250,15 @@ impl SoarEngine { /// Remove expired cooldown entries to prevent unbounded growth. /// Called by TTL scheduler every 60 seconds. pub fn cleanup_expired_cooldowns(&self) { + let default_cooldown_expiry = self.config.load().soar.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)); + .unwrap_or(default_cooldown_expiry); + let expiry = Duration::from_secs(max_cooldown_secs.saturating_mul(2).max(default_cooldown_expiry)); let before = self.cooldowns.len(); self.cooldowns.retain(|_, instant| instant.elapsed() < expiry); let removed = before.saturating_sub(self.cooldowns.len()); @@ -313,10 +303,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 default_min_conf = self.config.load().soar.default_single_source_high_min_confidence; self.playbooks .load() .iter() - .map(|pb| simulate_playbook(pb, event)) + .map(|pb| simulate_playbook(pb, event, default_min_conf)) .collect() } @@ -349,7 +340,7 @@ impl SoarEngine { /// condition is evaluated as "passes + flagged for admin review" so /// the preview stays conservative rather than a hard no against a /// rule that only fails because dry-run has no history to count. -fn simulate_playbook(pb: &Playbook, event: &ThreatDetectedEvent) -> DryRunMatch { +fn simulate_playbook(pb: &Playbook, event: &ThreatDetectedEvent, default_single_source_min_conf: f32) -> DryRunMatch { let trigger_matches = pb.trigger_event == event.attack_type; let mut has_frequency_condition = false; let conditions: Vec = pb @@ -359,7 +350,7 @@ fn simulate_playbook(pb: &Playbook, event: &ThreatDetectedEvent) -> DryRunMatch if c.condition_type == ConditionType::Frequency { has_frequency_condition = true; } - simulate_condition(c, event) + simulate_condition(c, event, default_single_source_min_conf) }) .collect(); let all_conditions_met = conditions.iter().all(|r| r.met); @@ -392,7 +383,11 @@ fn simulate_playbook(pb: &Playbook, event: &ThreatDetectedEvent) -> DryRunMatch /// logic except `Frequency`, which is reported as "passes-with-note" /// because a real evaluation would both need historical events and /// record a new one. -fn simulate_condition(cond: &PlaybookCondition, event: &ThreatDetectedEvent) -> DryRunConditionResult { +fn simulate_condition( + cond: &PlaybookCondition, + event: &ThreatDetectedEvent, + default_single_source_min_conf: f32, +) -> DryRunConditionResult { let base = |met: bool, note: Option| DryRunConditionResult { condition_type: cond.condition_type.to_string(), operator: cond.operator.clone(), @@ -479,7 +474,7 @@ fn simulate_condition(cond: &PlaybookCondition, event: &ThreatDetectedEvent) -> .value2 .as_ref() .and_then(|s| s.parse::().ok()) - .unwrap_or(DEFAULT_SINGLE_SOURCE_HIGH_MIN_CONFIDENCE); + .unwrap_or(default_single_source_min_conf); let solo_match = event.active_source_count == 1 && event.sources.len() == 1 && event.sources[0] == target_source; let conf_met = event.confidence >= min_conf; @@ -556,7 +551,7 @@ mod dry_run_tests { fn simulate_playbook_fires_when_trigger_matches_and_no_conditions() { let pb = playbook("trivial", "brute_force", vec![]); let ev = event("brute_force", 0.9, vec![DetectionSource::ML]); - let result = simulate_playbook(&pb, &ev); + let result = simulate_playbook(&pb, &ev, 0.95); assert!(result.trigger_matches); assert!(result.would_fire); assert_eq!(result.actions.len(), 1); @@ -567,7 +562,7 @@ mod dry_run_tests { fn simulate_playbook_does_not_fire_on_mismatched_trigger() { let pb = playbook("brute-match", "brute_force", vec![]); let ev = event("c2_beacon", 0.9, vec![DetectionSource::ML]); - let result = simulate_playbook(&pb, &ev); + let result = simulate_playbook(&pb, &ev, 0.95); assert!(!result.trigger_matches); assert!(!result.would_fire); } @@ -577,7 +572,7 @@ mod dry_run_tests { let mut pb = playbook("off", "brute_force", vec![]); pb.enabled = false; let ev = event("brute_force", 0.9, vec![DetectionSource::ML]); - let result = simulate_playbook(&pb, &ev); + let result = simulate_playbook(&pb, &ev, 0.95); assert!(result.trigger_matches); assert!(!result.enabled); assert!(!result.would_fire); @@ -592,7 +587,7 @@ mod dry_run_tests { value2: Some("60".to_string()), }; let ev = event("brute_force", 0.8, vec![DetectionSource::ML]); - let result = simulate_condition(&cond, &ev); + let result = simulate_condition(&cond, &ev, 0.95); assert!(result.met, "frequency must pass in dry-run"); assert!( result.note.as_deref().unwrap_or("").contains("frequency"), @@ -615,8 +610,8 @@ mod dry_run_tests { value2: None, }; let ev = event("brute_force", 0.9, vec![DetectionSource::ML]); - assert!(simulate_condition(>e, &ev).met); - assert!(!simulate_condition(<e, &ev).met); + assert!(simulate_condition(>e, &ev, 0.95).met); + assert!(!simulate_condition(<e, &ev, 0.95).met); } #[test] @@ -629,8 +624,8 @@ mod dry_run_tests { }; let ev_two = event("c2_beacon", 0.9, vec![DetectionSource::Suricata, DetectionSource::ML]); let ev_one = event("c2_beacon", 0.9, vec![DetectionSource::ML]); - assert!(simulate_condition(&cond, &ev_two).met); - assert!(!simulate_condition(&cond, &ev_one).met); + assert!(simulate_condition(&cond, &ev_two, 0.95).met); + assert!(!simulate_condition(&cond, &ev_one, 0.95).met); } #[test] @@ -645,10 +640,10 @@ mod dry_run_tests { let solo_low = event("c2_beacon", 0.90, vec![DetectionSource::Suricata]); let multi = event("c2_beacon", 0.96, vec![DetectionSource::Suricata, DetectionSource::ML]); let wrong_source = event("c2_beacon", 0.96, vec![DetectionSource::ML]); - assert!(simulate_condition(&cond, &solo_high).met); - assert!(!simulate_condition(&cond, &solo_low).met); - assert!(!simulate_condition(&cond, &multi).met); - assert!(!simulate_condition(&cond, &wrong_source).met); + assert!(simulate_condition(&cond, &solo_high, 0.95).met); + assert!(!simulate_condition(&cond, &solo_low, 0.95).met); + assert!(!simulate_condition(&cond, &multi, 0.95).met); + assert!(!simulate_condition(&cond, &wrong_source, 0.95).met); } #[test] @@ -661,7 +656,7 @@ mod dry_run_tests { }; let pb = playbook("brute_force_block", "brute_force", vec![cond]); let ev = event("brute_force", 0.9, vec![DetectionSource::ML]); - let result = simulate_playbook(&pb, &ev); + let result = simulate_playbook(&pb, &ev, 0.95); assert!(result.has_frequency_condition); assert!( result.would_fire, diff --git a/net-guardia/src/core/soar/rate_limit_owner.rs b/net-guardia/src/core/soar/rate_limit_owner.rs index c66813c..2595ca7 100644 --- a/net-guardia/src/core/soar/rate_limit_owner.rs +++ b/net-guardia/src/core/soar/rate_limit_owner.rs @@ -20,10 +20,6 @@ use crate::model::error::Error; use crate::model::error::soar::SoarError; use crate::model::log::soar::SoarLog; -/// Channel depth for the owner-task command queue. SOAR rate-limit operations -/// are bursty but rare (operator action / playbook trigger), so 64 is plenty. -const RATE_LIMIT_CMD_CHANNEL_CAPACITY: usize = 64; - /// Settings keys persisted across restarts so the next process can resume the /// same TTL window. Stable wire format with the DB. const KEY_ORIGINAL: &str = "soar_rate_limit_original"; @@ -58,8 +54,8 @@ impl RateLimitOwnerHandle { /// (rusqlite via r2d2 and eBPF map writes) are synchronous I/O — running /// them directly inside the async owner loop would block the tokio /// worker thread for the entire adjust/restore batch. - pub fn spawn(db: Arc, rate_limit: Arc) -> Self { - let (tx, mut rx) = mpsc::channel::(RATE_LIMIT_CMD_CHANNEL_CAPACITY); + pub fn spawn(db: Arc, rate_limit: Arc, channel_capacity: usize) -> Self { + let (tx, mut rx) = mpsc::channel::(channel_capacity.max(1)); tokio::spawn(async move { while let Some(cmd) = rx.recv().await { match cmd { diff --git a/net-guardia/src/core/soar/scheduler.rs b/net-guardia/src/core/soar/scheduler.rs index cc23db8..a54c67d 100644 --- a/net-guardia/src/core/soar/scheduler.rs +++ b/net-guardia/src/core/soar/scheduler.rs @@ -56,7 +56,7 @@ impl TtlScheduler { // Clean up expired cooldown + frequency tracker entries to prevent unbounded memory growth self.soar_engine.cleanup_expired_cooldowns(); - let expired = self.db.get_expired_soar_blocks()?; + let expired = self.db.list_expired_soar_blocks()?; if expired.is_empty() { return Ok(()); diff --git a/net-guardia/src/core/stats_aggregator.rs b/net-guardia/src/core/stats_aggregator.rs index 5cd0b05..c395926 100644 --- a/net-guardia/src/core/stats_aggregator.rs +++ b/net-guardia/src/core/stats_aggregator.rs @@ -14,11 +14,11 @@ use crate::model::log::system::SystemLog; /// and writes them to the settings table for the Report engine to consume. pub struct StatsAggregator { stats: Arc, - repo: Arc, + repo: Arc, } impl StatsAggregator { - pub fn new(stats: Arc, repo: Arc) -> Self { + pub fn new(stats: Arc, repo: Arc) -> Self { Self { stats, repo } } @@ -163,7 +163,10 @@ mod tests { db.insert_soar_execution(1, Some("5.6.7.8"), "brute_force", "[]").ok(); db.insert_soar_block_rule("1.2.3.4", 1, "2099-01-01 00:00:00").ok(); - let aggregator = StatsAggregator::new(db.clone() as Arc, db.clone() as Arc); + let aggregator = StatsAggregator::new( + db.clone() as Arc, + db.clone() as Arc, + ); aggregator.aggregate().expect("aggregation should succeed"); // Verify settings were written @@ -192,7 +195,10 @@ mod tests { #[test] fn aggregator_handles_empty_db() { let db = Arc::new(Database::new(":memory:").expect("test db")); - let aggregator = StatsAggregator::new(db.clone() as Arc, db.clone() as Arc); + let aggregator = StatsAggregator::new( + db.clone() as Arc, + db.clone() as Arc, + ); aggregator .aggregate() .expect("aggregation should succeed with empty data"); diff --git a/net-guardia/src/infrastructure/app_config.rs b/net-guardia/src/infrastructure/app_config.rs deleted file mode 100644 index 4aa1f64..0000000 --- a/net-guardia/src/infrastructure/app_config.rs +++ /dev/null @@ -1,533 +0,0 @@ -use crate::adapter::persistence::Database; -use crate::model::error::Error; -use crate::model::error::system::SystemError; -use crate::model::system::config::{ - HttpConfig, InferenceConfig, MiscConfig, NetworkConfig, PipelineConfig, SuricataConfig, -}; - -pub struct AppConfig { - pub http: HttpConfig, - pub network: NetworkConfig, - pub inference: InferenceConfig, - pub misc: MiscConfig, - pub pipeline: PipelineConfig, - pub suricata: SuricataConfig, -} - -impl AppConfig { - /// Build AppConfig from DB settings with hardcoded defaults. - /// DB is the single source of truth — config.toml is not read. - pub fn new(db: &Database) -> Result { - let mut config = Self::defaults(); - Self::apply_db_overrides(&mut config, db); - Self::validate_config(&config)?; - Ok(config) - } - - /// Seed all default values into the settings table. - /// Uses INSERT OR IGNORE so existing user-set values are never overwritten. - /// Call this before `new()` so the DB always has a complete set of keys. - pub fn seed_defaults(db: &Database) -> Result<(), Error> { - let defaults: &[(&str, String)] = &[ - // Network - ("ingress_interface", "eth0".into()), - ("egress_interface", "eth1".into()), - ("combined_queue_count", "1".into()), - ("channel_size", "4096".into()), - ("fill_queue_size", "4096".into()), - ("comp_queue_size", "4096".into()), - ("tx_queue_size", "4096".into()), - ("rx_queue_size", "4096".into()), - ("frame_size", "4096".into()), - ("frame_count", "4096".into()), - ("refresh_interval", "5".into()), - ("packet_buffer_size", "2048".into()), - ("buffer_pool_capacity", "1024".into()), - // HTTP - ("http_port", "8080".into()), - ("jwt_expiry_hours", "24".into()), - ("cors_allowed_origins", "".into()), - // Inference - ("deep_autoencoder_name", "deep_autoencoder.onnx".into()), - ("classifier_name", "classifier.onnx".into()), - ("models_config_name", "inference_config.json".into()), - ("max_concurrent_flows", "10000".into()), - ("min_packets_for_inference", "5".into()), - ("inference_interval_secs", "5".into()), - ("aggregator_window_secs", "30".into()), - ("inference_batch_size", "200".into()), - ("traffic_logging_mode", "false".into()), - ("traffic_log_csv_path", "traffic_log.csv".into()), - // Flow Trace rotation (defaults match the DEFAULT_* constants - // in traffic_logger.rs; DB overrides let admins tune per env). - ("flow_trace_max_file_bytes", (500 * 1024 * 1024_u64).to_string()), - ("flow_trace_max_file_age_secs", "3600".into()), - ( - "flow_trace_total_budget_bytes", - (10 * 1024 * 1024 * 1024_u64).to_string(), - ), - // Model upload size caps (per-field multipart ceilings). - ("model_upload_max_onnx_bytes", (100 * 1024 * 1024_usize).to_string()), - ("model_upload_max_manifest_bytes", (64 * 1024_usize).to_string()), - ("model_upload_max_scaler_bytes", (64 * 1024_usize).to_string()), - // Misc - ("geoip_db_name", "net-guardia/static/geo/dbip-city-lite.mmdb".into()), - // Pipeline - ("pipeline_ingress", "access_control,rate_limit,service".into()), - ("pipeline_egress", "".into()), - // SOAR - ("soar_max_auto_block_cap", "100".into()), - ("soar_max_ttl_secs", "86400".into()), - // ML - ("ml_drift_window_secs", "3600".into()), - // Telegram - ("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()), - // DNS - ("dns_max_domains_per_request", "1000".into()), - // HTTPS redirect - ("force_https", "false".into()), - // Suricata bridge - ("suricata_enabled", "false".into()), - ("suricata_binary_path", "/usr/bin/suricata".into()), - ("suricata_config_path", "/etc/netguardia/suricata.yaml".into()), - ("suricata_eve_log_path", "/var/log/netguardia/eve.json".into()), - ("suricata_auto_restart_on_crash", "true".into()), - ("suricata_restart_backoff_secs", "10".into()), - ]; - - for (key, value) in defaults { - if db.get_setting(key)?.is_none() { - db.set_setting(key, value)?; - } - } - Ok(()) - } - - /// Hardcoded defaults for all configuration values. - /// These match the original config.toml values and serve as the baseline - /// when DB has no overrides (e.g., first boot before setup wizard). - fn defaults() -> Self { - Self { - http: HttpConfig { - http_server_bind_port: 8080, - jwt_expiry_hours: 24, - cors_allowed_origins: vec![], - }, - network: NetworkConfig { - ingress_ifname: "eth0".into(), - egress_ifname: "eth1".into(), - combined_queue_count: 1, - channel_size: 4096, - fill_queue_size: 4096, - comp_queue_size: 4096, - tx_queue_size: 4096, - rx_queue_size: 4096, - frame_size: 4096, - frame_count: 4096, - refresh_interval: 5, - packet_buffer_size: 2048, - buffer_pool_capacity: 1024, - }, - inference: InferenceConfig { - deep_autoencoder_name: "deep_autoencoder.onnx".into(), - classifier_name: "classifier.onnx".into(), - models_config_name: "inference_config.json".into(), - max_concurrent_flows: 10000, - min_packets_for_inference: 5, - inference_interval_secs: 5, - aggregator_window_secs: 30, - inference_batch_size: 200, - traffic_logging_mode: false, - traffic_log_csv_path: "traffic_log.csv".into(), - flow_trace_max_file_bytes: 500 * 1024 * 1024, - flow_trace_max_file_age_secs: 3600, - flow_trace_total_budget_bytes: 10 * 1024 * 1024 * 1024, - model_upload_max_onnx_bytes: 100 * 1024 * 1024, - model_upload_max_manifest_bytes: 64 * 1024, - model_upload_max_scaler_bytes: 64 * 1024, - }, - misc: MiscConfig { - geoip_db_name: "net-guardia/static/geo/dbip-city-lite.mmdb".into(), - database_path: "net-guardia.db".into(), - }, - pipeline: PipelineConfig { - ingress: vec!["access_control".into(), "rate_limit".into(), "service".into()], - egress: vec![], - }, - suricata: SuricataConfig { - enabled: false, - binary_path: "/usr/bin/suricata".into(), - config_path: "/etc/netguardia/suricata.yaml".into(), - eve_log_path: "/var/log/netguardia/eve.json".into(), - auto_restart_on_crash: true, - restart_backoff_secs: 10, - }, - } - } - - /// Override defaults with DB settings. Each setting is optional — - /// missing keys simply keep the default value. - fn apply_db_overrides(config: &mut Self, db: &Database) { - // Network interfaces (set by setup wizard) - if let Ok(Some(v)) = db.get_setting("ingress_interface") { - config.network.ingress_ifname = v; - } - if let Ok(Some(v)) = db.get_setting("egress_interface") { - config.network.egress_ifname = v; - } - - // HTTP - if let Ok(Some(v)) = db.get_setting("http_port") - && let Ok(port) = v.parse::() - { - config.http.http_server_bind_port = port; - } - if let Ok(Some(v)) = db.get_setting("jwt_expiry_hours") - && let Ok(hours) = v.parse::() - { - config.http.jwt_expiry_hours = hours; - } - if let Ok(Some(v)) = db.get_setting("cors_allowed_origins") { - config.http.cors_allowed_origins = if v.is_empty() { - vec![] - } else { - v.split(',').map(|s| s.trim().to_string()).collect() - }; - } - - // XDP tuning - if let Ok(Some(v)) = db.get_setting("combined_queue_count") - && let Ok(n) = v.parse::() - { - config.network.combined_queue_count = n; - } - if let Ok(Some(v)) = db.get_setting("fill_queue_size") - && let Ok(n) = v.parse::() - { - config.network.fill_queue_size = n; - } - if let Ok(Some(v)) = db.get_setting("comp_queue_size") - && let Ok(n) = v.parse::() - { - config.network.comp_queue_size = n; - } - if let Ok(Some(v)) = db.get_setting("tx_queue_size") - && let Ok(n) = v.parse::() - { - config.network.tx_queue_size = n; - } - if let Ok(Some(v)) = db.get_setting("rx_queue_size") - && let Ok(n) = v.parse::() - { - config.network.rx_queue_size = n; - } - if let Ok(Some(v)) = db.get_setting("frame_size") - && let Ok(n) = v.parse::() - { - config.network.frame_size = n; - } - if let Ok(Some(v)) = db.get_setting("frame_count") - && let Ok(n) = v.parse::() - { - config.network.frame_count = n; - } - - // Inference tuning - if let Ok(Some(v)) = db.get_setting("max_concurrent_flows") - && let Ok(n) = v.parse::() - { - config.inference.max_concurrent_flows = n; - } - if let Ok(Some(v)) = db.get_setting("min_packets_for_inference") - && let Ok(n) = v.parse::() - { - config.inference.min_packets_for_inference = n; - } - if let Ok(Some(v)) = db.get_setting("inference_interval_secs") - && let Ok(n) = v.parse::() - { - config.inference.inference_interval_secs = n; - } - if let Ok(Some(v)) = db.get_setting("aggregator_window_secs") - && let Ok(n) = v.parse::() - { - config.inference.aggregator_window_secs = n; - } - if let Ok(Some(v)) = db.get_setting("inference_batch_size") - && let Ok(n) = v.parse::() - { - config.inference.inference_batch_size = n; - } - if let Ok(Some(v)) = db.get_setting("flow_trace_max_file_bytes") - && let Ok(n) = v.parse::() - { - config.inference.flow_trace_max_file_bytes = n; - } - if let Ok(Some(v)) = db.get_setting("flow_trace_max_file_age_secs") - && let Ok(n) = v.parse::() - { - config.inference.flow_trace_max_file_age_secs = n; - } - if let Ok(Some(v)) = db.get_setting("flow_trace_total_budget_bytes") - && let Ok(n) = v.parse::() - { - config.inference.flow_trace_total_budget_bytes = n; - } - if let Ok(Some(v)) = db.get_setting("model_upload_max_onnx_bytes") - && let Ok(n) = v.parse::() - { - config.inference.model_upload_max_onnx_bytes = n; - } - if let Ok(Some(v)) = db.get_setting("model_upload_max_manifest_bytes") - && let Ok(n) = v.parse::() - { - config.inference.model_upload_max_manifest_bytes = n; - } - if let Ok(Some(v)) = db.get_setting("model_upload_max_scaler_bytes") - && let Ok(n) = v.parse::() - { - config.inference.model_upload_max_scaler_bytes = n; - } - if let Ok(Some(v)) = db.get_setting("refresh_interval") - && let Ok(n) = v.parse::() - { - config.network.refresh_interval = n; - } - if let Ok(Some(v)) = db.get_setting("channel_size") - && let Ok(n) = v.parse::() - { - config.network.channel_size = n; - } - if let Ok(Some(v)) = db.get_setting("packet_buffer_size") - && let Ok(n) = v.parse::() - { - config.network.packet_buffer_size = n; - } - if let Ok(Some(v)) = db.get_setting("buffer_pool_capacity") - && let Ok(n) = v.parse::() - { - config.network.buffer_pool_capacity = n; - } - - // Bool settings - if let Ok(Some(v)) = db.get_setting("traffic_logging_mode") { - config.inference.traffic_logging_mode = v == "true" || v == "1"; - } - - // File path settings - if let Ok(Some(v)) = db.get_setting("deep_autoencoder_name") - && !v.is_empty() - { - config.inference.deep_autoencoder_name = v; - } - if let Ok(Some(v)) = db.get_setting("classifier_name") - && !v.is_empty() - { - config.inference.classifier_name = v; - } - if let Ok(Some(v)) = db.get_setting("models_config_name") - && !v.is_empty() - { - config.inference.models_config_name = v; - } - if let Ok(Some(v)) = db.get_setting("traffic_log_csv_path") - && !v.is_empty() - { - config.inference.traffic_log_csv_path = v; - } - if let Ok(Some(v)) = db.get_setting("geoip_db_name") - && !v.is_empty() - { - config.misc.geoip_db_name = v; - } - - // Suricata bridge - if let Ok(Some(v)) = db.get_setting("suricata_enabled") { - config.suricata.enabled = v == "true" || v == "1"; - } - if let Ok(Some(v)) = db.get_setting("suricata_binary_path") - && !v.is_empty() - { - config.suricata.binary_path = v; - } - if let Ok(Some(v)) = db.get_setting("suricata_config_path") - && !v.is_empty() - { - config.suricata.config_path = v; - } - if let Ok(Some(v)) = db.get_setting("suricata_eve_log_path") - && !v.is_empty() - { - config.suricata.eve_log_path = v; - } - if let Ok(Some(v)) = db.get_setting("suricata_auto_restart_on_crash") { - config.suricata.auto_restart_on_crash = v == "true" || v == "1"; - } - if let Ok(Some(v)) = db.get_setting("suricata_restart_backoff_secs") - && let Ok(n) = v.parse::() - { - config.suricata.restart_backoff_secs = n; - } - - // Pipeline (stored as comma-separated) - if let Ok(Some(v)) = db.get_setting("pipeline_ingress") { - config.pipeline.ingress = if v.is_empty() { - vec![] - } else { - v.split(',').map(|s| s.trim().to_string()).collect() - }; - } - if let Ok(Some(v)) = db.get_setting("pipeline_egress") { - config.pipeline.egress = if v.is_empty() { - vec![] - } else { - v.split(',').map(|s| s.trim().to_string()).collect() - }; - } - } - - fn validate_config(config: &Self) -> Result<(), Error> { - let net = &config.network; - let inf = &config.inference; - let valid = net.refresh_interval <= 3600 - && net.combined_queue_count > 0 - && net.fill_queue_size > 0 - && net.comp_queue_size > 0 - && net.tx_queue_size > 0 - && net.rx_queue_size > 0 - && net.frame_size > 0 - && net.frame_count > 0 - && config.http.http_server_bind_port > 0 - && inf.max_concurrent_flows > 0 - && inf.min_packets_for_inference > 0 - && inf.inference_interval_secs > 0 - && inf.inference_batch_size > 0; - - if !valid { - Err(SystemError::InvalidConfig)? - } - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn test_db() -> Database { - Database::new(":memory:").expect("in-memory DB") - } - - #[test] - fn defaults_are_valid() { - let db = test_db(); - let config = AppConfig::new(&db).expect("defaults should be valid"); - assert_eq!(config.http.http_server_bind_port, 8080); - assert_eq!(config.network.ingress_ifname, "eth0"); - assert_eq!(config.network.egress_ifname, "eth1"); - assert_eq!(config.network.combined_queue_count, 1); - assert_eq!(config.network.frame_size, 4096); - } - - #[test] - fn db_overrides_interface_names() { - let db = test_db(); - db.set_setting("ingress_interface", "ens33").unwrap(); - db.set_setting("egress_interface", "ens34").unwrap(); - let config = AppConfig::new(&db).unwrap(); - assert_eq!(config.network.ingress_ifname, "ens33"); - assert_eq!(config.network.egress_ifname, "ens34"); - } - - #[test] - fn db_overrides_http_port() { - let db = test_db(); - db.set_setting("http_port", "9090").unwrap(); - let config = AppConfig::new(&db).unwrap(); - assert_eq!(config.http.http_server_bind_port, 9090); - } - - #[test] - fn db_overrides_xdp_tuning() { - let db = test_db(); - db.set_setting("frame_size", "8192").unwrap(); - db.set_setting("combined_queue_count", "4").unwrap(); - let config = AppConfig::new(&db).unwrap(); - assert_eq!(config.network.frame_size, 8192); - assert_eq!(config.network.combined_queue_count, 4); - } - - #[test] - fn invalid_db_values_ignored() { - let db = test_db(); - db.set_setting("http_port", "not_a_number").unwrap(); - let config = AppConfig::new(&db).unwrap(); - // Should keep default since parse fails - assert_eq!(config.http.http_server_bind_port, 8080); - } - - #[test] - fn empty_db_uses_all_defaults() { - let db = test_db(); - let config = AppConfig::new(&db).unwrap(); - assert_eq!(config.inference.inference_interval_secs, 5); - assert_eq!(config.inference.inference_batch_size, 200); - assert_eq!(config.misc.database_path, "net-guardia.db"); - } - - #[test] - fn seed_defaults_populates_empty_db() { - let db = test_db(); - AppConfig::seed_defaults(&db).expect("seed should succeed"); - assert_eq!(db.get_setting("http_port").unwrap(), Some("8080".to_string())); - assert_eq!( - db.get_setting("traffic_logging_mode").unwrap(), - Some("false".to_string()) - ); - assert_eq!( - db.get_setting("pipeline_ingress").unwrap(), - Some("access_control,rate_limit,service".to_string()) - ); - assert_eq!( - db.get_setting("geoip_db_name").unwrap(), - Some("net-guardia/static/geo/dbip-city-lite.mmdb".to_string()) - ); - } - - #[test] - fn seed_defaults_does_not_overwrite_existing() { - let db = test_db(); - db.set_setting("http_port", "9090").unwrap(); - AppConfig::seed_defaults(&db).expect("seed should succeed"); - assert_eq!(db.get_setting("http_port").unwrap(), Some("9090".to_string())); - } - - #[test] - fn db_overrides_traffic_logging_mode() { - // Default is false (ML inference enabled). Override to true enables CSV logging only. - let db = test_db(); - db.set_setting("traffic_logging_mode", "true").unwrap(); - let config = AppConfig::new(&db).unwrap(); - assert!(config.inference.traffic_logging_mode); - } - - #[test] - fn default_traffic_logging_mode_is_false() { - // ML inference should be enabled by default, not CSV logging - let db = test_db(); - let config = AppConfig::new(&db).unwrap(); - assert!(!config.inference.traffic_logging_mode); - } - - #[test] - fn db_overrides_pipeline() { - let db = test_db(); - db.set_setting("pipeline_ingress", "access_control,service").unwrap(); - let config = AppConfig::new(&db).unwrap(); - assert_eq!(config.pipeline.ingress, vec!["access_control", "service"]); - } -} diff --git a/net-guardia/src/infrastructure/app_services.rs b/net-guardia/src/infrastructure/app_services.rs index b99ed22..a0eb4ad 100644 --- a/net-guardia/src/infrastructure/app_services.rs +++ b/net-guardia/src/infrastructure/app_services.rs @@ -12,24 +12,25 @@ use crate::core::ml::adapter::ModelSourceState; use crate::core::ml::alert::MLAlert; use crate::core::ml::drift_detector::DriftDetectorHandle; use crate::core::ml::engine::Engine; +use crate::core::ml::flow_tracker::FlowLimits; use crate::core::ml::inference::Inference; use crate::core::ml::manifest::ModelManifest; use crate::core::ml::model_loader::build_adapter; use crate::core::ml::traffic_logger::{RotationPolicy, TrafficLogger}; -use crate::infrastructure::app_config::AppConfig; use crate::infrastructure::communication_manager::CommunicationManager; use crate::infrastructure::health::SystemHealth; use crate::infrastructure::statistics::FlowStatistics; +use crate::model::config::AppConfig; use crate::model::config::constants::{MANIFEST_FILENAME, MODELS_DIR}; use crate::model::detection::flow_features::FlowFeatures; use crate::model::detection::ml_detection::EngineConfig; +use crate::model::detection::ml_inference_config::MLInferenceConfig; use crate::model::detection::model_source::ModelInfo; use crate::model::error::Error; use crate::model::error::misc::MiscError; use crate::model::error::system::SystemError; use crate::model::log::ml::MLLog; use crate::model::log::system::SystemLog; -use crate::model::system::config::MLInferenceConfig; use crate::model::system::health::EbpfHealth; /// Application-level service orchestrator. @@ -47,7 +48,7 @@ pub struct AppServices { impl AppServices { pub fn new( - app_config: Arc, + app_config: Arc>, inference_config: Arc, ml_manifest: Option, drift_detector: DriftDetectorHandle, @@ -56,12 +57,20 @@ impl AppServices { ) -> Result { let health = SystemHealth::new(app_config.clone(), ebpf_health)?; - let batch_size = app_config.inference.inference_batch_size; + let config = app_config.load(); + let batch_size = config.ml.inference_batch_size; + let onnx_load_timeout = Duration::from_secs(config.ml.onnx_load_timeout_secs); let initial_state = match ml_manifest.as_ref() { Some(manifest) => { let manifest_path = PathBuf::from(MODELS_DIR).join(MANIFEST_FILENAME); - match build_adapter(manifest, Some(&manifest_path), &inference_config, batch_size) { + match build_adapter( + manifest, + Some(&manifest_path), + &inference_config, + batch_size, + onnx_load_timeout, + ) { Ok(adapter) => { let info = ModelInfo::new( manifest.name.clone(), @@ -95,21 +104,31 @@ impl AppServices { } }; - let ml_inference = Arc::new(Inference::new(initial_state, inference_config.clone())); - let ml_alert = Arc::new(MLAlert::new()); + let ml_inference = Arc::new(Inference::new( + initial_state, + inference_config.clone(), + app_config.clone(), + )); + let ml_alert = Arc::new(MLAlert::new(config.ml.alert_channel_capacity)); - let traffic_logger = if app_config.inference.traffic_logging_mode { - let csv_path = app_config.inference.traffic_log_csv_path.clone(); + let traffic_logger = if config.ml.traffic_logging_mode { + let csv_path = config.ml.traffic_log_csv_path.clone(); let mut header = FlowFeatures::all_feature_names_owned(); header.push("Label".to_string()); let base_path = PathBuf::from(&csv_path); let policy = RotationPolicy { - max_file_bytes: app_config.inference.flow_trace_max_file_bytes, - max_file_age: Duration::from_secs(app_config.inference.flow_trace_max_file_age_secs), - total_budget_bytes: app_config.inference.flow_trace_total_budget_bytes, + max_file_bytes: config.ml.flow_trace_max_file_bytes, + max_file_age: Duration::from_secs(config.ml.flow_trace_max_file_age_secs), + total_budget_bytes: config.ml.flow_trace_total_budget_bytes, }; - let logger = TrafficLogger::new(&base_path, header, policy, Some(comm.clone())) - .map_err(|e| MiscError::TrafficLogCreateError(csv_path.clone(), e.to_string()))?; + let logger = TrafficLogger::new( + &base_path, + header, + policy, + config.ml.traffic_logger_channel_capacity, + Some(comm.clone()), + ) + .map_err(|e| MiscError::TrafficLogCreateError(csv_path.clone(), e.to_string()))?; log!(SystemLog::TrafficLoggingEnabled(csv_path)); Some(Arc::new(logger)) } else { @@ -117,11 +136,23 @@ impl AppServices { }; let engine_config = EngineConfig { - max_flows: app_config.inference.max_concurrent_flows, - min_packets: app_config.inference.min_packets_for_inference, - batch_size: app_config.inference.inference_batch_size, - inference_interval_secs: app_config.inference.inference_interval_secs, - aggregator_window_secs: app_config.inference.aggregator_window_secs, + max_flows: config.ml.max_concurrent_flows, + min_packets: config.ml.min_packets_for_inference, + min_packets_floor: config.ml.min_packets_floor, + batch_size: config.ml.inference_batch_size, + inference_interval_secs: config.ml.inference_interval_secs, + aggregator_window_secs: config.ml.aggregator_window_secs, + confirmation_window_fraction: config.ml.confirmation_window_fraction, + }; + + let flow_limits = FlowLimits { + max_packets_per_direction: config.ml.flow_max_packets_per_direction, + max_periods: config.ml.flow_max_periods, + idle_threshold_us: config.ml.flow_idle_threshold_us, + bulk_min_packets: config.ml.flow_bulk_min_packets, + bulk_min_bytes: config.ml.flow_bulk_min_bytes, + idle_timeout_us: config.ml.flow_idle_timeout_us, + terminated_timeout_us: config.ml.flow_terminated_timeout_us, }; let ml_engine = Arc::new(Engine::new( @@ -129,8 +160,9 @@ impl AppServices { ml_alert.clone(), drift_detector, engine_config, + flow_limits, traffic_logger, - app_config.network.combined_queue_count, + config.ebpf.combined_queue_count, )); let flow_statistics = Arc::new(FlowStatistics::new(ml_engine.clone())); diff --git a/net-guardia/src/infrastructure/communication_manager.rs b/net-guardia/src/infrastructure/communication_manager.rs index e14c005..f978d06 100644 --- a/net-guardia/src/infrastructure/communication_manager.rs +++ b/net-guardia/src/infrastructure/communication_manager.rs @@ -16,7 +16,6 @@ use crate::interface::communication::command::*; use crate::interface::communication::event::Event; use crate::interface::communication::event::EventBroadcaster; use crate::interface::communication::query::*; -use crate::model::config::constants::DEFAULT_EVENT_CHANNEL_CAPACITY; use crate::model::error::Error; use crate::model::error::misc::MiscError; @@ -47,12 +46,12 @@ pub struct CommunicationManager { } impl CommunicationManager { - pub fn new() -> Self { + pub fn new(channel_capacity: usize) -> Self { Self { command_handlers: DashMap::new(), query_handlers: DashMap::new(), event_broadcasters: DashMap::new(), - channel_capacity: DEFAULT_EVENT_CHANNEL_CAPACITY, + channel_capacity: channel_capacity.max(1), } } @@ -259,7 +258,7 @@ mod tests { received: received.clone(), }); - let comm = Arc::new(CommunicationManager::new()); + let comm = Arc::new(CommunicationManager::new(256)); comm.register_command_handler::(handler); comm.send_command(TestCommand { value: "hello".into() }).await.unwrap(); @@ -271,7 +270,7 @@ mod tests { #[tokio::test] async fn test_command_not_found() { - let comm = CommunicationManager::new(); + let comm = CommunicationManager::new(256); let result = comm.send_command(TestCommand { value: "nope".into() }).await; assert!(result.is_err()); } @@ -279,7 +278,7 @@ mod tests { #[tokio::test] async fn test_query_dispatch() { let handler = Arc::new(TestQueryHandler); - let comm = Arc::new(CommunicationManager::new()); + let comm = Arc::new(CommunicationManager::new(256)); comm.register_query_handler::(handler); let result = comm.send_query(TestQuery { input: 21 }).await.unwrap(); @@ -288,14 +287,14 @@ mod tests { #[tokio::test] async fn test_query_not_found() { - let comm = CommunicationManager::new(); + let comm = CommunicationManager::new(256); let result = comm.send_query(TestQuery { input: 1 }).await; assert!(result.is_err()); } #[tokio::test] async fn test_event_pub_sub() { - let comm = CommunicationManager::new(); + let comm = CommunicationManager::new(256); comm.register_event_type::(); let mut receiver = comm.subscribe_event::().unwrap(); @@ -308,14 +307,14 @@ mod tests { #[tokio::test] async fn test_event_not_registered() { - let comm = CommunicationManager::new(); + let comm = CommunicationManager::new(256); let result = comm.subscribe_event::(); assert!(result.is_err()); } #[tokio::test] async fn publish_event_sync_delivers_to_subscriber() { - let comm = CommunicationManager::new(); + let comm = CommunicationManager::new(256); comm.register_event_type::(); let mut rx = comm.subscribe_event::().unwrap(); @@ -327,7 +326,7 @@ mod tests { #[test] fn publish_event_sync_errors_when_type_unregistered() { - let comm = CommunicationManager::new(); + let comm = CommunicationManager::new(256); let result = comm.publish_event_sync(TestEvent { message: "dropped".into(), }); @@ -336,7 +335,7 @@ mod tests { #[tokio::test] async fn test_event_multiple_subscribers() { - let comm = CommunicationManager::new(); + let comm = CommunicationManager::new(256); comm.register_event_type::(); let mut rx1 = comm.subscribe_event::().unwrap(); @@ -359,7 +358,7 @@ mod tests { received: received.clone(), }); - let comm = Arc::new(CommunicationManager::new()); + let comm = Arc::new(CommunicationManager::new(256)); let _comm = comm.clone().with_service(handler).command::().build(); comm.send_command(TestCommand { diff --git a/net-guardia/src/infrastructure/enforce_mode_handler.rs b/net-guardia/src/infrastructure/enforce_mode_handler.rs index a4ee9f7..538b40c 100644 --- a/net-guardia/src/infrastructure/enforce_mode_handler.rs +++ b/net-guardia/src/infrastructure/enforce_mode_handler.rs @@ -84,7 +84,7 @@ mod tests { fn test_handler() -> (Arc, Arc) { let db = Arc::new(Database::new(":memory:").unwrap()) as Arc; let cache = Arc::new(AtomicU8::new(0)); - let comm = Arc::new(CommunicationManager::new()); + let comm = Arc::new(CommunicationManager::new(256)); comm.register_event_type::(); let handler = Arc::new(EnforceModeHandler::new(db, comm.clone(), cache)); let _ = comm diff --git a/net-guardia/src/infrastructure/geoip.rs b/net-guardia/src/infrastructure/geoip.rs index efe3888..4afbeb4 100644 --- a/net-guardia/src/infrastructure/geoip.rs +++ b/net-guardia/src/infrastructure/geoip.rs @@ -2,10 +2,12 @@ use std::net::IpAddr; use std::path::{Path, PathBuf}; use std::sync::Arc; +use async_trait::async_trait; use maxminddb::{MaxMindDbError, Reader, geoip2}; use moka::sync::Cache; use tokio::task; +use crate::interface::port::geo_lookup::GeoLookup; use crate::model::monitoring::geolocation::GeoLocation; use crate::utils::ip_address; @@ -30,35 +32,6 @@ impl GeoIpService { }) } - pub async fn lookup(&self, ip: IpAddr) -> Result, MaxMindDbError> { - if ip_address::is_private_ip(&ip) { - return Ok(Some(GeoLocation { - country: Some("Local IP".into()), - country_code: Some("Local".into()), - city: None, - latitude: None, - longitude: None, - timezone: None, - })); - } - - if let Some(cached) = self.cache.get(&ip) { - return Ok(cached); - } - - let reader = self.reader.clone(); - let result = task::spawn_blocking(move || Self::lookup_from_db_blocking(&reader, ip)) - .await - .map_err(|e| MaxMindDbError::InvalidDatabase { - message: format!("Task join error: {}", e), - offset: None, - })??; - - self.cache.insert(ip, result.clone()); - - Ok(result) - } - fn lookup_from_db_blocking(reader: &Reader>, ip: IpAddr) -> Result, MaxMindDbError> { let lookup_result = reader.lookup(ip)?; let city_option: Option = lookup_result.decode()?; @@ -85,3 +58,32 @@ impl GeoIpService { })) } } + +#[async_trait] +impl GeoLookup for GeoIpService { + async fn lookup(&self, ip: IpAddr) -> Option { + if ip_address::is_private_ip(&ip) { + return Some(GeoLocation { + country: Some("Local IP".into()), + country_code: Some("Local".into()), + city: None, + latitude: None, + longitude: None, + timezone: None, + }); + } + + if let Some(cached) = self.cache.get(&ip) { + return cached; + } + + let reader = self.reader.clone(); + let result = match task::spawn_blocking(move || Self::lookup_from_db_blocking(&reader, ip)).await { + Ok(Ok(loc)) => loc, + _ => None, + }; + + self.cache.insert(ip, result.clone()); + result + } +} diff --git a/net-guardia/src/infrastructure/health.rs b/net-guardia/src/infrastructure/health.rs index f6c050b..237ca86 100644 --- a/net-guardia/src/infrastructure/health.rs +++ b/net-guardia/src/infrastructure/health.rs @@ -8,7 +8,7 @@ use sysinfo::{Components, Networks, System}; use tokio::sync::{broadcast, oneshot}; use tokio::time::interval; -use crate::infrastructure::app_config::AppConfig; +use crate::model::config::AppConfig; use crate::model::error::Error; use crate::model::log::health::Health; use crate::model::system::health::{ @@ -33,10 +33,11 @@ pub struct SystemHealth { } 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 ingress_interface = config.network.ingress_ifname.clone(); - let egress_interface = config.network.egress_ifname.clone(); + let cfg = config.load(); + let ingress_interface = cfg.ebpf.ingress_ifname.clone(); + let egress_interface = cfg.ebpf.egress_ifname.clone(); // Bootstrap snapshot so readers don't have to handle a "no metrics yet" // case before the refresh task fires for the first time. The diff --git a/net-guardia/src/infrastructure/http_server.rs b/net-guardia/src/infrastructure/http_server.rs index ae27f43..886af5a 100644 --- a/net-guardia/src/infrastructure/http_server.rs +++ b/net-guardia/src/infrastructure/http_server.rs @@ -6,6 +6,7 @@ use actix_cors::Cors; use actix_web::dev::ServerHandle; use actix_web::web::route; use actix_web::{App, HttpResponse, HttpServer, web}; +use arc_swap::ArcSwap; use macros::log; use crate::adapter::ebpf::EbpfServices; @@ -29,7 +30,6 @@ use crate::core::notification_service::NotificationService; use crate::core::playbook_service::PlaybookService; use crate::core::rate_limit_service::RateLimitService; use crate::core::soar::engine::SoarEngine; -use crate::infrastructure::app_config::AppConfig; use crate::infrastructure::app_services::AppServices; use crate::infrastructure::communication_manager::CommunicationManager; use crate::infrastructure::secret_store::SecretStore; @@ -38,11 +38,12 @@ use crate::infrastructure::system::ShutdownHandle; use crate::interface::port::api_key::ApiKeyRepo; use crate::interface::port::app_repo::AppRepo; use crate::interface::port::audit::AuditRepo; +use crate::model::config::AppConfig; use crate::model::config::constants::HTTP_FALLBACK_PORT; +use crate::model::detection::ml_inference_config::MLInferenceConfig; use crate::model::error::Error; use crate::model::error::http::HttpError; use crate::model::log::http::HttpLog; -use crate::model::system::config::MLInferenceConfig; use crate::model::system::readiness::ReadinessState; /// Shared flag: true when all services (eBPF, ML, SOAR) are fully initialized. @@ -50,7 +51,7 @@ pub type ReadyFlag = Arc; /// Parameters for starting the HTTP server, avoiding `#[cfg]` on function params. pub struct HttpServerParams { - pub app_config: Arc, + pub app_config: Arc>, pub inference_config: Arc, pub ebpf_services: Arc, pub app_services: Arc, @@ -237,7 +238,7 @@ pub async fn run(params: HttpServerParams) -> Result<(), Error> { let shutdown_handle = params.shutdown_handle; let suricata_manager = params.suricata_manager; let soar_engine = params.soar_engine; - let port = app_config.http.http_server_bind_port; + let port = app_config.load().http_server.port; // Shared across every actix worker so concurrent model uploads // serialize their rename-into-`models/` critical section. Built @@ -248,7 +249,7 @@ pub async fn run(params: HttpServerParams) -> Result<(), Error> { HttpServer::new(move || { let app = App::new() .wrap(HttpsRedirect) - .wrap(cors(app_config.http.cors_allowed_origins.clone())) + .wrap(cors(app_config.load().http_server.cors_allowed_origins.clone())) .app_data(web::Data::new(force_https.clone())) .app_data(web::Data::from(shutdown_handle.clone())) .app_data(web::Data::from(app_config.clone())) diff --git a/net-guardia/src/infrastructure/mod.rs b/net-guardia/src/infrastructure/mod.rs index 30174d3..dd06b08 100644 --- a/net-guardia/src/infrastructure/mod.rs +++ b/net-guardia/src/infrastructure/mod.rs @@ -1,4 +1,3 @@ -pub mod app_config; pub mod app_services; pub mod audit_logger; pub mod cli; diff --git a/net-guardia/src/infrastructure/secret_store.rs b/net-guardia/src/infrastructure/secret_store.rs index 5f7dc92..09276ed 100644 --- a/net-guardia/src/infrastructure/secret_store.rs +++ b/net-guardia/src/infrastructure/secret_store.rs @@ -136,6 +136,10 @@ impl SecretStorePort for SecretStore { let envelope = self.encrypt(plaintext)?; self.db.set_app_secret(key, &envelope) } + + fn encrypt_envelope(&self, plaintext: &str) -> Result { + self.encrypt(plaintext) + } } #[cfg(test)] diff --git a/net-guardia/src/infrastructure/service_factory.rs b/net-guardia/src/infrastructure/service_factory.rs index 65e0b0b..140a4d9 100644 --- a/net-guardia/src/infrastructure/service_factory.rs +++ b/net-guardia/src/infrastructure/service_factory.rs @@ -29,7 +29,6 @@ use crate::core::playbook_service::PlaybookService; use crate::core::rate_limit_service::RateLimitService; use crate::core::soar::engine::SoarEngine; use crate::core::soar::scheduler::TtlScheduler; -use crate::infrastructure::app_config::AppConfig; use crate::infrastructure::app_services::AppServices; use crate::infrastructure::communication_manager::CommunicationManager; use crate::infrastructure::ebpf_preflight; @@ -44,13 +43,16 @@ use crate::interface::port::access_control_admin::AccessControlAdminPort; use crate::interface::port::app_repo::AppRepo; use crate::interface::port::dns_filter_api::DnsFilterPort; use crate::interface::port::geo_block_api::GeoBlockPort; +use crate::interface::port::geo_lookup::GeoLookup; use crate::interface::port::notification::{AlertNotifier, AlertNotifierFactory}; use crate::interface::port::rate_limit_api::RateLimitPort; use crate::interface::port::secret_store::SecretStorePort; use crate::interface::port::setting::SettingRepo; use crate::interface::port::soar::SoarRepo; use crate::model::access_control::list_type::ListType; +use crate::model::config::AppConfig; use crate::model::detection::drift::FeatureBaselines; +use crate::model::detection::ml_inference_config::MLInferenceConfig; use crate::model::error::Error; use crate::model::error::ebpf::EbpfError; use crate::model::error::misc::MiscError; @@ -59,13 +61,12 @@ use crate::model::log::ebpf::EbpfLog; use crate::model::log::ml::MLLog; use crate::model::log::system::SystemLog; use crate::model::monitoring::direction::FlowDirection; -use crate::model::system::config::MLInferenceConfig; use crate::model::system::health::EbpfFailStage; use crate::model::system::health::EbpfHealth; /// Holds all Arc-wrapped services that make up the running application. pub struct AppState { - pub app_config: Arc, + pub app_config: Arc>, pub inference_config: Arc, pub ebpf_services: Arc, pub app_services: Arc, @@ -82,7 +83,7 @@ pub struct AppState { pub notification_service: Arc, pub playbook_service: Arc, pub rate_limit_service: Arc, - pub geoip: Option>, + pub geoip: Option>, pub drift_detector: DriftDetectorHandle, pub ingress_ebpf: Option, pub egress_ebpf: Option, @@ -114,8 +115,8 @@ impl ServiceFactory { /// Only called when setup is complete — all config values are in DB. pub async fn build(db: Arc) -> Result { // Ensure DB has all default config keys (INSERT OR IGNORE — never overwrites) - AppConfig::seed_defaults(&db)?; - let app_config = Arc::new(AppConfig::new(&db)?); + AppConfig::seed_defaults(db.as_ref())?; + let app_config = Arc::new(ArcSwap::from_pointee(AppConfig::from_settings(db.as_ref())?)); // Prefer `models/manifest.yaml` when present (v12 BYO-model path). The manifest // is the user-authored source of truth for features, labels, thresholds, and @@ -133,7 +134,7 @@ impl ServiceFactory { (Arc::new(cfg), Some(manifest)) } else { ( - Arc::new(MLInferenceConfig::load_file(&app_config.inference.models_config_name)?), + Arc::new(MLInferenceConfig::load_file(&app_config.load().ml.models_config_name)?), None, ) }; @@ -172,17 +173,24 @@ impl ServiceFactory { let secret_store = Arc::new(SecretStore::new(db.clone())); let secret_store_port: Arc = secret_store.clone(); - let jwt_service = Arc::new(JwtService::new(&secret_store_port, app_config.http.jwt_expiry_hours)?); + let jwt_service = Arc::new(JwtService::new( + &secret_store_port, + app_config.load().auth.jwt_expiry_hours, + )?); // Initialize ML drift detector from inference config baselines let baselines = FeatureBaselines::from_inference_config(&inference_config); - let drift_window_secs: u64 = db - .get_setting("ml_drift_window_secs") - .ok() - .flatten() - .and_then(|v| v.parse().ok()) - .unwrap_or(3600); - let drift_detector = DriftDetectorHandle::spawn(baselines, Duration::from_secs(drift_window_secs)); + let drift_cfg = app_config.load(); + let drift_window_secs = drift_cfg.ml.drift_window_secs; + let drift_max_snapshots = drift_cfg.ml.drift_max_snapshots; + let drift_channel_capacity = drift_cfg.ml.drift_channel_capacity; + drop(drift_cfg); + let drift_detector = DriftDetectorHandle::spawn( + baselines, + Duration::from_secs(drift_window_secs), + drift_max_snapshots, + drift_channel_capacity, + ); // Create AtomicU8 enforce-level cache (Monitor=0, MlOnly=1, Enforce=2) let enforce_level_cache = Arc::new(AtomicU8::new({ @@ -196,7 +204,9 @@ impl ServiceFactory { // publish `flow_trace_stopped` audit events the moment it tries // to open its first rotated file, and an unregistered channel // would silently drop that evidence. - let comm = Arc::new(CommunicationManager::new()); + let comm = Arc::new(CommunicationManager::new( + app_config.load().observability.default_event_channel_capacity, + )); comm.register_event_type::(); comm.register_event_type::(); comm.register_event_type::(); @@ -233,8 +243,8 @@ impl ServiceFactory { // Create TelegramAdapter as alert notifier (may fail if not configured yet) let alert_notifier: Option> = match TelegramAdapter::new( - db.clone() as Arc, - db.clone() as Arc, + db.clone() as Arc, + app_config.clone(), Some(secret_store_port.clone()), ) { Ok(adapter) => Some(Arc::new(adapter)), @@ -245,7 +255,7 @@ impl ServiceFactory { }; // Try to initialize GeoIP service - let geoip: Option> = match GeoIpService::new(&app_config.misc.geoip_db_name) { + let geoip: Option> = match GeoIpService::new(&app_config.load().acl.geoip_db_name) { Ok(svc) => { log!(SystemLog::GeoIpInitialized); Some(Arc::new(svc)) @@ -264,6 +274,7 @@ impl ServiceFactory { let rate_limit_port: Arc = ebpf_services.rate_limit.clone(); let soar_engine = Arc::new(SoarEngine::new( db.clone(), + app_config.clone(), access_control_port.clone(), alert_notifier.clone(), geoip.clone(), @@ -276,8 +287,11 @@ impl ServiceFactory { let ttl_scheduler = TtlScheduler::new(db.clone(), access_control_port.clone(), soar_engine.clone()); // Create Report scheduler - let report_scheduler = - ReportScheduler::new(db.clone() as Arc, Some(secret_store_port.clone())); + let report_scheduler = ReportScheduler::new( + db.clone() as Arc, + app_config.clone(), + Some(secret_store_port.clone()), + ); // Create domain services (Phase 2B) — upcast concrete eBPF services to // their port-layer traits so the core services see only abstract ports. @@ -289,23 +303,29 @@ impl ServiceFactory { access_control_admin, geo_block_port, )); - let dns_filter_service = Arc::new(DnsFilterService::new(db.clone() as Arc, dns_filter_port)); + let dns_filter_service = Arc::new(DnsFilterService::new( + db.clone() as Arc, + dns_filter_port, + app_config.clone(), + )); let rate_limit_service = Arc::new(RateLimitService::new(db.clone() as Arc, rate_limit_port)); let playbook_service = Arc::new(PlaybookService::new( db.clone(), soar_engine.clone(), access_control_port, )); - let config_service = - Arc::new(ConfigService::new(db.clone() as Arc).with_secret_store(secret_store_port.clone())); + let config_service = Arc::new( + ConfigService::new(db.clone() as Arc, app_config.clone()) + .with_secret_store(secret_store_port.clone()), + ); let notifier_factory: Arc = Arc::new(TelegramAdapterFactory::new( - db.clone() as Arc, - db.clone() as Arc, + db.clone() as Arc, + app_config.clone(), Some(secret_store_port.clone()), )); let notification_service = Arc::new(NotificationService::new( - db.clone() as Arc, - db.clone() as Arc, + db.clone() as Arc, + app_config.clone(), secret_store_port, notifier_factory, )); @@ -346,19 +366,20 @@ impl ServiceFactory { /// ingress pipeline, write queue counts, and hand out map handles to the /// services. Returns the original stage on the first failure so the /// classifier can render targeted diagnostics. - #[allow(clippy::type_complexity)] fn try_build_ebpf( - app_config: &Arc, + app_config: &Arc>, ) -> Result<(Ebpf, Ebpf, ProgramArray, EbpfServices), (EbpfFailStage, Error)> { use crate::model::system::health::EbpfFailStage; let mut ingress = Self::load_ebpf("ingress").map_err(|e| (EbpfFailStage::Load, e))?; let mut egress = Self::load_ebpf("egress").map_err(|e| (EbpfFailStage::Load, e))?; - let pipeline = Self::configure_ingress_pipeline(&mut ingress, &app_config.pipeline.ingress) + let config = app_config.load(); + let pipeline = Self::configure_ingress_pipeline(&mut ingress, &config.pipeline.ingress) .map_err(|e| (EbpfFailStage::PipelineSetup, e))?; - let num_queues = app_config.network.combined_queue_count; + let num_queues = config.ebpf.combined_queue_count; + drop(config); Self::write_num_queues(&mut ingress, num_queues).map_err(|e| (EbpfFailStage::PipelineSetup, e))?; Self::write_num_queues(&mut egress, num_queues).map_err(|e| (EbpfFailStage::PipelineSetup, e))?; @@ -554,7 +575,7 @@ impl ServiceFactory { } async fn restore_acl_rules(db: &Database, ebpf_services: &EbpfServices) { - if let Ok(rules) = db.load_acl_rules() { + if let Ok(rules) = db.list_acl_rules() { let mut restored = 0u32; for (ip_version, direction, list_type, ip_address, port) in &rules { let dir = match direction.as_str() { diff --git a/net-guardia/src/infrastructure/suricata_manager.rs b/net-guardia/src/infrastructure/suricata_manager.rs index 882b8fa..89c86df 100644 --- a/net-guardia/src/infrastructure/suricata_manager.rs +++ b/net-guardia/src/infrastructure/suricata_manager.rs @@ -21,20 +21,20 @@ use tokio::process::{Child, Command}; use tokio::sync::oneshot; use tokio::time::{sleep, timeout}; -use crate::infrastructure::app_config::AppConfig; +use crate::model::config::AppConfig; use crate::model::error::Error; use crate::model::error::suricata::SuricataError; use crate::model::log::suricata::SuricataLog; use crate::model::system::suricata::SuricataHealth; pub struct SuricataManager { - config: Arc, + config: Arc>, health: Arc>, } impl SuricataManager { - pub fn new(config: Arc) -> Arc { - let initial = if config.suricata.enabled { + pub fn new(config: Arc>) -> Arc { + let initial = if config.load().suricata.enabled { SuricataHealth::Stopped { reason: "not yet started".to_string(), } @@ -57,7 +57,7 @@ impl SuricataManager { pub fn run(self: Arc) -> oneshot::Sender<()> { let (shutdown_tx, shutdown_rx) = oneshot::channel(); - if !self.config.suricata.enabled { + if !self.config.load().suricata.enabled { log!(SuricataLog::Disabled); return shutdown_tx; } @@ -97,8 +97,9 @@ impl SuricataManager { Ok(status) => format!("exited with {status}"), Err(e) => format!("wait error: {e}"), }; - if self.config.suricata.auto_restart_on_crash { - let backoff = self.config.suricata.restart_backoff_secs; + let config = self.config.load(); + if config.suricata.auto_restart_on_crash { + let backoff = config.suricata.restart_backoff_secs; log!(SuricataLog::CrashedRestartPending(reason.clone(), backoff)); self.health.store(Arc::new(SuricataHealth::Stopped { reason })); sleep(Duration::from_secs(backoff)).await; @@ -121,21 +122,23 @@ impl SuricataManager { } } - fn preflight(config: &AppConfig) -> Result<(), Error> { + fn preflight(config: &Arc>) -> Result<(), Error> { + let config = config.load(); let bin = &config.suricata.binary_path; if !Path::new(bin).exists() { Err(SuricataError::BinaryNotFound(bin.clone()))?; } - let cfg = &config.suricata.config_path; - if !Path::new(cfg).exists() { - Err(SuricataError::ConfigNotFound(cfg.clone()))?; + let cfg_path = &config.suricata.config_path; + if !Path::new(cfg_path).exists() { + Err(SuricataError::ConfigNotFound(cfg_path.clone()))?; } Ok(()) } fn spawn_child(&self) -> Result { - let sc = &self.config.suricata; - let iface = &self.config.network.ingress_ifname; + let config = self.config.load(); + let sc = &config.suricata; + let iface = &config.ebpf.ingress_ifname; log!(SuricataLog::Spawning( sc.binary_path.clone(), diff --git a/net-guardia/src/infrastructure/suricata_monitor.rs b/net-guardia/src/infrastructure/suricata_monitor.rs index 45a7e01..2500f24 100644 --- a/net-guardia/src/infrastructure/suricata_monitor.rs +++ b/net-guardia/src/infrastructure/suricata_monitor.rs @@ -19,54 +19,40 @@ use std::path::Path; use std::sync::Arc; use std::time::Duration; +use arc_swap::ArcSwap; use macros::log; use tokio::fs::{self, File}; use tokio::io::{AsyncBufReadExt, AsyncSeekExt, BufReader}; use tokio::sync::mpsc; use tokio::time::sleep; -use crate::infrastructure::app_config::AppConfig; +use crate::model::config::AppConfig; use crate::model::event::{DetectionEvent, DetectionSource}; use crate::model::log::suricata::SuricataLog; -/// Fixed poll interval for new eve.json content. eve.json is line-appended -/// so a short interval yields low latency; 200ms is well under any human -/// reaction time and negligible CPU cost. -const POLL_INTERVAL: Duration = Duration::from_millis(200); - -/// How long to wait between checks while the file does not yet exist. -const FILE_WAIT_INTERVAL: Duration = Duration::from_secs(1); - /// IANA protocol numbers for Suricata's `proto` strings. const IANA_PROTO_ICMP: u8 = 1; const IANA_PROTO_TCP: u8 = 6; const IANA_PROTO_UDP: u8 = 17; -/// Suricata severity → SOAR confidence mapping. Higher severity ⇒ higher -/// confidence so SOAR thresholds tend to trip on real alerts. -const SURICATA_CONFIDENCE_HIGH: f32 = 0.95; -const SURICATA_CONFIDENCE_MEDIUM: f32 = 0.80; -const SURICATA_CONFIDENCE_LOW: f32 = 0.65; -const SURICATA_CONFIDENCE_INFO: f32 = 0.50; - /// Suricata severity numeric encoding (eve.json `alert.severity`). const SURICATA_SEVERITY_HIGH: u64 = 1; const SURICATA_SEVERITY_MEDIUM: u64 = 2; const SURICATA_SEVERITY_LOW: u64 = 3; pub struct SuricataMonitor { - config: Arc, + config: Arc>, detection_tx: mpsc::Sender, } impl SuricataMonitor { - pub fn new(config: Arc, detection_tx: mpsc::Sender) -> Arc { + pub fn new(config: Arc>, detection_tx: mpsc::Sender) -> Arc { Arc::new(Self { config, detection_tx }) } /// Spawn the tail loop. No-op if the bridge is disabled. pub fn start(self: Arc) { - if !self.config.suricata.enabled { + if !self.config.load().suricata.enabled { return; } tokio::spawn(async move { @@ -75,21 +61,22 @@ impl SuricataMonitor { } async fn tail_loop(self: Arc) { - let path = self.config.suricata.eve_log_path.clone(); + let path = self.config.load().suricata.eve_log_path.clone(); loop { + let file_wait = Duration::from_secs(self.config.load().suricata.file_wait_interval_secs); // Wait until the file exists — Suricata spawns asynchronously and // may take a few seconds to create eve.json. if !Path::new(&path).exists() { log!(SuricataLog::MonitorWaitingForFile(path.clone())); while !Path::new(&path).exists() { - sleep(FILE_WAIT_INTERVAL).await; + sleep(file_wait).await; } } let mut file = match File::open(&path).await { Ok(f) => f, Err(_) => { - sleep(FILE_WAIT_INTERVAL).await; + sleep(file_wait).await; continue; } }; @@ -112,7 +99,7 @@ impl SuricataMonitor { log!(SuricataLog::MonitorFileRotated); break; // reopen } - sleep(POLL_INTERVAL).await; + sleep(Duration::from_millis(self.config.load().suricata.poll_interval_ms)).await; } Ok(n) => { pos += n as u64; @@ -120,7 +107,7 @@ impl SuricataMonitor { } Err(_) => { // Read error — treat as rotation and reopen. - sleep(POLL_INTERVAL).await; + sleep(Duration::from_millis(self.config.load().suricata.poll_interval_ms)).await; break; } } @@ -147,7 +134,7 @@ impl SuricataMonitor { if v.get("event_type").and_then(|x| x.as_str()) != Some("alert") { return; } - let Some(event) = Self::translate_alert(&v) else { + let Some(event) = self.translate_alert(&v) else { return; }; // mpsc is bounded; if the orchestrator is backed up, drop rather than @@ -157,7 +144,7 @@ impl SuricataMonitor { /// Map a Suricata alert JSON object to a DetectionEvent. Returns None if /// the event lacks the fields we need. - fn translate_alert(v: &serde_json::Value) -> Option { + fn translate_alert(&self, v: &serde_json::Value) -> Option { let src_ip = v.get("src_ip")?.as_str()?.to_string(); let dest_ip = v.get("dest_ip")?.as_str()?.to_string(); let proto_str = v.get("proto").and_then(|x| x.as_str()).unwrap_or(""); @@ -182,11 +169,12 @@ impl SuricataMonitor { .get("severity") .and_then(|x| x.as_u64()) .unwrap_or(SURICATA_SEVERITY_LOW); + let suri_cfg = self.config.load().suricata.clone(); let confidence = match severity { - SURICATA_SEVERITY_HIGH => SURICATA_CONFIDENCE_HIGH, - SURICATA_SEVERITY_MEDIUM => SURICATA_CONFIDENCE_MEDIUM, - SURICATA_SEVERITY_LOW => SURICATA_CONFIDENCE_LOW, - _ => SURICATA_CONFIDENCE_INFO, + SURICATA_SEVERITY_HIGH => suri_cfg.confidence_high, + SURICATA_SEVERITY_MEDIUM => suri_cfg.confidence_medium, + SURICATA_SEVERITY_LOW => suri_cfg.confidence_low, + _ => suri_cfg.confidence_info, }; log!(SuricataLog::AlertForwarded( diff --git a/net-guardia/src/infrastructure/system.rs b/net-guardia/src/infrastructure/system.rs index 4958dcf..efa75af 100644 --- a/net-guardia/src/infrastructure/system.rs +++ b/net-guardia/src/infrastructure/system.rs @@ -33,29 +33,29 @@ use crate::core::rate_limit_service::RateLimitService; use crate::core::soar::engine::SoarEngine; use crate::core::soar::scheduler::TtlScheduler; use crate::core::stats_aggregator::StatsAggregator; -use crate::infrastructure::app_config::AppConfig; use crate::infrastructure::app_services::AppServices; use crate::infrastructure::audit_logger::AuditLogger; use crate::infrastructure::communication_manager::CommunicationManager; -use crate::infrastructure::geoip::GeoIpService; use crate::infrastructure::http_server::{self, HttpServerParams}; use crate::infrastructure::secret_store::SecretStore; use crate::infrastructure::service_factory::ServiceFactory; use crate::infrastructure::suricata_manager::SuricataManager; use crate::infrastructure::suricata_monitor::SuricataMonitor; use crate::interface::port::audit::AuditRepo; +use crate::interface::port::geo_lookup::GeoLookup; use crate::interface::port::packet_sink::PacketSinkFactory; use crate::interface::port::setting::SettingRepo; use crate::interface::port::stats::StatsRepo; +use crate::model::config::AppConfig; use crate::model::config::constants::{MODELS_DIR, STAGING_SUBDIR}; use crate::model::detection::ml_detection::AlertMessage; +use crate::model::detection::ml_inference_config::MLInferenceConfig; use crate::model::error::Error; use crate::model::error::system::SystemError; use crate::model::event::{DetectionEvent, DetectionSource, DriftDetectedEvent}; use crate::model::log::detection::DetectionLog; use crate::model::log::ml::MLLog; use crate::model::log::system::SystemLog; -use crate::model::system::config::MLInferenceConfig; use crate::model::system::health::EbpfHealth; use crate::model::system::readiness::ReadinessState; @@ -71,11 +71,11 @@ pub enum ShutdownMode { /// and subsequent calls receive `TrySendError::Full` — no lock, no /// `Option::take`, no `Mutex`. pub struct ShutdownHandle { - tx: mpsc::Sender, + tx: Sender, } impl ShutdownHandle { - fn new(tx: mpsc::Sender) -> Self { + fn new(tx: Sender) -> Self { Self { tx } } @@ -90,7 +90,7 @@ impl ShutdownHandle { /// Construction is delegated to `ServiceFactory::build()`. /// Setup mode is handled by main.rs — System only runs when setup is complete. pub struct System { - pub app_config: Arc, + pub app_config: Arc>, pub inference_config: Arc, pub ebpf_services: Arc, pub app_services: Arc, @@ -109,7 +109,7 @@ pub struct System { pub notification_service: Arc, pub playbook_service: Arc, pub rate_limit_service: Arc, - pub geoip: Option>, + pub geoip: Option>, pub drift_detector: DriftDetectorHandle, pub shutdown_handle: Option>, _ingress_program_array: Option>, @@ -216,7 +216,8 @@ impl System { if let Err(e) = ebpf_services.run(sink_factory).await { use crate::infrastructure::ebpf_preflight; use crate::model::system::health::EbpfFailStage; - let iface = self.app_config.network.ingress_ifname.as_str(); + let cfg = self.app_config.load(); + let iface = cfg.ebpf.ingress_ifname.as_str(); let health = ebpf_preflight::classify(EbpfFailStage::AfXdpBind, &e, Some(iface)); log!(SystemLog::EbpfBringupFailed(format!("{:?}", health))); self.ebpf_health.store(Arc::new(health)); @@ -244,7 +245,7 @@ impl System { // Start stats aggregator (writes weekly_* settings for Report engine) let stats_aggregator = StatsAggregator::new( self.db.clone() as Arc, - self.db.clone() as Arc, + self.db.clone() as Arc, ); stats_aggregator.start(); @@ -260,6 +261,7 @@ impl System { // Start detection orchestrator (dedup + enrichment + source attribution) let (detection_tx, detection_rx) = mpsc::channel::(1024); let orchestrator = DetectionOrchestrator::new( + &self.app_config, detection_rx, self.comm.clone(), self.geoip.clone(), @@ -274,12 +276,13 @@ impl System { // Start cross-flow correlation engine (botnet, scan, lateral movement detection) let correlation_alert_rx = self.app_services.ml_alert.subscribe_to_alerts(); - let correlation_engine = CorrelationEngine::new(correlation_alert_rx, correlation_detection_tx); + let correlation_engine = + CorrelationEngine::new(&self.app_config, correlation_alert_rx, correlation_detection_tx); correlation_engine.start(); // Start temporal beaconing detector (CV-based C2 periodicity detection) let beaconing_alert_rx = self.app_services.ml_alert.subscribe_to_alerts(); - let beaconing_detector = BeaconingDetector::new(beaconing_alert_rx, beaconing_detection_tx); + let beaconing_detector = BeaconingDetector::new(&self.app_config, beaconing_alert_rx, beaconing_detection_tx); beaconing_detector.start(); // Bridge ML alerts → DetectionEvent (thin adapter, no enrichment) @@ -499,8 +502,9 @@ impl System { /// If either attach fails, record the reason in `ebpf_health` and /// continue — the rest of the system keeps running. fn attach_ebpf(&mut self) -> Result<(), Error> { - let ingress_ifname = self.app_config.network.ingress_ifname.clone(); - let egress_ifname = self.app_config.network.egress_ifname.clone(); + let cfg = self.app_config.load(); + let ingress_ifname = cfg.ebpf.ingress_ifname.clone(); + let egress_ifname = cfg.ebpf.egress_ifname.clone(); ServiceFactory::set_memory_limit()?; let (ingress, egress) = match (self.ingress_ebpf.as_mut(), self.egress_ebpf.as_mut()) { diff --git a/net-guardia/src/interface/port/acl.rs b/net-guardia/src/interface/port/acl.rs index 0552dbc..ea8bc66 100644 --- a/net-guardia/src/interface/port/acl.rs +++ b/net-guardia/src/interface/port/acl.rs @@ -32,7 +32,7 @@ pub trait AclRepo: Send + Sync { /// explicitly installed. fn has_manual_acl_rule(&self, ip_address: &str) -> Result; - fn load_admin_whitelist(&self) -> Result, Error>; + fn list_admin_whitelist(&self) -> Result, Error>; fn insert_admin_whitelist(&self, ip: &str) -> Result<(), Error>; fn delete_admin_whitelist(&self, ip: &str) -> Result<(), Error>; } diff --git a/net-guardia/src/interface/port/api_key.rs b/net-guardia/src/interface/port/api_key.rs index 0cdb492..891053d 100644 --- a/net-guardia/src/interface/port/api_key.rs +++ b/net-guardia/src/interface/port/api_key.rs @@ -2,7 +2,6 @@ use crate::model::error::Error; use crate::model::identity::auth::Claims; /// Type alias for API key list items: (id, name, permission_level, created_at, last_used_at) -#[allow(clippy::type_complexity)] pub type ApiKeyListItem = (i64, String, String, String, Option); /// Identity BC — API key CRUD + validation (distinct from user login, diff --git a/net-guardia/src/interface/port/geo_lookup.rs b/net-guardia/src/interface/port/geo_lookup.rs new file mode 100644 index 0000000..af24e73 --- /dev/null +++ b/net-guardia/src/interface/port/geo_lookup.rs @@ -0,0 +1,10 @@ +use std::net::IpAddr; + +use async_trait::async_trait; + +use crate::model::monitoring::geolocation::GeoLocation; + +#[async_trait] +pub trait GeoLookup: Send + Sync { + async fn lookup(&self, ip: IpAddr) -> Option; +} diff --git a/net-guardia/src/interface/port/identity.rs b/net-guardia/src/interface/port/identity.rs index 364f397..3604dfd 100644 --- a/net-guardia/src/interface/port/identity.rs +++ b/net-guardia/src/interface/port/identity.rs @@ -39,11 +39,11 @@ pub trait IdentityRepo: Send + Sync { fn get_user_group(&self, id: i64) -> Result, Error>; // --- Membership --- - fn get_user_groups(&self, user_id: i64) -> Result, Error>; + fn list_groups_for_user(&self, user_id: i64) -> Result, Error>; fn set_user_groups(&self, user_id: i64, group_ids: &[i64]) -> Result<(), Error>; - fn get_user_permissions(&self, user_id: i64) -> Result, Error>; - fn get_group_member_ids(&self, group_id: i64) -> Result, Error>; - fn get_group_members(&self, group_id: i64) -> Result, Error>; + fn list_user_permissions(&self, user_id: i64) -> Result, Error>; + fn list_group_member_ids(&self, group_id: i64) -> Result, Error>; + fn list_group_members(&self, group_id: i64) -> Result, Error>; // --- Login Rate Limiting --- fn record_login_failure(&self, username: &str) -> Result<(u32, Option), Error>; diff --git a/net-guardia/src/interface/port/mod.rs b/net-guardia/src/interface/port/mod.rs index 9730899..aab1f90 100644 --- a/net-guardia/src/interface/port/mod.rs +++ b/net-guardia/src/interface/port/mod.rs @@ -9,6 +9,7 @@ pub mod dns_filter_api; pub mod dns_query_filter; pub mod enforcement; pub mod geo_block_api; +pub mod geo_lookup; pub mod identity; pub mod notification; pub mod packet_sink; diff --git a/net-guardia/src/interface/port/secret_store.rs b/net-guardia/src/interface/port/secret_store.rs index 156b796..b7711c2 100644 --- a/net-guardia/src/interface/port/secret_store.rs +++ b/net-guardia/src/interface/port/secret_store.rs @@ -7,4 +7,5 @@ use crate::model::error::Error; pub trait SecretStorePort: Send + Sync { fn get_secret(&self, key: &str) -> Result, Error>; fn set_secret(&self, key: &str, plaintext: &str) -> Result<(), Error>; + fn encrypt_envelope(&self, plaintext: &str) -> Result; } diff --git a/net-guardia/src/interface/port/setting.rs b/net-guardia/src/interface/port/setting.rs index 56e6b0d..bc64c2e 100644 --- a/net-guardia/src/interface/port/setting.rs +++ b/net-guardia/src/interface/port/setting.rs @@ -1,23 +1,11 @@ use crate::model::error::Error; -/// Configuration technical service — key/value settings, encrypted app secrets, -/// and per-channel notification config blobs. -/// -/// Per DOMAIN_MAP §2 this is a Technical Service (no BC), but it has an -/// aggregate-shaped DB footprint (three tables: `settings`, `app_secrets`, -/// `notification_config`) with identical K/V semantics, so it gets a single -/// repo trait rather than three. -#[allow(dead_code)] -pub trait SettingRepo: Send + Sync { - // --- Plain settings (cleartext K/V) --- +pub trait SettingRepo { fn get_setting(&self, key: &str) -> Result, Error>; fn set_setting(&self, key: &str, value: &str) -> Result<(), Error>; - - // --- App secrets (encrypted-at-rest in `app_secrets` table) --- fn get_app_secret(&self, key: &str) -> Result, Error>; fn set_app_secret(&self, key: &str, plaintext: &str) -> Result<(), Error>; - - // --- Notification channel config blobs (JSON) --- fn get_notification_config(&self, channel: &str) -> Result, Error>; fn set_notification_config(&self, channel: &str, config_json: &str) -> Result<(), Error>; + fn transaction(&self, f: &mut dyn FnMut(&dyn SettingRepo) -> Result<(), Error>) -> Result<(), Error>; } diff --git a/net-guardia/src/interface/port/soar.rs b/net-guardia/src/interface/port/soar.rs index 0930ca2..e8a0b5f 100644 --- a/net-guardia/src/interface/port/soar.rs +++ b/net-guardia/src/interface/port/soar.rs @@ -1,9 +1,8 @@ use crate::model::error::Error; -use crate::model::soar::playbook_data::UpdatePlaybookRow; +use crate::model::soar::playbook_data::UpdatePlaybookInput; /// Type alias for playbook+action JOIN rows. /// (id, name, enabled, trigger_event, threshold, count, window, cooldown, action_id, action_order, action_type, params) -#[allow(clippy::type_complexity)] pub type PlaybookRow = ( i64, String, @@ -21,7 +20,6 @@ pub type PlaybookRow = ( /// Type alias for SOAR execution log rows. /// (id, playbook_id, source_ip, trigger_event, actions_executed, executed_at) -#[allow(clippy::type_complexity)] pub type SoarExecutionRow = (i64, i64, Option, String, String, String); /// Threat Response BC — SOAR aggregate repository. @@ -33,26 +31,25 @@ pub type SoarExecutionRow = (i64, i64, Option, String, String, String); /// `DbAdminRepo::with_transaction` + `TxRepos`. pub trait SoarRepo: Send + Sync { // --- Playbooks --- - fn load_playbooks_with_actions(&self) -> Result, Error>; + fn list_playbooks_with_actions(&self) -> Result, Error>; fn update_playbook_enabled(&self, id: i64, enabled: bool) -> Result; fn delete_playbook(&self, id: i64) -> Result; fn seed_default_playbooks(&self) -> Result<(), Error>; // --- Playbook Conditions --- /// Returns: (condition_id, playbook_id, condition_type, operator, value, value2) - #[allow(clippy::type_complexity)] - fn load_all_playbook_conditions(&self) -> Result)>, Error>; + fn list_all_playbook_conditions(&self) -> Result)>, Error>; // --- Block Rules --- fn count_active_soar_blocks(&self) -> Result; - fn get_active_soar_blocks(&self) -> Result, Error>; - fn get_soar_block_by_id(&self, id: i64) -> Result, Error>; - fn get_expired_soar_blocks(&self) -> Result, Error>; + fn list_active_soar_blocks(&self) -> Result, Error>; + fn find_soar_block_by_id(&self, id: i64) -> Result, Error>; + fn list_expired_soar_blocks(&self) -> Result, Error>; fn mark_soar_block_unblocked(&self, id: i64) -> Result<(), Error>; // --- Pending Unblock Recovery --- fn insert_pending_unblock(&self, source_ip: &str) -> Result; - fn load_pending_unblocks(&self) -> Result, Error>; + fn list_pending_unblocks(&self) -> Result, Error>; fn delete_pending_unblock(&self, id: i64) -> Result<(), Error>; fn increment_pending_unblock_retry(&self, id: i64) -> Result<(), Error>; @@ -74,7 +71,6 @@ pub trait SoarRepo: Send + Sync { /// /// `actions` tuples: `(action_order, action_type, params_json)`. /// `conditions` tuples: `(condition_type, operator, value, value2)`. - #[allow(clippy::too_many_arguments)] fn insert_playbook_atomic( &self, name: &str, @@ -93,7 +89,7 @@ pub trait SoarRepo: Send + Sync { fn update_playbook_atomic( &self, id: i64, - row: &UpdatePlaybookRow, + row: &UpdatePlaybookInput, actions: &[(i64, String, String)], conditions: &[(String, String, String, Option)], ) -> Result; diff --git a/net-guardia/src/main.rs b/net-guardia/src/main.rs index 58a1f98..6884ece 100644 --- a/net-guardia/src/main.rs +++ b/net-guardia/src/main.rs @@ -40,8 +40,21 @@ async fn main() -> Result<(), Error> { return handle_subcommand(&cli); } - Logging::initialize()?; + // Open DB before logging so the in-memory log ring buffer can size itself + // from the observability config. Any `log!` emitted during DB bring-up + // (e.g. `DbEncryptionDisabled`) is silently dropped by tracing because no + // subscriber is yet installed — acceptable for a single startup warning. let db = Arc::new(Database::new(&cli.db_path)?); + + let log_buffer_capacity: usize = db + .get_setting("log_buffer_capacity")? + .and_then(|v| v.parse().ok()) + .unwrap_or(5_000); + let log_buffer_max_message_bytes: usize = db + .get_setting("log_buffer_max_message_bytes")? + .and_then(|v| v.parse().ok()) + .unwrap_or(8_192); + Logging::initialize(log_buffer_capacity, log_buffer_max_message_bytes)?; if db.user_count().unwrap_or(0) == 0 { let hash = password::hash_password("admin")?; let admin_user_id = db.insert_user("admin", &hash, "admin", false)?; diff --git a/net-guardia/src/model/config/acl.rs b/net-guardia/src/model/config/acl.rs new file mode 100644 index 0000000..90cc028 --- /dev/null +++ b/net-guardia/src/model/config/acl.rs @@ -0,0 +1,27 @@ +use super::helpers::{override_string_nonempty, seed_key}; +use crate::interface::port::setting::SettingRepo; +use crate::model::error::Error; + +#[derive(Debug, Clone)] +pub struct AclConfig { + pub geoip_db_name: String, +} + +impl AclConfig { + pub fn defaults() -> Self { + Self { + geoip_db_name: "net-guardia/static/geo/dbip-city-lite.mmdb".to_string(), + } + } + + pub fn from_settings(repo: &dyn SettingRepo) -> Result { + let mut config = Self::defaults(); + override_string_nonempty(&mut config.geoip_db_name, repo, "geoip_db_name")?; + Ok(config) + } + + pub fn seed_defaults(repo: &dyn SettingRepo) -> Result<(), Error> { + seed_key(repo, "geoip_db_name", "net-guardia/static/geo/dbip-city-lite.mmdb")?; + Ok(()) + } +} diff --git a/net-guardia/src/model/config/auth.rs b/net-guardia/src/model/config/auth.rs new file mode 100644 index 0000000..8840244 --- /dev/null +++ b/net-guardia/src/model/config/auth.rs @@ -0,0 +1,25 @@ +use super::helpers::{override_parsed, seed_key}; +use crate::interface::port::setting::SettingRepo; +use crate::model::error::Error; + +#[derive(Debug, Clone)] +pub struct AuthConfig { + pub jwt_expiry_hours: u64, +} + +impl AuthConfig { + pub fn defaults() -> Self { + Self { jwt_expiry_hours: 24 } + } + + pub fn from_settings(repo: &dyn SettingRepo) -> Result { + let mut cfg = Self::defaults(); + override_parsed(&mut cfg.jwt_expiry_hours, repo, "jwt_expiry_hours")?; + Ok(cfg) + } + + pub fn seed_defaults(repo: &dyn SettingRepo) -> Result<(), Error> { + seed_key(repo, "jwt_expiry_hours", "24")?; + Ok(()) + } +} diff --git a/net-guardia/src/model/config/constants.rs b/net-guardia/src/model/config/constants.rs index 3b4401a..15567f9 100644 --- a/net-guardia/src/model/config/constants.rs +++ b/net-guardia/src/model/config/constants.rs @@ -1,30 +1,23 @@ //! Centralized constants for the NetGuardia application. -//! Tunable parameters are grouped by subsystem. Adjust here, not in individual files. - -// ── SOAR Engine ──────────────────────────────────────────────────── -pub const MAX_PENDING_UNBLOCK_RETRIES: i64 = 5; - -// ── ML Engine ────────────────────────────────────────────────────── -pub const ML_ALERT_CHANNEL_CAPACITY: usize = 1024; -pub const FLOW_MAX_PACKETS_PER_DIRECTION: usize = 1000; -pub const FLOW_MAX_PERIODS: usize = 1000; -pub const FLOW_IDLE_THRESHOLD_US: u64 = 1_000_000; -pub const FLOW_BULK_MIN_PACKETS: u64 = 4; -pub const FLOW_BULK_MIN_BYTES: u64 = 1000; -pub const FLOW_IDLE_TIMEOUT_US: u64 = 120_000_000; -pub const FLOW_TERMINATED_TIMEOUT_US: u64 = 5_000_000; +//! +//! Only **true constants** live here — values that are either part of a +//! stable wire/FS contract or derived from an external spec. Anything +//! runtime-tunable has moved into the corresponding `model/config/*.rs` +//! subsystem config (Q-10, 2026-04-22). // ── ML Model Directory ───────────────────────────────────────────── +/// Directory name (relative to working directory) where promoted models land. +/// Stable filesystem contract shared with the model watcher and upload path. pub const MODELS_DIR: &str = "models"; +/// Filename the model watcher listens for as the "commit marker" of a new +/// model promotion. Paired with upload's atomic rename order. pub const MANIFEST_FILENAME: &str = "manifest.yaml"; +/// Hidden subdirectory inside `MODELS_DIR` used for in-progress uploads. +/// The watcher filters events inside this path so partial uploads don't +/// trigger reloads. Stable contract with the multipart upload handler. pub const STAGING_SUBDIR: &str = ".staging"; -// ── Notification ─────────────────────────────────────────────────── -pub const TELEGRAM_MAX_RETRIES: u32 = 2; - // ── HTTP Server ──────────────────────────────────────────────────── +/// Fallback port used when `http_port` is unset / unparseable. Matches +/// the `defaults()` of `HttpServerConfig` and the setup-wizard default. pub const HTTP_FALLBACK_PORT: u16 = 8080; - -// ── Infrastructure ───────────────────────────────────────────────── -pub const DEFAULT_EVENT_CHANNEL_CAPACITY: usize = 256; -pub const DROP_CHANNEL_CAPACITY: usize = 100; diff --git a/net-guardia/src/model/config/correlation.rs b/net-guardia/src/model/config/correlation.rs new file mode 100644 index 0000000..2c09743 --- /dev/null +++ b/net-guardia/src/model/config/correlation.rs @@ -0,0 +1,64 @@ +use super::helpers::{override_parsed, seed_key}; +use crate::interface::port::setting::SettingRepo; +use crate::model::error::Error; + +/// Shared shape for scan / lateral / botnet correlation detectors. +#[derive(Debug, Clone)] +pub struct CorrelationDetectorParams { + pub window_secs: u64, + pub threshold: usize, +} + +#[derive(Debug, Clone)] +pub struct CorrelationConfig { + pub scan: CorrelationDetectorParams, + pub lateral: CorrelationDetectorParams, + pub botnet: CorrelationDetectorParams, + /// Maximum tracked source/destination IPs per detector to bound memory. + /// Shared across all three detectors since they share the same memory + /// concern under DDoS. + pub max_tracked_entries: usize, +} + +impl CorrelationConfig { + pub fn defaults() -> Self { + Self { + scan: CorrelationDetectorParams { + window_secs: 120, + threshold: 20, + }, + lateral: CorrelationDetectorParams { + window_secs: 300, + threshold: 5, + }, + botnet: CorrelationDetectorParams { + window_secs: 300, + threshold: 10, + }, + max_tracked_entries: 10_000, + } + } + + pub fn from_settings(repo: &dyn SettingRepo) -> Result { + let mut cfg = Self::defaults(); + override_parsed(&mut cfg.scan.window_secs, repo, "correlation_scan_window_secs")?; + override_parsed(&mut cfg.scan.threshold, repo, "correlation_scan_threshold")?; + override_parsed(&mut cfg.lateral.window_secs, repo, "correlation_lateral_window_secs")?; + override_parsed(&mut cfg.lateral.threshold, repo, "correlation_lateral_threshold")?; + override_parsed(&mut cfg.botnet.window_secs, repo, "correlation_botnet_window_secs")?; + override_parsed(&mut cfg.botnet.threshold, repo, "correlation_botnet_threshold")?; + override_parsed(&mut cfg.max_tracked_entries, repo, "correlation_max_tracked_entries")?; + Ok(cfg) + } + + pub fn seed_defaults(repo: &dyn SettingRepo) -> Result<(), Error> { + seed_key(repo, "correlation_scan_window_secs", "120")?; + seed_key(repo, "correlation_scan_threshold", "20")?; + seed_key(repo, "correlation_lateral_window_secs", "300")?; + seed_key(repo, "correlation_lateral_threshold", "5")?; + seed_key(repo, "correlation_botnet_window_secs", "300")?; + seed_key(repo, "correlation_botnet_threshold", "10")?; + seed_key(repo, "correlation_max_tracked_entries", "10000")?; + Ok(()) + } +} diff --git a/net-guardia/src/model/config/detection.rs b/net-guardia/src/model/config/detection.rs new file mode 100644 index 0000000..c58c400 --- /dev/null +++ b/net-guardia/src/model/config/detection.rs @@ -0,0 +1,119 @@ +use super::helpers::{override_parsed, seed_key}; +use crate::interface::port::setting::SettingRepo; +use crate::model::error::Error; + +#[derive(Debug, Clone)] +pub struct DetectionConfig { + pub fusion: FusionConfig, + pub beaconing: BeaconingConfig, + pub cleanup_interval_secs: u64, +} + +impl DetectionConfig { + pub fn defaults() -> Self { + Self { + fusion: FusionConfig::defaults(), + beaconing: BeaconingConfig::defaults(), + cleanup_interval_secs: 60, + } + } + + pub fn from_settings(repo: &dyn SettingRepo) -> Result { + let mut cfg = Self::defaults(); + cfg.fusion = FusionConfig::from_settings(repo)?; + cfg.beaconing = BeaconingConfig::from_settings(repo)?; + override_parsed(&mut cfg.cleanup_interval_secs, repo, "detection_cleanup_interval_secs")?; + Ok(cfg) + } + + pub fn seed_defaults(repo: &dyn SettingRepo) -> Result<(), Error> { + FusionConfig::seed_defaults(repo)?; + BeaconingConfig::seed_defaults(repo)?; + seed_key(repo, "detection_cleanup_interval_secs", "60")?; + Ok(()) + } +} + +#[derive(Debug, Clone)] +pub struct FusionConfig { + pub dedup_window_secs: u64, + pub repeat_offender_window_secs: u64, + pub max_dedup_entries: usize, +} + +impl FusionConfig { + pub fn defaults() -> Self { + Self { + dedup_window_secs: 30, + repeat_offender_window_secs: 2 * 60 * 60, + max_dedup_entries: 50_000, + } + } + + pub fn from_settings(repo: &dyn SettingRepo) -> Result { + let mut cfg = Self::defaults(); + override_parsed(&mut cfg.dedup_window_secs, repo, "fusion_dedup_window_secs")?; + override_parsed( + &mut cfg.repeat_offender_window_secs, + repo, + "fusion_repeat_offender_window_secs", + )?; + override_parsed(&mut cfg.max_dedup_entries, repo, "fusion_max_dedup_entries")?; + Ok(cfg) + } + + pub fn seed_defaults(repo: &dyn SettingRepo) -> Result<(), Error> { + seed_key(repo, "fusion_dedup_window_secs", "30")?; + seed_key(repo, "fusion_repeat_offender_window_secs", "7200")?; + seed_key(repo, "fusion_max_dedup_entries", "50000")?; + Ok(()) + } +} + +#[derive(Debug, Clone)] +pub struct BeaconingConfig { + pub analysis_interval_secs: u64, + pub min_observations: usize, + pub cv_threshold: f64, + pub max_cache_entries: usize, + pub expiry_secs: u64, + pub alert_cooldown_secs: u64, +} + +impl BeaconingConfig { + pub fn defaults() -> Self { + Self { + analysis_interval_secs: 30, + min_observations: 5, + cv_threshold: 0.3, + max_cache_entries: 50_000, + expiry_secs: 600, + alert_cooldown_secs: 300, + } + } + + pub fn from_settings(repo: &dyn SettingRepo) -> Result { + let mut cfg = Self::defaults(); + override_parsed( + &mut cfg.analysis_interval_secs, + repo, + "beaconing_analysis_interval_secs", + )?; + override_parsed(&mut cfg.min_observations, repo, "beaconing_min_observations")?; + override_parsed(&mut cfg.cv_threshold, repo, "beaconing_cv_threshold")?; + override_parsed(&mut cfg.max_cache_entries, repo, "beaconing_max_cache_entries")?; + override_parsed(&mut cfg.expiry_secs, repo, "beaconing_expiry_secs")?; + override_parsed(&mut cfg.alert_cooldown_secs, repo, "beaconing_alert_cooldown_secs")?; + Ok(cfg) + } + + pub fn seed_defaults(repo: &dyn SettingRepo) -> Result<(), Error> { + seed_key(repo, "beaconing_analysis_interval_secs", "30")?; + seed_key(repo, "beaconing_min_observations", "5")?; + seed_key(repo, "beaconing_cv_threshold", "0.3")?; + seed_key(repo, "beaconing_max_cache_entries", "50000")?; + seed_key(repo, "beaconing_expiry_secs", "600")?; + seed_key(repo, "beaconing_alert_cooldown_secs", "300")?; + Ok(()) + } +} diff --git a/net-guardia/src/model/config/dns_filter.rs b/net-guardia/src/model/config/dns_filter.rs new file mode 100644 index 0000000..25913f2 --- /dev/null +++ b/net-guardia/src/model/config/dns_filter.rs @@ -0,0 +1,27 @@ +use super::helpers::{override_parsed, seed_key}; +use crate::interface::port::setting::SettingRepo; +use crate::model::error::Error; + +#[derive(Debug, Clone)] +pub struct DnsFilterConfig { + pub max_domains_per_request: usize, +} + +impl DnsFilterConfig { + pub fn defaults() -> Self { + Self { + max_domains_per_request: 1000, + } + } + + pub fn from_settings(repo: &dyn SettingRepo) -> Result { + let mut cfg = Self::defaults(); + override_parsed(&mut cfg.max_domains_per_request, repo, "dns_max_domains_per_request")?; + Ok(cfg) + } + + pub fn seed_defaults(repo: &dyn SettingRepo) -> Result<(), Error> { + seed_key(repo, "dns_max_domains_per_request", "1000")?; + Ok(()) + } +} diff --git a/net-guardia/src/model/config/ebpf.rs b/net-guardia/src/model/config/ebpf.rs new file mode 100644 index 0000000..fe87037 --- /dev/null +++ b/net-guardia/src/model/config/ebpf.rs @@ -0,0 +1,75 @@ +use super::helpers::{override_parsed, override_string, seed_key}; +use crate::interface::port::setting::SettingRepo; +use crate::model::error::Error; + +#[derive(Debug, Clone)] +pub struct EbpfConfig { + pub ingress_ifname: String, + pub egress_ifname: String, + pub combined_queue_count: u32, + pub channel_size: usize, + pub fill_queue_size: u32, + pub comp_queue_size: u32, + pub tx_queue_size: u32, + pub rx_queue_size: u32, + pub frame_size: u32, + pub frame_count: u32, + pub refresh_interval: u64, + pub packet_buffer_size: usize, + pub buffer_pool_capacity: usize, +} + +impl EbpfConfig { + pub fn defaults() -> Self { + Self { + ingress_ifname: "eth0".to_string(), + egress_ifname: "eth1".to_string(), + combined_queue_count: 1, + channel_size: 4096, + fill_queue_size: 4096, + comp_queue_size: 4096, + tx_queue_size: 4096, + rx_queue_size: 4096, + frame_size: 4096, + frame_count: 4096, + refresh_interval: 5, + packet_buffer_size: 2048, + buffer_pool_capacity: 1024, + } + } + + pub fn from_settings(repo: &dyn SettingRepo) -> Result { + let mut cfg = Self::defaults(); + override_string(&mut cfg.ingress_ifname, repo, "ingress_interface")?; + override_string(&mut cfg.egress_ifname, repo, "egress_interface")?; + override_parsed(&mut cfg.combined_queue_count, repo, "combined_queue_count")?; + override_parsed(&mut cfg.channel_size, repo, "channel_size")?; + override_parsed(&mut cfg.fill_queue_size, repo, "fill_queue_size")?; + override_parsed(&mut cfg.comp_queue_size, repo, "comp_queue_size")?; + override_parsed(&mut cfg.tx_queue_size, repo, "tx_queue_size")?; + override_parsed(&mut cfg.rx_queue_size, repo, "rx_queue_size")?; + override_parsed(&mut cfg.frame_size, repo, "frame_size")?; + override_parsed(&mut cfg.frame_count, repo, "frame_count")?; + override_parsed(&mut cfg.refresh_interval, repo, "refresh_interval")?; + override_parsed(&mut cfg.packet_buffer_size, repo, "packet_buffer_size")?; + override_parsed(&mut cfg.buffer_pool_capacity, repo, "buffer_pool_capacity")?; + Ok(cfg) + } + + pub fn seed_defaults(repo: &dyn SettingRepo) -> Result<(), Error> { + seed_key(repo, "ingress_interface", "eth0")?; + seed_key(repo, "egress_interface", "eth1")?; + seed_key(repo, "combined_queue_count", "1")?; + seed_key(repo, "channel_size", "4096")?; + seed_key(repo, "fill_queue_size", "4096")?; + seed_key(repo, "comp_queue_size", "4096")?; + seed_key(repo, "tx_queue_size", "4096")?; + seed_key(repo, "rx_queue_size", "4096")?; + seed_key(repo, "frame_size", "4096")?; + seed_key(repo, "frame_count", "4096")?; + seed_key(repo, "refresh_interval", "5")?; + seed_key(repo, "packet_buffer_size", "2048")?; + seed_key(repo, "buffer_pool_capacity", "1024")?; + Ok(()) + } +} diff --git a/net-guardia/src/model/config/helpers.rs b/net-guardia/src/model/config/helpers.rs new file mode 100644 index 0000000..f71ee0b --- /dev/null +++ b/net-guardia/src/model/config/helpers.rs @@ -0,0 +1,54 @@ +use std::str::FromStr; + +use crate::interface::port::setting::SettingRepo; +use crate::model::error::Error; + +pub(super) fn override_parsed(target: &mut T, repo: &dyn SettingRepo, key: &str) -> Result<(), Error> { + if let Some(v) = repo.get_setting(key)? + && let Ok(parsed) = v.parse() + { + *target = parsed; + } + Ok(()) +} + +pub(super) fn override_bool(target: &mut bool, repo: &dyn SettingRepo, key: &str) -> Result<(), Error> { + if let Some(v) = repo.get_setting(key)? { + *target = v == "true" || v == "1"; + } + Ok(()) +} + +pub(super) fn override_string(target: &mut String, repo: &dyn SettingRepo, key: &str) -> Result<(), Error> { + if let Some(v) = repo.get_setting(key)? { + *target = v; + } + Ok(()) +} + +pub(super) fn override_string_nonempty(target: &mut String, repo: &dyn SettingRepo, key: &str) -> Result<(), Error> { + if let Some(v) = repo.get_setting(key)? + && !v.is_empty() + { + *target = v; + } + Ok(()) +} + +pub(super) fn override_csv(target: &mut Vec, repo: &dyn SettingRepo, key: &str) -> Result<(), Error> { + if let Some(v) = repo.get_setting(key)? { + *target = if v.is_empty() { + Vec::new() + } else { + v.split(',').map(|s| s.trim().to_string()).collect() + }; + } + Ok(()) +} + +pub(super) fn seed_key(repo: &dyn SettingRepo, key: &str, value: &str) -> Result<(), Error> { + if repo.get_setting(key)?.is_none() { + repo.set_setting(key, value)?; + } + Ok(()) +} diff --git a/net-guardia/src/model/config/http_server.rs b/net-guardia/src/model/config/http_server.rs new file mode 100644 index 0000000..47c1ba2 --- /dev/null +++ b/net-guardia/src/model/config/http_server.rs @@ -0,0 +1,35 @@ +use super::helpers::{override_bool, override_csv, override_parsed, seed_key}; +use crate::interface::port::setting::SettingRepo; +use crate::model::error::Error; + +#[derive(Debug, Clone)] +pub struct HttpServerConfig { + pub port: u16, + pub cors_allowed_origins: Vec, + pub force_https: bool, +} + +impl HttpServerConfig { + pub fn defaults() -> Self { + Self { + port: 8080, + cors_allowed_origins: Vec::new(), + force_https: false, + } + } + + pub fn from_settings(repo: &dyn SettingRepo) -> Result { + let mut cfg = Self::defaults(); + override_parsed(&mut cfg.port, repo, "http_port")?; + override_csv(&mut cfg.cors_allowed_origins, repo, "cors_allowed_origins")?; + override_bool(&mut cfg.force_https, repo, "force_https")?; + Ok(cfg) + } + + pub fn seed_defaults(repo: &dyn SettingRepo) -> Result<(), Error> { + seed_key(repo, "http_port", "8080")?; + seed_key(repo, "cors_allowed_origins", "")?; + seed_key(repo, "force_https", "false")?; + Ok(()) + } +} diff --git a/net-guardia/src/model/config/ml.rs b/net-guardia/src/model/config/ml.rs new file mode 100644 index 0000000..c44245b --- /dev/null +++ b/net-guardia/src/model/config/ml.rs @@ -0,0 +1,219 @@ +use super::helpers::{override_bool, override_parsed, override_string_nonempty, seed_key}; +use crate::interface::port::setting::SettingRepo; +use crate::model::error::Error; + +#[derive(Debug, Clone)] +pub struct MlConfig { + pub deep_autoencoder_name: String, + pub classifier_name: String, + pub models_config_name: String, + pub max_concurrent_flows: usize, + pub min_packets_for_inference: usize, + pub min_packets_floor: usize, + pub inference_interval_secs: u64, + pub aggregator_window_secs: u64, + pub inference_batch_size: usize, + pub confirmation_window_fraction: u64, + pub traffic_logging_mode: bool, + pub traffic_log_csv_path: String, + pub flow_trace_max_file_bytes: u64, + pub flow_trace_max_file_age_secs: u64, + pub flow_trace_total_budget_bytes: u64, + pub traffic_logger_channel_capacity: usize, + pub model_upload_max_onnx_bytes: usize, + pub model_upload_max_manifest_bytes: usize, + pub model_upload_max_scaler_bytes: usize, + pub drift_window_secs: u64, + pub drift_max_snapshots: usize, + pub drift_channel_capacity: usize, + pub alert_channel_capacity: usize, + pub circuit_breaker_threshold: u32, + pub circuit_breaker_window_secs: u64, + pub circuit_breaker_cooldown_secs: u64, + pub onnx_load_timeout_secs: u64, + pub model_watcher_debounce_secs: u64, + pub flow_max_packets_per_direction: usize, + pub flow_max_periods: usize, + pub flow_idle_threshold_us: u64, + pub flow_bulk_min_packets: u64, + pub flow_bulk_min_bytes: u64, + pub flow_idle_timeout_us: u64, + pub flow_terminated_timeout_us: u64, +} + +impl MlConfig { + pub fn defaults() -> Self { + Self { + deep_autoencoder_name: "deep_autoencoder.onnx".to_string(), + classifier_name: "classifier.onnx".to_string(), + models_config_name: "inference_config.json".to_string(), + max_concurrent_flows: 10_000, + min_packets_for_inference: 5, + min_packets_floor: 5, + inference_interval_secs: 5, + aggregator_window_secs: 30, + inference_batch_size: 200, + confirmation_window_fraction: 2, + traffic_logging_mode: false, + traffic_log_csv_path: "traffic_log.csv".to_string(), + flow_trace_max_file_bytes: 500 * 1024 * 1024, + flow_trace_max_file_age_secs: 3600, + flow_trace_total_budget_bytes: 10 * 1024 * 1024 * 1024, + traffic_logger_channel_capacity: 65_536, + model_upload_max_onnx_bytes: 100 * 1024 * 1024, + model_upload_max_manifest_bytes: 64 * 1024, + model_upload_max_scaler_bytes: 64 * 1024, + drift_window_secs: 3600, + drift_max_snapshots: 10_000, + drift_channel_capacity: 1024, + alert_channel_capacity: 1024, + circuit_breaker_threshold: 5, + circuit_breaker_window_secs: 60, + circuit_breaker_cooldown_secs: 120, + onnx_load_timeout_secs: 5, + model_watcher_debounce_secs: 5, + flow_max_packets_per_direction: 1000, + flow_max_periods: 1000, + flow_idle_threshold_us: 1_000_000, + flow_bulk_min_packets: 4, + flow_bulk_min_bytes: 1000, + flow_idle_timeout_us: 120_000_000, + flow_terminated_timeout_us: 5_000_000, + } + } + + pub fn from_settings(repo: &dyn SettingRepo) -> Result { + let mut cfg = Self::defaults(); + override_string_nonempty(&mut cfg.deep_autoencoder_name, repo, "deep_autoencoder_name")?; + override_string_nonempty(&mut cfg.classifier_name, repo, "classifier_name")?; + override_string_nonempty(&mut cfg.models_config_name, repo, "models_config_name")?; + override_parsed(&mut cfg.max_concurrent_flows, repo, "max_concurrent_flows")?; + override_parsed(&mut cfg.min_packets_for_inference, repo, "min_packets_for_inference")?; + override_parsed(&mut cfg.min_packets_floor, repo, "ml_min_packets_floor")?; + override_parsed(&mut cfg.inference_interval_secs, repo, "inference_interval_secs")?; + override_parsed(&mut cfg.aggregator_window_secs, repo, "aggregator_window_secs")?; + override_parsed(&mut cfg.inference_batch_size, repo, "inference_batch_size")?; + override_parsed( + &mut cfg.confirmation_window_fraction, + repo, + "ml_confirmation_window_fraction", + )?; + override_bool(&mut cfg.traffic_logging_mode, repo, "traffic_logging_mode")?; + override_string_nonempty(&mut cfg.traffic_log_csv_path, repo, "traffic_log_csv_path")?; + override_parsed(&mut cfg.flow_trace_max_file_bytes, repo, "flow_trace_max_file_bytes")?; + override_parsed( + &mut cfg.flow_trace_max_file_age_secs, + repo, + "flow_trace_max_file_age_secs", + )?; + override_parsed( + &mut cfg.flow_trace_total_budget_bytes, + repo, + "flow_trace_total_budget_bytes", + )?; + override_parsed( + &mut cfg.traffic_logger_channel_capacity, + repo, + "traffic_logger_channel_capacity", + )?; + override_parsed( + &mut cfg.model_upload_max_onnx_bytes, + repo, + "model_upload_max_onnx_bytes", + )?; + override_parsed( + &mut cfg.model_upload_max_manifest_bytes, + repo, + "model_upload_max_manifest_bytes", + )?; + override_parsed( + &mut cfg.model_upload_max_scaler_bytes, + repo, + "model_upload_max_scaler_bytes", + )?; + override_parsed(&mut cfg.drift_window_secs, repo, "ml_drift_window_secs")?; + override_parsed(&mut cfg.drift_max_snapshots, repo, "ml_drift_max_snapshots")?; + override_parsed(&mut cfg.drift_channel_capacity, repo, "ml_drift_channel_capacity")?; + override_parsed(&mut cfg.alert_channel_capacity, repo, "ml_alert_channel_capacity")?; + override_parsed(&mut cfg.circuit_breaker_threshold, repo, "ml_circuit_breaker_threshold")?; + override_parsed( + &mut cfg.circuit_breaker_window_secs, + repo, + "ml_circuit_breaker_window_secs", + )?; + override_parsed( + &mut cfg.circuit_breaker_cooldown_secs, + repo, + "ml_circuit_breaker_cooldown_secs", + )?; + override_parsed(&mut cfg.onnx_load_timeout_secs, repo, "ml_onnx_load_timeout_secs")?; + override_parsed( + &mut cfg.model_watcher_debounce_secs, + repo, + "ml_model_watcher_debounce_secs", + )?; + override_parsed( + &mut cfg.flow_max_packets_per_direction, + repo, + "ml_flow_max_packets_per_direction", + )?; + override_parsed(&mut cfg.flow_max_periods, repo, "ml_flow_max_periods")?; + override_parsed(&mut cfg.flow_idle_threshold_us, repo, "ml_flow_idle_threshold_us")?; + override_parsed(&mut cfg.flow_bulk_min_packets, repo, "ml_flow_bulk_min_packets")?; + override_parsed(&mut cfg.flow_bulk_min_bytes, repo, "ml_flow_bulk_min_bytes")?; + override_parsed(&mut cfg.flow_idle_timeout_us, repo, "ml_flow_idle_timeout_us")?; + override_parsed( + &mut cfg.flow_terminated_timeout_us, + repo, + "ml_flow_terminated_timeout_us", + )?; + Ok(cfg) + } + + pub fn seed_defaults(repo: &dyn SettingRepo) -> Result<(), Error> { + seed_key(repo, "deep_autoencoder_name", "deep_autoencoder.onnx")?; + seed_key(repo, "classifier_name", "classifier.onnx")?; + seed_key(repo, "models_config_name", "inference_config.json")?; + seed_key(repo, "max_concurrent_flows", "10000")?; + seed_key(repo, "min_packets_for_inference", "5")?; + seed_key(repo, "ml_min_packets_floor", "5")?; + seed_key(repo, "inference_interval_secs", "5")?; + seed_key(repo, "aggregator_window_secs", "30")?; + seed_key(repo, "inference_batch_size", "200")?; + seed_key(repo, "ml_confirmation_window_fraction", "2")?; + seed_key(repo, "traffic_logging_mode", "false")?; + seed_key(repo, "traffic_log_csv_path", "traffic_log.csv")?; + seed_key(repo, "flow_trace_max_file_bytes", &(500_u64 * 1024 * 1024).to_string())?; + seed_key(repo, "flow_trace_max_file_age_secs", "3600")?; + seed_key( + repo, + "flow_trace_total_budget_bytes", + &(10_u64 * 1024 * 1024 * 1024).to_string(), + )?; + seed_key(repo, "traffic_logger_channel_capacity", "65536")?; + seed_key( + repo, + "model_upload_max_onnx_bytes", + &(100_usize * 1024 * 1024).to_string(), + )?; + seed_key(repo, "model_upload_max_manifest_bytes", &(64_usize * 1024).to_string())?; + seed_key(repo, "model_upload_max_scaler_bytes", &(64_usize * 1024).to_string())?; + seed_key(repo, "ml_drift_window_secs", "3600")?; + seed_key(repo, "ml_drift_max_snapshots", "10000")?; + seed_key(repo, "ml_drift_channel_capacity", "1024")?; + seed_key(repo, "ml_alert_channel_capacity", "1024")?; + seed_key(repo, "ml_circuit_breaker_threshold", "5")?; + seed_key(repo, "ml_circuit_breaker_window_secs", "60")?; + seed_key(repo, "ml_circuit_breaker_cooldown_secs", "120")?; + seed_key(repo, "ml_onnx_load_timeout_secs", "5")?; + seed_key(repo, "ml_model_watcher_debounce_secs", "5")?; + seed_key(repo, "ml_flow_max_packets_per_direction", "1000")?; + seed_key(repo, "ml_flow_max_periods", "1000")?; + seed_key(repo, "ml_flow_idle_threshold_us", "1000000")?; + seed_key(repo, "ml_flow_bulk_min_packets", "4")?; + seed_key(repo, "ml_flow_bulk_min_bytes", "1000")?; + seed_key(repo, "ml_flow_idle_timeout_us", "120000000")?; + seed_key(repo, "ml_flow_terminated_timeout_us", "5000000")?; + Ok(()) + } +} diff --git a/net-guardia/src/model/config/mod.rs b/net-guardia/src/model/config/mod.rs index 0b6d2c4..71f4b66 100644 --- a/net-guardia/src/model/config/mod.rs +++ b/net-guardia/src/model/config/mod.rs @@ -1 +1,291 @@ +pub mod acl; +pub mod auth; pub mod constants; +pub mod correlation; +pub mod detection; +pub mod dns_filter; +pub mod ebpf; +mod helpers; +pub mod http_server; +pub mod ml; +pub mod notification; +pub mod observability; +pub mod pipeline; +pub mod soar; +pub mod suricata; +pub mod system; + +use crate::interface::port::setting::SettingRepo; +use crate::model::config::acl::AclConfig; +use crate::model::config::auth::AuthConfig; +use crate::model::config::correlation::CorrelationConfig; +use crate::model::config::detection::DetectionConfig; +use crate::model::config::dns_filter::DnsFilterConfig; +use crate::model::config::ebpf::EbpfConfig; +use crate::model::config::http_server::HttpServerConfig; +use crate::model::config::ml::MlConfig; +use crate::model::config::notification::NotificationConfig; +use crate::model::config::observability::ObservabilityConfig; +use crate::model::config::pipeline::PipelineConfig; +use crate::model::config::soar::SoarConfig; +use crate::model::config::suricata::SuricataConfig; +use crate::model::config::system::SystemConfig; +use crate::model::error::Error; +use crate::model::error::system::SystemError; + +#[derive(Debug, Clone)] +pub struct AppConfig { + pub acl: AclConfig, + pub auth: AuthConfig, + pub correlation: CorrelationConfig, + pub detection: DetectionConfig, + pub dns_filter: DnsFilterConfig, + pub ebpf: EbpfConfig, + pub http_server: HttpServerConfig, + pub ml: MlConfig, + pub notification: NotificationConfig, + pub observability: ObservabilityConfig, + pub pipeline: PipelineConfig, + pub soar: SoarConfig, + pub suricata: SuricataConfig, + pub system: SystemConfig, +} + +impl AppConfig { + pub fn from_settings(repo: &dyn SettingRepo) -> Result { + let cfg = Self { + acl: AclConfig::from_settings(repo)?, + auth: AuthConfig::from_settings(repo)?, + correlation: CorrelationConfig::from_settings(repo)?, + detection: DetectionConfig::from_settings(repo)?, + dns_filter: DnsFilterConfig::from_settings(repo)?, + ebpf: EbpfConfig::from_settings(repo)?, + http_server: HttpServerConfig::from_settings(repo)?, + ml: MlConfig::from_settings(repo)?, + notification: NotificationConfig::from_settings(repo)?, + observability: ObservabilityConfig::from_settings(repo)?, + pipeline: PipelineConfig::from_settings(repo)?, + soar: SoarConfig::from_settings(repo)?, + suricata: SuricataConfig::from_settings(repo)?, + system: SystemConfig::from_settings(repo)?, + }; + cfg.validate()?; + Ok(cfg) + } + + pub fn seed_defaults(repo: &dyn SettingRepo) -> Result<(), Error> { + AclConfig::seed_defaults(repo)?; + AuthConfig::seed_defaults(repo)?; + CorrelationConfig::seed_defaults(repo)?; + DetectionConfig::seed_defaults(repo)?; + DnsFilterConfig::seed_defaults(repo)?; + EbpfConfig::seed_defaults(repo)?; + HttpServerConfig::seed_defaults(repo)?; + MlConfig::seed_defaults(repo)?; + NotificationConfig::seed_defaults(repo)?; + ObservabilityConfig::seed_defaults(repo)?; + PipelineConfig::seed_defaults(repo)?; + SoarConfig::seed_defaults(repo)?; + SuricataConfig::seed_defaults(repo)?; + SystemConfig::seed_defaults(repo)?; + Ok(()) + } + + fn validate(&self) -> Result<(), Error> { + let valid = self.ebpf.refresh_interval <= 3600 + && self.ebpf.combined_queue_count > 0 + && self.ebpf.fill_queue_size > 0 + && self.ebpf.comp_queue_size > 0 + && self.ebpf.tx_queue_size > 0 + && self.ebpf.rx_queue_size > 0 + && self.ebpf.frame_size > 0 + && self.ebpf.frame_count > 0 + && self.http_server.port > 0 + && self.ml.max_concurrent_flows > 0 + && self.ml.min_packets_for_inference > 0 + && self.ml.min_packets_floor > 0 + && self.ml.inference_interval_secs > 0 + && self.ml.inference_batch_size > 0 + && self.ml.confirmation_window_fraction > 0 + && self.ml.drift_max_snapshots > 0 + && self.ml.drift_channel_capacity > 0 + && self.ml.alert_channel_capacity > 0 + && self.ml.traffic_logger_channel_capacity > 0 + && self.ml.circuit_breaker_threshold > 0 + && self.ml.onnx_load_timeout_secs > 0 + && self.ml.flow_max_packets_per_direction > 0 + && self.ml.flow_max_periods > 0 + && self.soar.handle_concurrency > 0 + && self.soar.rate_limit_cmd_channel_capacity > 0 + && self.soar.frequency_max_tracked_keys > 0 + && (0.0..=1.0).contains(&self.soar.default_rate_limit_factor) + && (0.0..=1.0).contains(&self.soar.default_single_source_high_min_confidence) + && self.detection.cleanup_interval_secs > 0 + && self.detection.fusion.max_dedup_entries > 0 + && self.detection.fusion.dedup_window_secs > 0 + && self.detection.beaconing.min_observations > 0 + && self.detection.beaconing.max_cache_entries > 0 + && self.correlation.max_tracked_entries > 0 + && self.correlation.scan.window_secs > 0 + && self.correlation.scan.threshold > 0 + && self.correlation.lateral.window_secs > 0 + && self.correlation.lateral.threshold > 0 + && self.correlation.botnet.window_secs > 0 + && self.correlation.botnet.threshold > 0 + && self.observability.log_buffer_capacity > 0 + && self.observability.log_buffer_max_message_bytes > 0 + && self.observability.log_live_default_limit > 0 + && self.observability.log_live_max_limit >= self.observability.log_live_default_limit + && self.observability.fusion_explain_scan_limit > 0 + && self.observability.fusion_explain_response_cap > 0 + && self.observability.default_event_channel_capacity > 0 + && self.observability.drop_channel_capacity > 0 + && self.suricata.poll_interval_ms > 0 + && (0.0..=1.0).contains(&self.suricata.confidence_high) + && (0.0..=1.0).contains(&self.suricata.confidence_medium) + && (0.0..=1.0).contains(&self.suricata.confidence_low) + && (0.0..=1.0).contains(&self.suricata.confidence_info); + + if !valid { + Err(SystemError::InvalidConfig)? + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::adapter::persistence::Database; + + fn test_db() -> Database { + // SAFETY: in-memory SQLite open is infallible under standard library features. + Database::new(":memory:").expect("in-memory DB") + } + + #[test] + fn defaults_are_valid() { + let db = test_db(); + let cfg = AppConfig::from_settings(&db).expect("defaults should be valid"); + assert_eq!(cfg.http_server.port, 8080); + assert_eq!(cfg.ebpf.ingress_ifname, "eth0"); + assert_eq!(cfg.ebpf.egress_ifname, "eth1"); + assert_eq!(cfg.ebpf.combined_queue_count, 1); + assert_eq!(cfg.ebpf.frame_size, 4096); + assert_eq!(cfg.auth.jwt_expiry_hours, 24); + } + + #[test] + fn db_overrides_interface_names() { + let db = test_db(); + db.set_setting("ingress_interface", "ens33").unwrap(); + db.set_setting("egress_interface", "ens34").unwrap(); + let cfg = AppConfig::from_settings(&db).unwrap(); + assert_eq!(cfg.ebpf.ingress_ifname, "ens33"); + assert_eq!(cfg.ebpf.egress_ifname, "ens34"); + } + + #[test] + fn db_overrides_http_port() { + let db = test_db(); + db.set_setting("http_port", "9090").unwrap(); + let cfg = AppConfig::from_settings(&db).unwrap(); + assert_eq!(cfg.http_server.port, 9090); + } + + #[test] + fn db_overrides_xdp_tuning() { + let db = test_db(); + db.set_setting("frame_size", "8192").unwrap(); + db.set_setting("combined_queue_count", "4").unwrap(); + let cfg = AppConfig::from_settings(&db).unwrap(); + assert_eq!(cfg.ebpf.frame_size, 8192); + assert_eq!(cfg.ebpf.combined_queue_count, 4); + } + + #[test] + fn invalid_db_values_ignored() { + let db = test_db(); + db.set_setting("http_port", "not_a_number").unwrap(); + let cfg = AppConfig::from_settings(&db).unwrap(); + assert_eq!(cfg.http_server.port, 8080); + } + + #[test] + fn empty_db_uses_all_defaults() { + let db = test_db(); + let cfg = AppConfig::from_settings(&db).unwrap(); + assert_eq!(cfg.ml.inference_interval_secs, 5); + assert_eq!(cfg.ml.inference_batch_size, 200); + assert_eq!(cfg.system.database_path, "net-guardia.db"); + } + + #[test] + fn seed_defaults_populates_empty_db() { + let db = test_db(); + AppConfig::seed_defaults(&db).expect("seed should succeed"); + assert_eq!(db.get_setting("http_port").unwrap(), Some("8080".to_string())); + assert_eq!( + db.get_setting("traffic_logging_mode").unwrap(), + Some("false".to_string()) + ); + assert_eq!( + db.get_setting("pipeline_ingress").unwrap(), + Some("access_control,rate_limit,service".to_string()) + ); + assert_eq!( + db.get_setting("geoip_db_name").unwrap(), + Some("net-guardia/static/geo/dbip-city-lite.mmdb".to_string()) + ); + assert_eq!( + db.get_setting("soar_max_auto_block_cap").unwrap(), + Some("100".to_string()) + ); + assert_eq!(db.get_setting("soar_max_ttl_secs").unwrap(), Some("86400".to_string())); + assert_eq!( + db.get_setting("ml_drift_window_secs").unwrap(), + Some("3600".to_string()) + ); + } + + #[test] + fn seed_defaults_does_not_overwrite_existing() { + let db = test_db(); + db.set_setting("http_port", "9090").unwrap(); + AppConfig::seed_defaults(&db).expect("seed should succeed"); + assert_eq!(db.get_setting("http_port").unwrap(), Some("9090".to_string())); + } + + #[test] + fn db_overrides_traffic_logging_mode() { + let db = test_db(); + db.set_setting("traffic_logging_mode", "true").unwrap(); + let cfg = AppConfig::from_settings(&db).unwrap(); + assert!(cfg.ml.traffic_logging_mode); + } + + #[test] + fn default_traffic_logging_mode_is_false() { + let db = test_db(); + let cfg = AppConfig::from_settings(&db).unwrap(); + assert!(!cfg.ml.traffic_logging_mode); + } + + #[test] + fn db_overrides_pipeline() { + let db = test_db(); + db.set_setting("pipeline_ingress", "access_control,service").unwrap(); + let cfg = AppConfig::from_settings(&db).unwrap(); + assert_eq!(cfg.pipeline.ingress, vec!["access_control", "service"]); + } + + #[test] + fn db_overrides_soar_and_drift() { + let db = test_db(); + db.set_setting("soar_max_ttl_secs", "3600").unwrap(); + db.set_setting("ml_drift_window_secs", "900").unwrap(); + let cfg = AppConfig::from_settings(&db).unwrap(); + assert_eq!(cfg.soar.max_ttl_secs, 3600); + assert_eq!(cfg.ml.drift_window_secs, 900); + } +} diff --git a/net-guardia/src/model/config/notification.rs b/net-guardia/src/model/config/notification.rs new file mode 100644 index 0000000..796d503 --- /dev/null +++ b/net-guardia/src/model/config/notification.rs @@ -0,0 +1,95 @@ +use super::helpers::{override_parsed, override_string, seed_key}; +use crate::interface::port::setting::SettingRepo; +use crate::model::error::Error; + +#[derive(Debug, Clone)] +pub struct TelegramConfig { + pub rate_limit_max_messages: u32, + pub rate_limit_window_secs: u32, + pub max_retries: u32, +} + +impl TelegramConfig { + pub fn defaults() -> Self { + Self { + rate_limit_max_messages: 20, + rate_limit_window_secs: 60, + max_retries: 2, + } + } + + pub fn from_settings(repo: &dyn SettingRepo) -> Result { + let mut cfg = Self::defaults(); + override_parsed( + &mut cfg.rate_limit_max_messages, + repo, + "telegram_rate_limit_max_messages", + )?; + override_parsed(&mut cfg.rate_limit_window_secs, repo, "telegram_rate_limit_window_secs")?; + override_parsed(&mut cfg.max_retries, repo, "telegram_max_retries")?; + Ok(cfg) + } + + pub fn seed_defaults(repo: &dyn SettingRepo) -> Result<(), Error> { + seed_key(repo, "telegram_rate_limit_max_messages", "20")?; + seed_key(repo, "telegram_rate_limit_window_secs", "60")?; + seed_key(repo, "telegram_max_retries", "2")?; + Ok(()) + } +} + +#[derive(Debug, Clone)] +pub struct SmtpConfig { + pub host: String, + pub port: u16, + pub username: String, + pub sender: String, + pub recipient: String, +} + +impl SmtpConfig { + pub fn defaults() -> Self { + Self { + host: String::new(), + port: 587, + username: String::new(), + sender: String::new(), + recipient: String::new(), + } + } + + pub fn from_settings(repo: &dyn SettingRepo) -> Result { + let mut cfg = Self::defaults(); + override_string(&mut cfg.host, repo, "smtp_host")?; + override_parsed(&mut cfg.port, repo, "smtp_port")?; + override_string(&mut cfg.username, repo, "smtp_username")?; + override_string(&mut cfg.sender, repo, "smtp_sender")?; + override_string(&mut cfg.recipient, repo, "smtp_recipient")?; + Ok(cfg) + } + + pub fn seed_defaults(_repo: &dyn SettingRepo) -> Result<(), Error> { + Ok(()) + } +} + +#[derive(Debug, Clone)] +pub struct NotificationConfig { + pub telegram: TelegramConfig, + pub smtp: SmtpConfig, +} + +impl NotificationConfig { + pub fn from_settings(repo: &dyn SettingRepo) -> Result { + Ok(Self { + telegram: TelegramConfig::from_settings(repo)?, + smtp: SmtpConfig::from_settings(repo)?, + }) + } + + pub fn seed_defaults(repo: &dyn SettingRepo) -> Result<(), Error> { + TelegramConfig::seed_defaults(repo)?; + SmtpConfig::seed_defaults(repo)?; + Ok(()) + } +} diff --git a/net-guardia/src/model/config/observability.rs b/net-guardia/src/model/config/observability.rs new file mode 100644 index 0000000..28eee9d --- /dev/null +++ b/net-guardia/src/model/config/observability.rs @@ -0,0 +1,71 @@ +use super::helpers::{override_parsed, seed_key}; +use crate::interface::port::setting::SettingRepo; +use crate::model::error::Error; + +#[derive(Debug, Clone)] +pub struct ObservabilityConfig { + pub log_buffer_capacity: usize, + pub log_buffer_max_message_bytes: usize, + pub log_max_download_size: u64, + pub log_live_default_limit: usize, + pub log_live_max_limit: usize, + pub fusion_explain_scan_limit: i64, + pub fusion_explain_response_cap: usize, + pub default_event_channel_capacity: usize, + pub drop_channel_capacity: usize, +} + +impl ObservabilityConfig { + pub fn defaults() -> Self { + Self { + log_buffer_capacity: 5_000, + log_buffer_max_message_bytes: 8_192, + log_max_download_size: 50 * 1024 * 1024, + log_live_default_limit: 500, + log_live_max_limit: 2_000, + fusion_explain_scan_limit: 5_000, + fusion_explain_response_cap: 200, + default_event_channel_capacity: 256, + drop_channel_capacity: 100, + } + } + + pub fn from_settings(repo: &dyn SettingRepo) -> Result { + let mut cfg = Self::defaults(); + override_parsed(&mut cfg.log_buffer_capacity, repo, "log_buffer_capacity")?; + override_parsed( + &mut cfg.log_buffer_max_message_bytes, + repo, + "log_buffer_max_message_bytes", + )?; + override_parsed(&mut cfg.log_max_download_size, repo, "log_max_download_size")?; + override_parsed(&mut cfg.log_live_default_limit, repo, "log_live_default_limit")?; + override_parsed(&mut cfg.log_live_max_limit, repo, "log_live_max_limit")?; + override_parsed(&mut cfg.fusion_explain_scan_limit, repo, "fusion_explain_scan_limit")?; + override_parsed( + &mut cfg.fusion_explain_response_cap, + repo, + "fusion_explain_response_cap", + )?; + override_parsed( + &mut cfg.default_event_channel_capacity, + repo, + "default_event_channel_capacity", + )?; + override_parsed(&mut cfg.drop_channel_capacity, repo, "drop_channel_capacity")?; + Ok(cfg) + } + + pub fn seed_defaults(repo: &dyn SettingRepo) -> Result<(), Error> { + seed_key(repo, "log_buffer_capacity", "5000")?; + seed_key(repo, "log_buffer_max_message_bytes", "8192")?; + seed_key(repo, "log_max_download_size", &(50_u64 * 1024 * 1024).to_string())?; + seed_key(repo, "log_live_default_limit", "500")?; + seed_key(repo, "log_live_max_limit", "2000")?; + seed_key(repo, "fusion_explain_scan_limit", "5000")?; + seed_key(repo, "fusion_explain_response_cap", "200")?; + seed_key(repo, "default_event_channel_capacity", "256")?; + seed_key(repo, "drop_channel_capacity", "100")?; + Ok(()) + } +} diff --git a/net-guardia/src/model/config/pipeline.rs b/net-guardia/src/model/config/pipeline.rs new file mode 100644 index 0000000..ce60b79 --- /dev/null +++ b/net-guardia/src/model/config/pipeline.rs @@ -0,0 +1,35 @@ +use super::helpers::{override_csv, seed_key}; +use crate::interface::port::setting::SettingRepo; +use crate::model::error::Error; + +#[derive(Debug, Clone)] +pub struct PipelineConfig { + pub ingress: Vec, + pub egress: Vec, +} + +impl PipelineConfig { + pub fn defaults() -> Self { + Self { + ingress: vec![ + "access_control".to_string(), + "rate_limit".to_string(), + "service".to_string(), + ], + egress: Vec::new(), + } + } + + pub fn from_settings(repo: &dyn SettingRepo) -> Result { + let mut cfg = Self::defaults(); + override_csv(&mut cfg.ingress, repo, "pipeline_ingress")?; + override_csv(&mut cfg.egress, repo, "pipeline_egress")?; + Ok(cfg) + } + + pub fn seed_defaults(repo: &dyn SettingRepo) -> Result<(), Error> { + seed_key(repo, "pipeline_ingress", "access_control,rate_limit,service")?; + seed_key(repo, "pipeline_egress", "")?; + Ok(()) + } +} diff --git a/net-guardia/src/model/config/soar.rs b/net-guardia/src/model/config/soar.rs new file mode 100644 index 0000000..170ba0d --- /dev/null +++ b/net-guardia/src/model/config/soar.rs @@ -0,0 +1,115 @@ +use super::helpers::{override_parsed, seed_key}; +use crate::interface::port::setting::SettingRepo; +use crate::model::error::Error; + +#[derive(Debug, Clone)] +pub struct SoarConfig { + pub max_auto_block_cap: u32, + pub max_ttl_secs: u64, + pub handle_concurrency: usize, + pub max_pending_unblock_retries: i64, + pub default_block_ttl_secs: u64, + pub default_rate_limit_factor: f64, + pub default_rate_limit_ttl_secs: u64, + pub default_webhook_timeout_secs: u64, + pub default_frequency_window_secs: u64, + pub default_single_source_high_min_confidence: f32, + pub default_cooldown_expiry_secs: u64, + pub rate_limit_cmd_channel_capacity: usize, + pub frequency_max_tracked_keys: usize, + pub fallback_cooldown_secs: i64, +} + +impl SoarConfig { + pub fn defaults() -> Self { + Self { + max_auto_block_cap: 100, + max_ttl_secs: 86_400, + handle_concurrency: 16, + max_pending_unblock_retries: 5, + default_block_ttl_secs: 1800, + default_rate_limit_factor: 0.5, + default_rate_limit_ttl_secs: 600, + default_webhook_timeout_secs: 10, + default_frequency_window_secs: 60, + default_single_source_high_min_confidence: 0.95, + default_cooldown_expiry_secs: 3600, + rate_limit_cmd_channel_capacity: 64, + frequency_max_tracked_keys: 50_000, + fallback_cooldown_secs: 300, + } + } + + pub fn from_settings(repo: &dyn SettingRepo) -> Result { + let mut cfg = Self::defaults(); + override_parsed(&mut cfg.max_auto_block_cap, repo, "soar_max_auto_block_cap")?; + override_parsed(&mut cfg.max_ttl_secs, repo, "soar_max_ttl_secs")?; + override_parsed(&mut cfg.handle_concurrency, repo, "soar_handle_concurrency")?; + override_parsed( + &mut cfg.max_pending_unblock_retries, + repo, + "soar_max_pending_unblock_retries", + )?; + override_parsed(&mut cfg.default_block_ttl_secs, repo, "soar_default_block_ttl_secs")?; + override_parsed( + &mut cfg.default_rate_limit_factor, + repo, + "soar_default_rate_limit_factor", + )?; + override_parsed( + &mut cfg.default_rate_limit_ttl_secs, + repo, + "soar_default_rate_limit_ttl_secs", + )?; + override_parsed( + &mut cfg.default_webhook_timeout_secs, + repo, + "soar_default_webhook_timeout_secs", + )?; + override_parsed( + &mut cfg.default_frequency_window_secs, + repo, + "soar_default_frequency_window_secs", + )?; + override_parsed( + &mut cfg.default_single_source_high_min_confidence, + repo, + "soar_default_single_source_high_min_confidence", + )?; + override_parsed( + &mut cfg.default_cooldown_expiry_secs, + repo, + "soar_default_cooldown_expiry_secs", + )?; + override_parsed( + &mut cfg.rate_limit_cmd_channel_capacity, + repo, + "soar_rate_limit_cmd_channel_capacity", + )?; + override_parsed( + &mut cfg.frequency_max_tracked_keys, + repo, + "soar_frequency_max_tracked_keys", + )?; + override_parsed(&mut cfg.fallback_cooldown_secs, repo, "soar_fallback_cooldown_secs")?; + Ok(cfg) + } + + pub fn seed_defaults(repo: &dyn SettingRepo) -> Result<(), Error> { + seed_key(repo, "soar_max_auto_block_cap", "100")?; + seed_key(repo, "soar_max_ttl_secs", "86400")?; + seed_key(repo, "soar_handle_concurrency", "16")?; + seed_key(repo, "soar_max_pending_unblock_retries", "5")?; + seed_key(repo, "soar_default_block_ttl_secs", "1800")?; + seed_key(repo, "soar_default_rate_limit_factor", "0.5")?; + seed_key(repo, "soar_default_rate_limit_ttl_secs", "600")?; + seed_key(repo, "soar_default_webhook_timeout_secs", "10")?; + seed_key(repo, "soar_default_frequency_window_secs", "60")?; + seed_key(repo, "soar_default_single_source_high_min_confidence", "0.95")?; + seed_key(repo, "soar_default_cooldown_expiry_secs", "3600")?; + seed_key(repo, "soar_rate_limit_cmd_channel_capacity", "64")?; + seed_key(repo, "soar_frequency_max_tracked_keys", "50000")?; + seed_key(repo, "soar_fallback_cooldown_secs", "300")?; + Ok(()) + } +} diff --git a/net-guardia/src/model/config/suricata.rs b/net-guardia/src/model/config/suricata.rs new file mode 100644 index 0000000..c41d1c0 --- /dev/null +++ b/net-guardia/src/model/config/suricata.rs @@ -0,0 +1,75 @@ +use super::helpers::{override_bool, override_parsed, override_string_nonempty, seed_key}; +use crate::interface::port::setting::SettingRepo; +use crate::model::error::Error; + +#[derive(Debug, Clone)] +pub struct SuricataConfig { + pub enabled: bool, + pub binary_path: String, + pub config_path: String, + pub eve_log_path: String, + pub auto_restart_on_crash: bool, + pub restart_backoff_secs: u64, + pub poll_interval_ms: u64, + pub file_wait_interval_secs: u64, + pub confidence_high: f32, + pub confidence_medium: f32, + pub confidence_low: f32, + pub confidence_info: f32, +} + +impl SuricataConfig { + pub fn defaults() -> Self { + Self { + enabled: false, + binary_path: "/usr/bin/suricata".to_string(), + config_path: "/etc/netguardia/suricata.yaml".to_string(), + eve_log_path: "/var/log/netguardia/eve.json".to_string(), + auto_restart_on_crash: true, + restart_backoff_secs: 10, + poll_interval_ms: 200, + file_wait_interval_secs: 1, + confidence_high: 0.95, + confidence_medium: 0.80, + confidence_low: 0.65, + confidence_info: 0.50, + } + } + + pub fn from_settings(repo: &dyn SettingRepo) -> Result { + let mut cfg = Self::defaults(); + override_bool(&mut cfg.enabled, repo, "suricata_enabled")?; + override_string_nonempty(&mut cfg.binary_path, repo, "suricata_binary_path")?; + override_string_nonempty(&mut cfg.config_path, repo, "suricata_config_path")?; + override_string_nonempty(&mut cfg.eve_log_path, repo, "suricata_eve_log_path")?; + override_bool(&mut cfg.auto_restart_on_crash, repo, "suricata_auto_restart_on_crash")?; + override_parsed(&mut cfg.restart_backoff_secs, repo, "suricata_restart_backoff_secs")?; + override_parsed(&mut cfg.poll_interval_ms, repo, "suricata_poll_interval_ms")?; + override_parsed( + &mut cfg.file_wait_interval_secs, + repo, + "suricata_file_wait_interval_secs", + )?; + override_parsed(&mut cfg.confidence_high, repo, "suricata_confidence_high")?; + override_parsed(&mut cfg.confidence_medium, repo, "suricata_confidence_medium")?; + override_parsed(&mut cfg.confidence_low, repo, "suricata_confidence_low")?; + override_parsed(&mut cfg.confidence_info, repo, "suricata_confidence_info")?; + Ok(cfg) + } + + pub fn seed_defaults(repo: &dyn SettingRepo) -> Result<(), Error> { + seed_key(repo, "suricata_enabled", "false")?; + seed_key(repo, "suricata_binary_path", "/usr/bin/suricata")?; + seed_key(repo, "suricata_config_path", "/etc/netguardia/suricata.yaml")?; + seed_key(repo, "suricata_eve_log_path", "/var/log/netguardia/eve.json")?; + seed_key(repo, "suricata_auto_restart_on_crash", "true")?; + seed_key(repo, "suricata_restart_backoff_secs", "10")?; + seed_key(repo, "suricata_poll_interval_ms", "200")?; + seed_key(repo, "suricata_file_wait_interval_secs", "1")?; + seed_key(repo, "suricata_confidence_high", "0.95")?; + seed_key(repo, "suricata_confidence_medium", "0.80")?; + seed_key(repo, "suricata_confidence_low", "0.65")?; + seed_key(repo, "suricata_confidence_info", "0.50")?; + Ok(()) + } +} diff --git a/net-guardia/src/model/config/system.rs b/net-guardia/src/model/config/system.rs new file mode 100644 index 0000000..4c88854 --- /dev/null +++ b/net-guardia/src/model/config/system.rs @@ -0,0 +1,34 @@ +use super::helpers::{override_string_nonempty, seed_key}; +use crate::interface::port::setting::SettingRepo; +use crate::model::error::Error; + +#[derive(Debug, Clone)] +pub struct SystemConfig { + pub database_path: String, + pub report_dir: String, + pub log_dir: String, +} + +impl SystemConfig { + pub fn defaults() -> Self { + Self { + database_path: "net-guardia.db".to_string(), + report_dir: "/var/lib/netguardia/reports".to_string(), + log_dir: "logs".to_string(), + } + } + + pub fn from_settings(repo: &dyn SettingRepo) -> Result { + let mut cfg = Self::defaults(); + override_string_nonempty(&mut cfg.database_path, repo, "database_path")?; + override_string_nonempty(&mut cfg.report_dir, repo, "report_dir")?; + override_string_nonempty(&mut cfg.log_dir, repo, "log_dir")?; + Ok(cfg) + } + + pub fn seed_defaults(repo: &dyn SettingRepo) -> Result<(), Error> { + seed_key(repo, "report_dir", "/var/lib/netguardia/reports")?; + seed_key(repo, "log_dir", "logs")?; + Ok(()) + } +} diff --git a/net-guardia/src/model/detection/drift.rs b/net-guardia/src/model/detection/drift.rs index 8a8ff3a..7cb9b53 100644 --- a/net-guardia/src/model/detection/drift.rs +++ b/net-guardia/src/model/detection/drift.rs @@ -1,4 +1,4 @@ -use crate::model::system::config::MLInferenceConfig; +use crate::model::detection::ml_inference_config::MLInferenceConfig; /// Baselines loaded from the inference config (scaler mean / std). /// If inference_config has no scaler data, drift detection is disabled. diff --git a/net-guardia/src/model/detection/ml_detection.rs b/net-guardia/src/model/detection/ml_detection.rs index fbebfae..074b8d1 100644 --- a/net-guardia/src/model/detection/ml_detection.rs +++ b/net-guardia/src/model/detection/ml_detection.rs @@ -12,9 +12,11 @@ pub type RunnableModel = SimplePlan, Graph, + pub ae_clip_params: HashMap, + pub ae_scaler_mean: Vec, + pub ae_scaler_std: Vec, + pub ae_post_clip_min: f64, + pub ae_post_clip_max: f64, + pub ae_threshold: f32, + pub classifier_feature_names: Vec, + pub attack_labels: HashMap, + pub anomaly_threshold: f32, + pub c2_threshold: f32, + #[serde(default = "default_class_min_confidence")] + pub class_min_confidence: f32, + #[serde(default = "default_alert_threshold_multiplier")] + pub alert_threshold_multiplier: f32, + pub model_type: String, + pub output_names: Vec, + pub ae_feature_weights: HashMap, +} + +fn default_class_min_confidence() -> f32 { + 0.4 +} + +fn default_alert_threshold_multiplier() -> f32 { + 1.2 +} + +impl MLInferenceConfig { + pub fn num_ae_features(&self) -> usize { + self.ae_feature_names.len() + } + + pub fn num_classifier_features(&self) -> usize { + self.classifier_feature_names.len() + } + + pub fn num_attack_types(&self) -> usize { + self.attack_labels.len() + } +} diff --git a/net-guardia/src/model/detection/mod.rs b/net-guardia/src/model/detection/mod.rs index f80bef1..69fe5a5 100644 --- a/net-guardia/src/model/detection/mod.rs +++ b/net-guardia/src/model/detection/mod.rs @@ -2,4 +2,5 @@ pub mod attack_type; pub mod drift; pub mod flow_features; pub mod ml_detection; +pub mod ml_inference_config; pub mod model_source; diff --git a/net-guardia/src/model/soar/playbook_data.rs b/net-guardia/src/model/soar/playbook_data.rs index 4fa5717..9a4dde9 100644 --- a/net-guardia/src/model/soar/playbook_data.rs +++ b/net-guardia/src/model/soar/playbook_data.rs @@ -1,5 +1,5 @@ /// Input for updating a playbook row (without actions/conditions). -pub struct UpdatePlaybookRow { +pub struct UpdatePlaybookInput { pub name: String, pub trigger_event: String, pub condition_threshold: Option, @@ -16,6 +16,27 @@ pub struct CreateConditionInput { pub value2: Option, } +impl CreateConditionInput { + pub fn new(condition_type: String, operator: Option, value: String, value2: Option) -> Self { + let operator = operator.unwrap_or_else(|| default_operator_for(&condition_type).to_string()); + Self { + condition_type, + operator, + value, + value2, + } + } +} + +fn default_operator_for(condition_type: &str) -> &'static str { + match condition_type { + "threshold" | "frequency" => ">=", + "source_country" | "ip_pattern" => "in", + "repeat_offender" => "==", + _ => ">=", + } +} + /// Input for creating a new playbook. pub struct CreatePlaybookInput { pub name: String, @@ -29,7 +50,7 @@ pub struct CreatePlaybookInput { } /// Persisted condition row for API responses. -pub struct ConditionData { +pub struct ConditionView { pub id: i64, pub condition_type: String, pub operator: String, @@ -38,7 +59,7 @@ pub struct ConditionData { } /// Flattened playbook representation for API responses. -pub struct PlaybookData { +pub struct PlaybookView { pub id: i64, pub name: String, pub enabled: bool, @@ -47,11 +68,11 @@ pub struct PlaybookData { pub condition_count: Option, pub condition_window_secs: Option, pub cooldown_secs: i64, - pub actions: Vec, - pub conditions: Vec, + pub actions: Vec, + pub conditions: Vec, } -pub struct ActionData { +pub struct ActionView { pub id: i64, pub action_order: i64, pub action_type: String, @@ -59,7 +80,7 @@ pub struct ActionData { } /// Execution record from soar_executions table. -pub struct ExecutionData { +pub struct ExecutionView { pub id: i64, pub playbook_id: i64, pub source_ip: Option, @@ -69,7 +90,7 @@ pub struct ExecutionData { } /// Active block record from soar_block_rules table. -pub struct ActiveBlockData { +pub struct ActiveBlockView { pub id: i64, pub source_ip: String, pub playbook_id: i64, diff --git a/net-guardia/src/model/system/config.rs b/net-guardia/src/model/system/config.rs deleted file mode 100644 index b95805b..0000000 --- a/net-guardia/src/model/system/config.rs +++ /dev/null @@ -1,185 +0,0 @@ -use std::collections::HashMap; - -use serde::{Deserialize, Serialize}; - -use crate::model::detection::ml_detection::ClipParams; - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct HttpConfig { - pub http_server_bind_port: u16, - #[serde(default = "default_jwt_expiry")] - pub jwt_expiry_hours: u64, - /// Explicit CORS allowed origins. Empty = allow RFC1918 private networks only. - #[serde(default)] - pub cors_allowed_origins: Vec, -} - -fn default_jwt_expiry() -> u64 { - 24 -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct NetworkConfig { - pub ingress_ifname: String, - pub egress_ifname: String, - pub combined_queue_count: u32, - pub channel_size: usize, - pub fill_queue_size: u32, - pub comp_queue_size: u32, - pub tx_queue_size: u32, - pub rx_queue_size: u32, - pub frame_size: u32, - pub frame_count: u32, - pub refresh_interval: u64, - #[serde(default = "default_packet_buffer_size")] - pub packet_buffer_size: usize, - #[serde(default = "default_buffer_pool_capacity")] - pub buffer_pool_capacity: usize, -} - -fn default_packet_buffer_size() -> usize { - 2048 -} -fn default_buffer_pool_capacity() -> usize { - 1024 -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct InferenceConfig { - pub deep_autoencoder_name: String, - pub classifier_name: String, - pub models_config_name: String, - pub max_concurrent_flows: usize, - pub min_packets_for_inference: usize, - pub inference_interval_secs: u64, - pub aggregator_window_secs: u64, - pub inference_batch_size: usize, - pub traffic_logging_mode: bool, - pub traffic_log_csv_path: String, - /// Rotation: close the current CSV when it reaches this many bytes - /// and open a fresh one. 500MB default — large enough that dropdown - /// analysis tools can eat a shard in one gulp, small enough that - /// a browser download finishes in reasonable time. - #[serde(default = "default_flow_trace_max_file_bytes")] - pub flow_trace_max_file_bytes: u64, - /// Rotation: also roll when the active file crosses this age in - /// seconds, so analysts always have bounded-age shards regardless - /// of traffic volume. 1h default. - #[serde(default = "default_flow_trace_max_file_age_secs")] - pub flow_trace_max_file_age_secs: u64, - /// FIFO budget: total bytes across every rotated shard in the - /// directory. When exceeded, oldest files are deleted until the - /// sum is back under budget. 10GB default keeps a few days of - /// recording on a typical office link. - #[serde(default = "default_flow_trace_total_budget_bytes")] - pub flow_trace_total_budget_bytes: u64, - /// Hard ceiling on the multipart `.onnx` stream. 100MB default fits - /// every shipped shape of netguardia's own model plus headroom for - /// medium BYO networks; very large models (modern transformers) - /// can raise this, at the cost of a wider DoS surface. - #[serde(default = "default_model_upload_max_onnx_bytes")] - pub model_upload_max_onnx_bytes: usize, - /// Hard ceiling on the multipart `manifest` YAML stream. 64KB - /// default is ~100× the largest realistic manifest. - #[serde(default = "default_model_upload_max_manifest_bytes")] - pub model_upload_max_manifest_bytes: usize, - /// Hard ceiling on the optional `scaler` JSON sidecar stream. - /// Shares the 64KB default with the manifest cap — sidecars are - /// numeric arrays whose size scales with feature count, so even a - /// generous feature set stays well under. - #[serde(default = "default_model_upload_max_scaler_bytes")] - pub model_upload_max_scaler_bytes: usize, -} - -fn default_flow_trace_max_file_bytes() -> u64 { - 500 * 1024 * 1024 -} -fn default_flow_trace_max_file_age_secs() -> u64 { - 3600 -} -fn default_flow_trace_total_budget_bytes() -> u64 { - 10 * 1024 * 1024 * 1024 -} -fn default_model_upload_max_onnx_bytes() -> usize { - 100 * 1024 * 1024 -} -fn default_model_upload_max_manifest_bytes() -> usize { - 64 * 1024 -} -fn default_model_upload_max_scaler_bytes() -> usize { - 64 * 1024 -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct MiscConfig { - pub geoip_db_name: String, - #[serde(default = "default_db_path")] - pub database_path: String, -} - -fn default_db_path() -> String { - "net-guardia.db".to_string() -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct PipelineConfig { - pub ingress: Vec, - pub egress: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct SuricataConfig { - pub enabled: bool, - pub binary_path: String, - pub config_path: String, - pub eve_log_path: String, - pub auto_restart_on_crash: bool, - pub restart_backoff_secs: u64, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MLInferenceConfig { - pub ae_feature_names: Vec, - pub ae_clip_params: HashMap, - pub ae_scaler_mean: Vec, - pub ae_scaler_std: Vec, - pub ae_post_clip_min: f64, - pub ae_post_clip_max: f64, - pub ae_threshold: f32, - pub classifier_feature_names: Vec, - pub attack_labels: HashMap, - pub anomaly_threshold: f32, - pub c2_threshold: f32, - #[serde(default = "default_class_min_confidence")] - pub class_min_confidence: f32, - /// Multiplier applied to the confidence threshold before the aggregator - /// fires an alert. The manifest can override this via - /// `thresholds.alert_multiplier`. - #[serde(default = "default_alert_threshold_multiplier")] - pub alert_threshold_multiplier: f32, - pub model_type: String, - pub output_names: Vec, - pub ae_feature_weights: HashMap, -} - -fn default_class_min_confidence() -> f32 { - 0.4 -} - -fn default_alert_threshold_multiplier() -> f32 { - 1.2 -} - -impl MLInferenceConfig { - pub fn num_ae_features(&self) -> usize { - self.ae_feature_names.len() - } - - pub fn num_classifier_features(&self) -> usize { - self.classifier_feature_names.len() - } - - pub fn num_attack_types(&self) -> usize { - self.attack_labels.len() - } -} diff --git a/net-guardia/src/model/system/mod.rs b/net-guardia/src/model/system/mod.rs index 6c0d46b..e1cbced 100644 --- a/net-guardia/src/model/system/mod.rs +++ b/net-guardia/src/model/system/mod.rs @@ -1,4 +1,3 @@ -pub mod config; pub mod health; pub mod rate_limit_settings; pub mod readiness; diff --git a/net-guardia/src/model/system/suricata.rs b/net-guardia/src/model/system/suricata.rs index 403ee9f..2ae9f54 100644 --- a/net-guardia/src/model/system/suricata.rs +++ b/net-guardia/src/model/system/suricata.rs @@ -20,7 +20,6 @@ pub enum SuricataHealth { } impl SuricataHealth { - #[allow(dead_code)] pub fn is_running(&self) -> bool { matches!(self, SuricataHealth::Running { .. }) } diff --git a/net-guardia/src/utils/logging.rs b/net-guardia/src/utils/logging.rs index 4cb5e61..89da243 100644 --- a/net-guardia/src/utils/logging.rs +++ b/net-guardia/src/utils/logging.rs @@ -30,7 +30,7 @@ static PRESERVED_DIRECTIVES: OnceLock> = OnceLock::new(); pub struct Logging; impl Logging { - pub fn initialize() -> Result<(), Error> { + pub fn initialize(log_buffer_capacity: usize, log_buffer_max_message_bytes: usize) -> Result<(), Error> { let log_directory = "logs"; fs::create_dir_all(log_directory).map_err(|err| IOError::CreateDirectoryFailed(log_directory, err))?; @@ -92,7 +92,7 @@ impl Logging { .with(filter_layer) .with(stdout_layer) .with(file_layer) - .with(LogBufferLayer::new()) + .with(LogBufferLayer::new(log_buffer_capacity, log_buffer_max_message_bytes)) .init(); // Store type-erased handle for runtime log level changes