mirror of
https://github.com/DaLaw2/NetGuardia.git
synced 2026-08-24 14:10:28 +09:00
chore: Remove/Fix AI trash
This commit is contained in:
parent
4bfd00d12a
commit
1282048144
@ -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 => {
|
||||
|
||||
@ -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<Self, Error> {
|
||||
pub fn new(ebpf: &mut Ebpf, app_config: Arc<ArcSwap<AppConfig>>) -> Result<Self, Error> {
|
||||
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<ArcSwap<AppConfig>>) -> 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 {
|
||||
|
||||
@ -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<AppConfig>, ingress_ebpf: &mut Ebpf, egress_ebpf: &mut Ebpf) -> Result<Self, Error> {
|
||||
pub fn new(
|
||||
app_config: Arc<ArcSwap<AppConfig>>,
|
||||
ingress_ebpf: &mut Ebpf,
|
||||
egress_ebpf: &mut Ebpf,
|
||||
) -> Result<Self, Error> {
|
||||
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<AppConfig>) -> Self {
|
||||
pub fn unavailable(app_config: Arc<ArcSwap<AppConfig>>) -> 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(),
|
||||
}
|
||||
|
||||
@ -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<AppConfig>,
|
||||
app_config: Arc<ArcSwap<AppConfig>>,
|
||||
xsk_map: Mutex<Option<XskMap<MapData>>>,
|
||||
egress_xsk_map: Mutex<Option<XskMap<MapData>>>,
|
||||
}
|
||||
|
||||
impl XskManager {
|
||||
pub fn new(app_config: Arc<AppConfig>, ingress_ebpf: &mut Ebpf, egress_ebpf: &mut Ebpf) -> Result<Self, Error> {
|
||||
pub fn new(
|
||||
app_config: Arc<ArcSwap<AppConfig>>,
|
||||
ingress_ebpf: &mut Ebpf,
|
||||
egress_ebpf: &mut Ebpf,
|
||||
) -> Result<Self, Error> {
|
||||
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<AppConfig>) -> Self {
|
||||
pub fn unavailable(app_config: Arc<ArcSwap<AppConfig>>) -> 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,
|
||||
|
||||
@ -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<AclService>,
|
||||
) -> 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<AclService>,
|
||||
) -> 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<AclService>,
|
||||
) -> 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<AclService>,
|
||||
) -> 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<AclService>) -> impl Responder {
|
||||
|
||||
@ -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<LoginRequest>, db: web::Data<Repo>, 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<RegisterRequest>, db: web::D
|
||||
}
|
||||
|
||||
async fn me(auth: AuthClaims, db: web::Data<Repo>) -> 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<String> = user_groups
|
||||
.iter()
|
||||
.map(|(_id, name, _desc, _perms)| name.clone())
|
||||
@ -190,7 +191,7 @@ async fn me(auth: AuthClaims, db: web::Data<Repo>) -> 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<Repo>) -> 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<serde_json::Value> = 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<i64>, db: web::Data<Repo>)
|
||||
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,
|
||||
|
||||
@ -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<T, E: fmt::Display>(result: Result<T, E>) -> 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())
|
||||
|
||||
@ -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<FusionMetrics>) -> 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<dyn AuditRepo>) -> impl Responder {
|
||||
async fn explain_ip(
|
||||
req: HttpRequest,
|
||||
audit: web::Data<dyn AuditRepo>,
|
||||
app_config: web::Data<Arc<ArcSwap<AppConfig>>>,
|
||||
) -> 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<dyn AuditRepo>) -> 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<dyn AuditRepo>) -> 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(),
|
||||
|
||||
@ -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<LiveQuery>) -> HttpResponse {
|
||||
async fn live_logs(query: web::Query<LiveQuery>, app_config: web::Data<Arc<ArcSwap<AppConfig>>>) -> 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<String>) -> HttpResponse {
|
||||
async fn download_log(path: web::Path<String>, app_config: web::Data<Arc<ArcSwap<AppConfig>>>) -> 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<String>) -> 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 => {
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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@<username>` so
|
||||
/// do not rename without coordinating audit-chain readers.
|
||||
pub const AUDIT_ACTOR_SECURITY_ADMIN_PREFIX: &str = "SecurityAdmin";
|
||||
|
||||
@ -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<AppConfig>,
|
||||
app_config: web::Data<ArcSwap<AppConfig>>,
|
||||
inference: web::Data<Inference>,
|
||||
comm: web::Data<CommunicationManager>,
|
||||
promote_lock: web::Data<PromoteGate>,
|
||||
@ -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<PromoteReport, PromoteError> {
|
||||
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
|
||||
|
||||
@ -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<NotificationService>) -> 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<NotificationService>,
|
||||
body: web::Json<TelegramConfigRequest>,
|
||||
) -> 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<NotificationService>) -> HttpResponse {
|
||||
|
||||
@ -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<RateLimitService>) -> impl Responder {
|
||||
}
|
||||
|
||||
async fn set_config(settings: web::Json<RateLimitSettings>, service: web::Data<RateLimitService>) -> 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()))
|
||||
}
|
||||
|
||||
@ -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<Database>) -> 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<Database>,
|
||||
config: web::Data<ArcSwap<AppConfig>>,
|
||||
) -> 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<Database>) -> HttpResp
|
||||
|
||||
async fn report_data(_auth: AuthClaims, db: web::Data<Database>) -> 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<Database>, secrets: web::Data<SecretStore>) -> HttpResponse {
|
||||
async fn send_report(
|
||||
_auth: AuthClaims,
|
||||
db: web::Data<Database>,
|
||||
config: web::Data<ArcSwap<AppConfig>>,
|
||||
secrets: web::Data<SecretStore>,
|
||||
) -> 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<Database>, 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,
|
||||
|
||||
18
net-guardia/src/adapter/http/response.rs
Normal file
18
net-guardia/src/adapter/http/response.rs
Normal file
@ -0,0 +1,18 @@
|
||||
use std::fmt;
|
||||
|
||||
use actix_web::HttpResponse;
|
||||
use serde::Serialize;
|
||||
|
||||
pub fn ok_or_error<T, E: fmt::Display>(result: Result<T, E>) -> 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<T: Serialize, E: fmt::Display>(result: Result<T, E>) -> HttpResponse {
|
||||
match result {
|
||||
Ok(value) => HttpResponse::Ok().json(value),
|
||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
|
||||
}
|
||||
}
|
||||
@ -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<PlaybookService>)
|
||||
}
|
||||
|
||||
async fn manual_unblock(_auth: AuthClaims, svc: web::Data<PlaybookService>, path: web::Path<i64>) -> 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<PlaybookService>) -> HttpResponse {
|
||||
@ -294,10 +280,7 @@ async fn list_executions(_auth: AuthClaims, svc: web::Data<PlaybookService>) ->
|
||||
}
|
||||
|
||||
async fn list_whitelist(_auth: AuthClaims, svc: web::Data<PlaybookService>) -> 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<PlaybookService>, path: web::Path<String>) -> 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
|
||||
|
||||
@ -37,7 +37,7 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load_acl_rules(&self) -> Result<Vec<AclRuleTuple>, Error> {
|
||||
pub fn list_acl_rules(&self) -> Result<Vec<AclRuleTuple>, 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<Vec<String>, Error> {
|
||||
pub fn list_admin_whitelist(&self) -> Result<Vec<String>, 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<Vec<String>, Error> {
|
||||
self.load_admin_whitelist()
|
||||
fn list_admin_whitelist(&self) -> Result<Vec<String>, 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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -106,7 +106,6 @@ impl Database {
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub fn list_api_keys(&self) -> Result<Vec<(i64, String, String, String, Option<String>)>, Error> {
|
||||
let conn = self.conn()?;
|
||||
let mut stmt = conn.prepare("SELECT id, name, permission_level, created_at, last_used_at FROM api_keys")?;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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<Option<String>, 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<Option<String>, 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<Option<String>, 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(""));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<Vec<(i64, i64, String, String, String, Option<String>)>, 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<Vec<PlaybookRow>, Error> {
|
||||
self.load_playbooks_with_actions()
|
||||
fn list_playbooks_with_actions(&self) -> Result<Vec<PlaybookRow>, Error> {
|
||||
self.list_playbooks_with_actions()
|
||||
}
|
||||
|
||||
fn update_playbook_enabled(&self, id: i64, enabled: bool) -> Result<bool, Error> {
|
||||
@ -255,24 +252,24 @@ impl SoarRepo for Database {
|
||||
self.seed_default_playbooks()
|
||||
}
|
||||
|
||||
fn load_all_playbook_conditions(&self) -> Result<Vec<(i64, i64, String, String, String, Option<String>)>, Error> {
|
||||
self.load_all_playbook_conditions()
|
||||
fn list_all_playbook_conditions(&self) -> Result<Vec<(i64, i64, String, String, String, Option<String>)>, Error> {
|
||||
self.list_all_playbook_conditions()
|
||||
}
|
||||
|
||||
fn count_active_soar_blocks(&self) -> Result<u32, Error> {
|
||||
self.count_active_soar_blocks()
|
||||
}
|
||||
|
||||
fn get_active_soar_blocks(&self) -> Result<Vec<(i64, String, i64, String)>, Error> {
|
||||
self.get_active_soar_blocks()
|
||||
fn list_active_soar_blocks(&self) -> Result<Vec<(i64, String, i64, String)>, Error> {
|
||||
self.list_active_soar_blocks()
|
||||
}
|
||||
|
||||
fn get_soar_block_by_id(&self, id: i64) -> Result<Option<(i64, String, i64, String)>, Error> {
|
||||
self.get_soar_block_by_id(id)
|
||||
fn find_soar_block_by_id(&self, id: i64) -> Result<Option<(i64, String, i64, String)>, Error> {
|
||||
self.find_soar_block_by_id(id)
|
||||
}
|
||||
|
||||
fn get_expired_soar_blocks(&self) -> Result<Vec<(i64, String, i64)>, Error> {
|
||||
self.get_expired_soar_blocks()
|
||||
fn list_expired_soar_blocks(&self) -> Result<Vec<(i64, String, i64)>, 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<Vec<(i64, String, i64)>, Error> {
|
||||
self.load_pending_unblocks()
|
||||
fn list_pending_unblocks(&self) -> Result<Vec<(i64, String, i64)>, 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<String>)],
|
||||
) -> Result<bool, Error> {
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -28,7 +28,7 @@ impl Database {
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
pub fn get_expired_soar_blocks(&self) -> Result<Vec<(i64, String, i64)>, Error> {
|
||||
pub fn list_expired_soar_blocks(&self) -> Result<Vec<(i64, String, i64)>, 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<Option<(i64, String, i64, String)>, Error> {
|
||||
pub fn find_soar_block_by_id(&self, id: i64) -> Result<Option<(i64, String, i64, String)>, 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<Vec<(i64, String, i64, String)>, Error> {
|
||||
pub fn list_active_soar_blocks(&self) -> Result<Vec<(i64, String, i64, String)>, 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<Vec<(i64, String, i64)>, Error> {
|
||||
pub fn list_pending_unblocks(&self) -> Result<Vec<(i64, String, i64)>, 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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -224,7 +224,7 @@ impl Database {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_user_groups(&self, user_id: i64) -> Result<Vec<(i64, String, String, String)>, Error> {
|
||||
pub fn list_groups_for_user(&self, user_id: i64) -> Result<Vec<(i64, String, String, String)>, 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<Vec<String>, Error> {
|
||||
let groups = self.get_user_groups(user_id)?;
|
||||
pub fn list_user_permissions(&self, user_id: i64) -> Result<Vec<String>, 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::<Vec<String>>(&perms_json) {
|
||||
@ -279,7 +279,7 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_group_member_ids(&self, group_id: i64) -> Result<Vec<i64>, Error> {
|
||||
pub fn list_group_member_ids(&self, group_id: i64) -> Result<Vec<i64>, 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<Vec<(i64, String)>, Error> {
|
||||
pub fn list_group_members(&self, group_id: i64) -> Result<Vec<(i64, String)>, 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<Vec<(i64, String, String, String)>, Error> {
|
||||
self.get_user_groups(user_id)
|
||||
fn list_groups_for_user(&self, user_id: i64) -> Result<Vec<(i64, String, String, String)>, 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<Vec<String>, Error> {
|
||||
self.get_user_permissions(user_id)
|
||||
fn list_user_permissions(&self, user_id: i64) -> Result<Vec<String>, Error> {
|
||||
self.list_user_permissions(user_id)
|
||||
}
|
||||
|
||||
fn get_group_member_ids(&self, group_id: i64) -> Result<Vec<i64>, Error> {
|
||||
self.get_group_member_ids(group_id)
|
||||
fn list_group_member_ids(&self, group_id: i64) -> Result<Vec<i64>, Error> {
|
||||
self.list_group_member_ids(group_id)
|
||||
}
|
||||
|
||||
fn get_group_members(&self, group_id: i64) -> Result<Vec<(i64, String)>, Error> {
|
||||
self.get_group_members(group_id)
|
||||
fn list_group_members(&self, group_id: i64) -> Result<Vec<(i64, String)>, Error> {
|
||||
self.list_group_members(group_id)
|
||||
}
|
||||
|
||||
fn record_login_failure(&self, username: &str) -> Result<(u32, Option<u64>), Error> {
|
||||
|
||||
@ -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<dyn SettingRepo>,
|
||||
repo: Arc<dyn AppRepo>,
|
||||
notif: Arc<dyn SettingRepo + Send + Sync>,
|
||||
config: Arc<ArcSwap<AppConfig>>,
|
||||
secrets: Option<Arc<dyn SecretStorePort>>,
|
||||
/// 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<dyn SettingRepo>,
|
||||
repo: Arc<dyn AppRepo>,
|
||||
notif: Arc<dyn SettingRepo + Send + Sync>,
|
||||
config: Arc<ArcSwap<AppConfig>>,
|
||||
secrets: Option<Arc<dyn SecretStorePort>>,
|
||||
) -> Result<Self, Error> {
|
||||
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<dyn SettingRepo>,
|
||||
repo: Arc<dyn AppRepo>,
|
||||
notif: Arc<dyn SettingRepo + Send + Sync>,
|
||||
config: Arc<ArcSwap<AppConfig>>,
|
||||
secrets: Option<Arc<dyn SecretStorePort>>,
|
||||
}
|
||||
|
||||
impl TelegramAdapterFactory {
|
||||
pub fn new(notif: Arc<dyn SettingRepo>, repo: Arc<dyn AppRepo>, secrets: Option<Arc<dyn SecretStorePort>>) -> Self {
|
||||
Self { notif, repo, secrets }
|
||||
pub fn new(
|
||||
notif: Arc<dyn SettingRepo + Send + Sync>,
|
||||
config: Arc<ArcSwap<AppConfig>>,
|
||||
secrets: Option<Arc<dyn SecretStorePort>>,
|
||||
) -> Self {
|
||||
Self { notif, config, secrets }
|
||||
}
|
||||
}
|
||||
|
||||
impl AlertNotifierFactory for TelegramAdapterFactory {
|
||||
fn create(&self) -> Result<Arc<dyn AlertNotifier>, 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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<dyn AppRepo>,
|
||||
secrets: Option<Arc<dyn SecretStorePort>>,
|
||||
app_config: Arc<ArcSwap<AppConfig>>,
|
||||
}
|
||||
|
||||
impl ConfigService {
|
||||
pub fn new(db: Arc<dyn AppRepo>) -> Self {
|
||||
Self { db, secrets: None }
|
||||
pub fn new(db: Arc<dyn AppRepo>, app_config: Arc<ArcSwap<AppConfig>>) -> Self {
|
||||
Self {
|
||||
db,
|
||||
secrets: None,
|
||||
app_config,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_secret_store(mut self, secrets: Arc<dyn SecretStorePort>) -> 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<Vec<String>, Error> {
|
||||
let mut updated = Vec::new();
|
||||
let mut updated: Vec<String> = Vec::new();
|
||||
let mut new_cfg: Option<AppConfig> = 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<String> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<String>,
|
||||
window_start: Instant,
|
||||
@ -31,15 +23,19 @@ pub struct BotnetDetector {
|
||||
/// dst_ip → set of unique src_ips within the time window
|
||||
state: DashMap<String, TimedSourceSet>,
|
||||
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<String> = 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);
|
||||
|
||||
|
||||
@ -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<AlertMessage>,
|
||||
detection_tx: mpsc::Sender<DetectionEvent>,
|
||||
cleanup_interval_secs: u64,
|
||||
}
|
||||
|
||||
impl CorrelationEngine {
|
||||
pub fn new(alert_rx: broadcast::Receiver<AlertMessage>, detection_tx: mpsc::Sender<DetectionEvent>) -> Self {
|
||||
pub fn new(
|
||||
app_config: &Arc<ArcSwap<AppConfig>>,
|
||||
alert_rx: broadcast::Receiver<AlertMessage>,
|
||||
detection_tx: mpsc::Sender<DetectionEvent>,
|
||||
) -> 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! {
|
||||
|
||||
@ -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<String>,
|
||||
window_start: Instant,
|
||||
@ -29,15 +21,19 @@ pub struct LateralMovementDetector {
|
||||
/// src_ip → set of unique internal dst_ips within the time window
|
||||
state: DashMap<String, TimedDestSet>,
|
||||
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<String> = 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
|
||||
|
||||
@ -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<u16>,
|
||||
window_start: Instant,
|
||||
@ -29,15 +21,19 @@ pub struct ScanDetector {
|
||||
/// src_ip → set of unique dst_ports within the time window
|
||||
state: DashMap<String, TimedPortSet>,
|
||||
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<String> = 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 {
|
||||
|
||||
@ -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<FlowTuple, CachedFlow>,
|
||||
detection_tx: mpsc::Sender<DetectionEvent>,
|
||||
alert_rx: broadcast::Receiver<AlertMessage>,
|
||||
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<AlertMessage>, detection_tx: mpsc::Sender<DetectionEvent>) -> Self {
|
||||
pub fn new(
|
||||
app_config: &Arc<ArcSwap<AppConfig>>,
|
||||
alert_rx: broadcast::Receiver<AlertMessage>,
|
||||
detection_tx: mpsc::Sender<DetectionEvent>,
|
||||
) -> 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<FlowTuple> = 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<ArcSwap<AppConfig>> {
|
||||
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();
|
||||
|
||||
@ -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<usize> = 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<DetectionEvent>,
|
||||
comm: Arc<CommunicationManager>,
|
||||
geoip: Option<Arc<GeoIpService>>,
|
||||
geoip: Option<Arc<dyn GeoLookup>>,
|
||||
metrics: Arc<FusionMetrics>,
|
||||
// Enrichment state
|
||||
src_ip_counts: lru::LruCache<String, u32>,
|
||||
repeat_tracker: lru::LruCache<String, Instant>,
|
||||
src_ip_counts: LruCache<String, u32>,
|
||||
repeat_tracker: LruCache<String, Instant>,
|
||||
// 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<ArcSwap<AppConfig>>,
|
||||
rx: mpsc::Receiver<DetectionEvent>,
|
||||
comm: Arc<CommunicationManager>,
|
||||
geoip: Option<Arc<GeoIpService>>,
|
||||
geoip: Option<Arc<dyn GeoLookup>>,
|
||||
metrics: Arc<FusionMetrics>,
|
||||
) -> 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::<usize>::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
|
||||
}
|
||||
|
||||
@ -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<dyn AppRepo>,
|
||||
dns_filter: Arc<dyn DnsFilterPort>,
|
||||
config: Arc<ArcSwap<AppConfig>>,
|
||||
}
|
||||
|
||||
impl DnsFilterService {
|
||||
pub fn new(db: Arc<dyn AppRepo>, dns_filter: Arc<dyn DnsFilterPort>) -> Self {
|
||||
Self { db, dns_filter }
|
||||
pub fn new(db: Arc<dyn AppRepo>, dns_filter: Arc<dyn DnsFilterPort>, config: Arc<ArcSwap<AppConfig>>) -> Self {
|
||||
Self { db, dns_filter, config }
|
||||
}
|
||||
|
||||
pub fn list_domains(&self) -> Vec<String> {
|
||||
@ -22,13 +26,7 @@ impl DnsFilterService {
|
||||
}
|
||||
|
||||
pub fn add_domains(&self, domains: &[String]) -> Result<usize, Error> {
|
||||
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 {})",
|
||||
|
||||
@ -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<Option<Self>, 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<Option<Self>, 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<Option<Self>, 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<Option<String>, 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<dyn SettingRepo>,
|
||||
db: Arc<dyn SettingRepo + Send + Sync>,
|
||||
config: Arc<ArcSwap<AppConfig>>,
|
||||
secrets: Option<Arc<dyn SecretStorePort>>,
|
||||
}
|
||||
|
||||
impl ReportScheduler {
|
||||
pub fn new(db: Arc<dyn SettingRepo>, secrets: Option<Arc<dyn SecretStorePort>>) -> Self {
|
||||
Self { db, secrets }
|
||||
pub fn new(
|
||||
db: Arc<dyn SettingRepo + Send + Sync>,
|
||||
config: Arc<ArcSwap<AppConfig>>,
|
||||
secrets: Option<Arc<dyn SecretStorePort>>,
|
||||
) -> 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,
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<Self, MLError> {
|
||||
|
||||
@ -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<f64>)>,
|
||||
/// Number of features expected per snapshot.
|
||||
num_features: usize,
|
||||
@ -24,18 +16,20 @@ pub struct DriftDetector {
|
||||
baselines: Option<FeatureBaselines>,
|
||||
/// 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<FeatureBaselines>, drift_window: Duration) -> Self {
|
||||
/// Create a new detector with a configurable drift window and snapshot cap.
|
||||
pub fn new(baselines: Option<FeatureBaselines>, 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<FeatureBaselines>, drift_window: Duration) -> Self {
|
||||
let (tx, mut rx) = mpsc::channel::<DriftCmd>(DRIFT_CMD_CHANNEL_CAPACITY);
|
||||
pub fn spawn(
|
||||
baselines: Option<FeatureBaselines>,
|
||||
drift_window: Duration,
|
||||
max_snapshots: usize,
|
||||
channel_capacity: usize,
|
||||
) -> Self {
|
||||
let (tx, mut rx) = mpsc::channel::<DriftCmd>(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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<MLAlert>,
|
||||
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<MLAlert>,
|
||||
drift_detector: DriftDetectorHandle,
|
||||
engine_config: EngineConfig,
|
||||
flow_limits: FlowLimits,
|
||||
traffic_logger: Option<Arc<TrafficLogger>>,
|
||||
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<ThreadTracker> = (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]
|
||||
|
||||
@ -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<Mutex<FlowData>>;
|
||||
/// policy would mishandle.
|
||||
pub struct FlowTracker {
|
||||
active: Cache<FlowKey, FlowEntry>,
|
||||
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);
|
||||
|
||||
@ -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<f32>, Vec<Vec<f32>>, Vec<f32>);
|
||||
|
||||
/// 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<ModelSourceState>,
|
||||
pub config: Arc<MLInferenceConfig>,
|
||||
app_config: Arc<ArcSwap<AppConfig>>,
|
||||
/// 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<MLInferenceConfig>) -> Self {
|
||||
pub fn new(
|
||||
initial_state: ModelSourceState,
|
||||
config: Arc<MLInferenceConfig>,
|
||||
app_config: Arc<ArcSwap<AppConfig>>,
|
||||
) -> 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<MLModelAdapter, MLError> {
|
||||
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<String, LabelSpec>, target: &str) -> Optio
|
||||
.and_then(|(k, _)| k.parse::<usize>().ok())
|
||||
}
|
||||
|
||||
fn loader(model_path: &Path, model_name: &str, features: usize, batch_size: usize) -> Result<RunnableModel, MLError> {
|
||||
fn loader(
|
||||
model_path: &Path,
|
||||
model_name: &str,
|
||||
features: usize,
|
||||
batch_size: usize,
|
||||
onnx_load_timeout: Duration,
|
||||
) -> Result<RunnableModel, MLError> {
|
||||
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);
|
||||
|
||||
@ -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<Inference>,
|
||||
app_config: Arc<AppConfig>,
|
||||
config: Arc<ArcSwap<AppConfig>>,
|
||||
}
|
||||
|
||||
impl ModelWatcher {
|
||||
pub fn new(inference: Arc<Inference>, app_config: Arc<AppConfig>) -> Self {
|
||||
Self { inference, app_config }
|
||||
pub fn new(inference: Arc<Inference>, config: Arc<ArcSwap<AppConfig>>) -> 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<AppConfig>) {
|
||||
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()));
|
||||
}
|
||||
|
||||
@ -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<String>,
|
||||
policy: RotationPolicy,
|
||||
channel_capacity: usize,
|
||||
comm: Option<Arc<CommunicationManager>>,
|
||||
) -> Result<Self, io::Error> {
|
||||
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::<Vec<String>>(CHANNEL_CAPACITY);
|
||||
let (sender, receiver) = bounded::<Vec<String>>(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::<AuditEvent>();
|
||||
let mut rx = comm.subscribe_event::<AuditEvent>().unwrap();
|
||||
|
||||
|
||||
@ -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<dyn SettingRepo>,
|
||||
repo: Arc<dyn AppRepo>,
|
||||
notif: Arc<dyn SettingRepo + Send + Sync>,
|
||||
config: Arc<ArcSwap<AppConfig>>,
|
||||
secrets: Arc<dyn SecretStorePort>,
|
||||
alert_notifier_factory: Arc<dyn AlertNotifierFactory>,
|
||||
}
|
||||
|
||||
impl NotificationService {
|
||||
pub fn new(
|
||||
notif: Arc<dyn SettingRepo>,
|
||||
repo: Arc<dyn AppRepo>,
|
||||
notif: Arc<dyn SettingRepo + Send + Sync>,
|
||||
config: Arc<ArcSwap<AppConfig>>,
|
||||
secrets: Arc<dyn SecretStorePort>,
|
||||
alert_notifier_factory: Arc<dyn AlertNotifierFactory>,
|
||||
) -> 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<String, Error> {
|
||||
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,
|
||||
|
||||
@ -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<VecDeque<LogEntry>>,
|
||||
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<S: Subscriber> Layer<S> for LogBufferLayer {
|
||||
fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
|
||||
let Some(buf) = BUFFER.get() else {
|
||||
@ -160,8 +149,8 @@ impl<S: Subscriber> Layer<S> 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"));
|
||||
}
|
||||
|
||||
@ -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<Vec<PlaybookData>, Error> {
|
||||
let rows = self.db.load_playbooks_with_actions()?;
|
||||
let mut result: Vec<PlaybookData> = Vec::new();
|
||||
pub fn list_playbooks(&self) -> Result<Vec<PlaybookView>, Error> {
|
||||
let rows = self.db.list_playbooks_with_actions()?;
|
||||
let mut result: Vec<PlaybookView> = 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<i64, Vec<ConditionData>> = HashMap::new();
|
||||
let cond_rows = self.db.list_all_playbook_conditions()?;
|
||||
let mut cond_map: HashMap<i64, Vec<ConditionView>> = 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<bool, Error> {
|
||||
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<Vec<ActiveBlockData>, Error> {
|
||||
let blocks = self.db.get_active_soar_blocks()?;
|
||||
pub fn list_active_blocks(&self) -> Result<Vec<ActiveBlockView>, 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<Vec<ExecutionData>, Error> {
|
||||
pub fn list_executions(&self, limit: i64) -> Result<Vec<ExecutionView>, 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<Vec<String>, Error> {
|
||||
self.db.load_admin_whitelist()
|
||||
self.db.list_admin_whitelist()
|
||||
}
|
||||
|
||||
pub fn add_whitelist(&self, ip: &str) -> Result<(), Error> {
|
||||
|
||||
@ -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<String, Error> {
|
||||
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::<IpAddr>() {
|
||||
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<String, Error> {
|
||||
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<dyn AppRepo>) -> 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<dyn AppRepo>) -> 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<dyn AppRepo>,
|
||||
source_ip: String,
|
||||
|
||||
@ -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<Arc<dyn AlertNotifier>>,
|
||||
/// Optional GeoIP service for country lookups.
|
||||
pub(super) geoip: Option<Arc<GeoIpService>>,
|
||||
pub(super) geoip: Option<Arc<dyn GeoLookup>>,
|
||||
/// 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<AtomicU8>,
|
||||
/// Secret store for decrypting SMTP passwords etc.
|
||||
pub(super) secrets: Option<Arc<dyn SecretStorePort>>,
|
||||
pub(super) config: Arc<ArcSwap<AppConfig>>,
|
||||
}
|
||||
|
||||
impl SoarEngine {
|
||||
pub fn new(
|
||||
db: Arc<dyn AppRepo>,
|
||||
config: Arc<ArcSwap<AppConfig>>,
|
||||
access_control: Arc<dyn AccessControlPort>,
|
||||
alert_notifier: Option<Arc<dyn AlertNotifier>>,
|
||||
geoip: Option<Arc<GeoIpService>>,
|
||||
geoip: Option<Arc<dyn GeoLookup>>,
|
||||
rate_limit: Option<Arc<dyn RateLimitPort>>,
|
||||
enforce_level_cache: Arc<AtomicU8>,
|
||||
secrets: Option<Arc<dyn SecretStorePort>>,
|
||||
) -> Result<Self, Error> {
|
||||
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<Playbook> = 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::<ConditionType>()
|
||||
&& 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<String> = 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<Self>, mut rx: broadcast::Receiver<ThreatDetectedEvent>) {
|
||||
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<dyn AppRepo>, 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<dyn AppRepo>, 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<dyn AppRepo>, 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<dyn AppRepo>,
|
||||
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<dyn AppRepo>, 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<dyn AppRepo>,
|
||||
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<dyn AppRepo>, 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<dyn AppRepo>,
|
||||
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<dyn AppRepo>, 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<dyn AppRepo>,
|
||||
config,
|
||||
mock,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
cache,
|
||||
None,
|
||||
)
|
||||
.expect("Failed to create engine");
|
||||
|
||||
// Should have loaded default playbooks
|
||||
let count = engine.playbooks.load().len();
|
||||
|
||||
@ -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<FreqKey, VecDeque<Instant>>,
|
||||
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<FreqKey> = self.events.iter().take(excess).map(|e| e.key().clone()).collect();
|
||||
for key in keys_to_remove {
|
||||
self.events.remove(&key);
|
||||
|
||||
@ -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<Playbook>` so the per-event hot path bumps a refcount
|
||||
@ -163,7 +152,7 @@ impl SoarEngine {
|
||||
.value2
|
||||
.as_ref()
|
||||
.and_then(|s| s.parse::<u64>().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::<f32>().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<DryRunMatch> {
|
||||
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<DryRunConditionResult> = 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<String>| 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::<f32>().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,
|
||||
|
||||
@ -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<dyn AppRepo>, rate_limit: Arc<dyn RateLimitPort>) -> Self {
|
||||
let (tx, mut rx) = mpsc::channel::<RateLimitCmd>(RATE_LIMIT_CMD_CHANNEL_CAPACITY);
|
||||
pub fn spawn(db: Arc<dyn AppRepo>, rate_limit: Arc<dyn RateLimitPort>, channel_capacity: usize) -> Self {
|
||||
let (tx, mut rx) = mpsc::channel::<RateLimitCmd>(channel_capacity.max(1));
|
||||
tokio::spawn(async move {
|
||||
while let Some(cmd) = rx.recv().await {
|
||||
match cmd {
|
||||
|
||||
@ -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(());
|
||||
|
||||
@ -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<dyn StatsRepo>,
|
||||
repo: Arc<dyn SettingRepo>,
|
||||
repo: Arc<dyn SettingRepo + Send + Sync>,
|
||||
}
|
||||
|
||||
impl StatsAggregator {
|
||||
pub fn new(stats: Arc<dyn StatsRepo>, repo: Arc<dyn SettingRepo>) -> Self {
|
||||
pub fn new(stats: Arc<dyn StatsRepo>, repo: Arc<dyn SettingRepo + Send + Sync>) -> 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<dyn StatsRepo>, db.clone() as Arc<dyn SettingRepo>);
|
||||
let aggregator = StatsAggregator::new(
|
||||
db.clone() as Arc<dyn StatsRepo>,
|
||||
db.clone() as Arc<dyn SettingRepo + Send + Sync>,
|
||||
);
|
||||
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<dyn StatsRepo>, db.clone() as Arc<dyn SettingRepo>);
|
||||
let aggregator = StatsAggregator::new(
|
||||
db.clone() as Arc<dyn StatsRepo>,
|
||||
db.clone() as Arc<dyn SettingRepo + Send + Sync>,
|
||||
);
|
||||
aggregator
|
||||
.aggregate()
|
||||
.expect("aggregation should succeed with empty data");
|
||||
|
||||
@ -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<Self, Error> {
|
||||
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::<u16>()
|
||||
{
|
||||
config.http.http_server_bind_port = port;
|
||||
}
|
||||
if let Ok(Some(v)) = db.get_setting("jwt_expiry_hours")
|
||||
&& let Ok(hours) = v.parse::<u64>()
|
||||
{
|
||||
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::<u32>()
|
||||
{
|
||||
config.network.combined_queue_count = n;
|
||||
}
|
||||
if let Ok(Some(v)) = db.get_setting("fill_queue_size")
|
||||
&& let Ok(n) = v.parse::<u32>()
|
||||
{
|
||||
config.network.fill_queue_size = n;
|
||||
}
|
||||
if let Ok(Some(v)) = db.get_setting("comp_queue_size")
|
||||
&& let Ok(n) = v.parse::<u32>()
|
||||
{
|
||||
config.network.comp_queue_size = n;
|
||||
}
|
||||
if let Ok(Some(v)) = db.get_setting("tx_queue_size")
|
||||
&& let Ok(n) = v.parse::<u32>()
|
||||
{
|
||||
config.network.tx_queue_size = n;
|
||||
}
|
||||
if let Ok(Some(v)) = db.get_setting("rx_queue_size")
|
||||
&& let Ok(n) = v.parse::<u32>()
|
||||
{
|
||||
config.network.rx_queue_size = n;
|
||||
}
|
||||
if let Ok(Some(v)) = db.get_setting("frame_size")
|
||||
&& let Ok(n) = v.parse::<u32>()
|
||||
{
|
||||
config.network.frame_size = n;
|
||||
}
|
||||
if let Ok(Some(v)) = db.get_setting("frame_count")
|
||||
&& let Ok(n) = v.parse::<u32>()
|
||||
{
|
||||
config.network.frame_count = n;
|
||||
}
|
||||
|
||||
// Inference tuning
|
||||
if let Ok(Some(v)) = db.get_setting("max_concurrent_flows")
|
||||
&& let Ok(n) = v.parse::<usize>()
|
||||
{
|
||||
config.inference.max_concurrent_flows = n;
|
||||
}
|
||||
if let Ok(Some(v)) = db.get_setting("min_packets_for_inference")
|
||||
&& let Ok(n) = v.parse::<usize>()
|
||||
{
|
||||
config.inference.min_packets_for_inference = n;
|
||||
}
|
||||
if let Ok(Some(v)) = db.get_setting("inference_interval_secs")
|
||||
&& let Ok(n) = v.parse::<u64>()
|
||||
{
|
||||
config.inference.inference_interval_secs = n;
|
||||
}
|
||||
if let Ok(Some(v)) = db.get_setting("aggregator_window_secs")
|
||||
&& let Ok(n) = v.parse::<u64>()
|
||||
{
|
||||
config.inference.aggregator_window_secs = n;
|
||||
}
|
||||
if let Ok(Some(v)) = db.get_setting("inference_batch_size")
|
||||
&& let Ok(n) = v.parse::<usize>()
|
||||
{
|
||||
config.inference.inference_batch_size = n;
|
||||
}
|
||||
if let Ok(Some(v)) = db.get_setting("flow_trace_max_file_bytes")
|
||||
&& let Ok(n) = v.parse::<u64>()
|
||||
{
|
||||
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::<u64>()
|
||||
{
|
||||
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::<u64>()
|
||||
{
|
||||
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::<usize>()
|
||||
{
|
||||
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::<usize>()
|
||||
{
|
||||
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::<usize>()
|
||||
{
|
||||
config.inference.model_upload_max_scaler_bytes = n;
|
||||
}
|
||||
if let Ok(Some(v)) = db.get_setting("refresh_interval")
|
||||
&& let Ok(n) = v.parse::<u64>()
|
||||
{
|
||||
config.network.refresh_interval = n;
|
||||
}
|
||||
if let Ok(Some(v)) = db.get_setting("channel_size")
|
||||
&& let Ok(n) = v.parse::<usize>()
|
||||
{
|
||||
config.network.channel_size = n;
|
||||
}
|
||||
if let Ok(Some(v)) = db.get_setting("packet_buffer_size")
|
||||
&& let Ok(n) = v.parse::<usize>()
|
||||
{
|
||||
config.network.packet_buffer_size = n;
|
||||
}
|
||||
if let Ok(Some(v)) = db.get_setting("buffer_pool_capacity")
|
||||
&& let Ok(n) = v.parse::<usize>()
|
||||
{
|
||||
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::<u64>()
|
||||
{
|
||||
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"]);
|
||||
}
|
||||
}
|
||||
@ -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<AppConfig>,
|
||||
app_config: Arc<ArcSwap<AppConfig>>,
|
||||
inference_config: Arc<MLInferenceConfig>,
|
||||
ml_manifest: Option<ModelManifest>,
|
||||
drift_detector: DriftDetectorHandle,
|
||||
@ -56,12 +57,20 @@ impl AppServices {
|
||||
) -> Result<Self, Error> {
|
||||
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()));
|
||||
|
||||
@ -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::<TestCommand>(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::<TestQuery>(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::<TestEvent>();
|
||||
|
||||
let mut receiver = comm.subscribe_event::<TestEvent>().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::<TestEvent>();
|
||||
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::<TestEvent>();
|
||||
let mut rx = comm.subscribe_event::<TestEvent>().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::<TestEvent>();
|
||||
|
||||
let mut rx1 = comm.subscribe_event::<TestEvent>().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::<TestCommand>().build();
|
||||
|
||||
comm.send_command(TestCommand {
|
||||
|
||||
@ -84,7 +84,7 @@ mod tests {
|
||||
fn test_handler() -> (Arc<EnforceModeHandler>, Arc<CommunicationManager>) {
|
||||
let db = Arc::new(Database::new(":memory:").unwrap()) as Arc<dyn AppRepo>;
|
||||
let cache = Arc::new(AtomicU8::new(0));
|
||||
let comm = Arc::new(CommunicationManager::new());
|
||||
let comm = Arc::new(CommunicationManager::new(256));
|
||||
comm.register_event_type::<AuditEvent>();
|
||||
let handler = Arc::new(EnforceModeHandler::new(db, comm.clone(), cache));
|
||||
let _ = comm
|
||||
|
||||
@ -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<Option<GeoLocation>, 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<Vec<u8>>, ip: IpAddr) -> Result<Option<GeoLocation>, MaxMindDbError> {
|
||||
let lookup_result = reader.lookup(ip)?;
|
||||
let city_option: Option<geoip2::City> = lookup_result.decode()?;
|
||||
@ -85,3 +58,32 @@ impl GeoIpService {
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GeoLookup for GeoIpService {
|
||||
async fn lookup(&self, ip: IpAddr) -> Option<GeoLocation> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<AppConfig>, ebpf_health: Arc<ArcSwap<EbpfHealth>>) -> Result<Self, Error> {
|
||||
pub fn new(config: Arc<ArcSwap<AppConfig>>, ebpf_health: Arc<ArcSwap<EbpfHealth>>) -> Result<Self, Error> {
|
||||
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
|
||||
|
||||
@ -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<AtomicBool>;
|
||||
|
||||
/// Parameters for starting the HTTP server, avoiding `#[cfg]` on function params.
|
||||
pub struct HttpServerParams {
|
||||
pub app_config: Arc<AppConfig>,
|
||||
pub app_config: Arc<ArcSwap<AppConfig>>,
|
||||
pub inference_config: Arc<MLInferenceConfig>,
|
||||
pub ebpf_services: Arc<EbpfServices>,
|
||||
pub app_services: Arc<AppServices>,
|
||||
@ -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()))
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
pub mod app_config;
|
||||
pub mod app_services;
|
||||
pub mod audit_logger;
|
||||
pub mod cli;
|
||||
|
||||
@ -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<String, Error> {
|
||||
self.encrypt(plaintext)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@ -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<AppConfig>,
|
||||
pub app_config: Arc<ArcSwap<AppConfig>>,
|
||||
pub inference_config: Arc<MLInferenceConfig>,
|
||||
pub ebpf_services: Arc<EbpfServices>,
|
||||
pub app_services: Arc<AppServices>,
|
||||
@ -82,7 +83,7 @@ pub struct AppState {
|
||||
pub notification_service: Arc<NotificationService>,
|
||||
pub playbook_service: Arc<PlaybookService>,
|
||||
pub rate_limit_service: Arc<RateLimitService>,
|
||||
pub geoip: Option<Arc<GeoIpService>>,
|
||||
pub geoip: Option<Arc<dyn GeoLookup>>,
|
||||
pub drift_detector: DriftDetectorHandle,
|
||||
pub ingress_ebpf: Option<Ebpf>,
|
||||
pub egress_ebpf: Option<Ebpf>,
|
||||
@ -114,8 +115,8 @@ impl ServiceFactory {
|
||||
/// Only called when setup is complete — all config values are in DB.
|
||||
pub async fn build(db: Arc<Database>) -> Result<AppState, Error> {
|
||||
// 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<dyn SecretStorePort> = 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::<ThreatDetectedEvent>();
|
||||
comm.register_event_type::<DriftDetectedEvent>();
|
||||
comm.register_event_type::<AuditEvent>();
|
||||
@ -233,8 +243,8 @@ impl ServiceFactory {
|
||||
|
||||
// Create TelegramAdapter as alert notifier (may fail if not configured yet)
|
||||
let alert_notifier: Option<Arc<dyn AlertNotifier>> = match TelegramAdapter::new(
|
||||
db.clone() as Arc<dyn SettingRepo>,
|
||||
db.clone() as Arc<dyn AppRepo>,
|
||||
db.clone() as Arc<dyn SettingRepo + Send + Sync>,
|
||||
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<Arc<GeoIpService>> = match GeoIpService::new(&app_config.misc.geoip_db_name) {
|
||||
let geoip: Option<Arc<dyn GeoLookup>> = 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<dyn RateLimitPort> = 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<dyn SettingRepo>, Some(secret_store_port.clone()));
|
||||
let report_scheduler = ReportScheduler::new(
|
||||
db.clone() as Arc<dyn SettingRepo + Send + Sync>,
|
||||
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<dyn AppRepo>, dns_filter_port));
|
||||
let dns_filter_service = Arc::new(DnsFilterService::new(
|
||||
db.clone() as Arc<dyn AppRepo>,
|
||||
dns_filter_port,
|
||||
app_config.clone(),
|
||||
));
|
||||
let rate_limit_service = Arc::new(RateLimitService::new(db.clone() as Arc<dyn AppRepo>, 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<dyn AppRepo>).with_secret_store(secret_store_port.clone()));
|
||||
let config_service = Arc::new(
|
||||
ConfigService::new(db.clone() as Arc<dyn AppRepo>, app_config.clone())
|
||||
.with_secret_store(secret_store_port.clone()),
|
||||
);
|
||||
let notifier_factory: Arc<dyn AlertNotifierFactory> = Arc::new(TelegramAdapterFactory::new(
|
||||
db.clone() as Arc<dyn SettingRepo>,
|
||||
db.clone() as Arc<dyn AppRepo>,
|
||||
db.clone() as Arc<dyn SettingRepo + Send + Sync>,
|
||||
app_config.clone(),
|
||||
Some(secret_store_port.clone()),
|
||||
));
|
||||
let notification_service = Arc::new(NotificationService::new(
|
||||
db.clone() as Arc<dyn SettingRepo>,
|
||||
db.clone() as Arc<dyn AppRepo>,
|
||||
db.clone() as Arc<dyn SettingRepo + Send + Sync>,
|
||||
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<AppConfig>,
|
||||
app_config: &Arc<ArcSwap<AppConfig>>,
|
||||
) -> Result<(Ebpf, Ebpf, ProgramArray<MapData>, 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() {
|
||||
|
||||
@ -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<AppConfig>,
|
||||
config: Arc<ArcSwap<AppConfig>>,
|
||||
health: Arc<ArcSwap<SuricataHealth>>,
|
||||
}
|
||||
|
||||
impl SuricataManager {
|
||||
pub fn new(config: Arc<AppConfig>) -> Arc<Self> {
|
||||
let initial = if config.suricata.enabled {
|
||||
pub fn new(config: Arc<ArcSwap<AppConfig>>) -> Arc<Self> {
|
||||
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<Self>) -> 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<ArcSwap<AppConfig>>) -> 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<Child, Error> {
|
||||
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(),
|
||||
|
||||
@ -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<AppConfig>,
|
||||
config: Arc<ArcSwap<AppConfig>>,
|
||||
detection_tx: mpsc::Sender<DetectionEvent>,
|
||||
}
|
||||
|
||||
impl SuricataMonitor {
|
||||
pub fn new(config: Arc<AppConfig>, detection_tx: mpsc::Sender<DetectionEvent>) -> Arc<Self> {
|
||||
pub fn new(config: Arc<ArcSwap<AppConfig>>, detection_tx: mpsc::Sender<DetectionEvent>) -> Arc<Self> {
|
||||
Arc::new(Self { config, detection_tx })
|
||||
}
|
||||
|
||||
/// Spawn the tail loop. No-op if the bridge is disabled.
|
||||
pub fn start(self: Arc<Self>) {
|
||||
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<Self>) {
|
||||
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<DetectionEvent> {
|
||||
fn translate_alert(&self, v: &serde_json::Value) -> Option<DetectionEvent> {
|
||||
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(
|
||||
|
||||
@ -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<ShutdownMode>,
|
||||
tx: Sender<ShutdownMode>,
|
||||
}
|
||||
|
||||
impl ShutdownHandle {
|
||||
fn new(tx: mpsc::Sender<ShutdownMode>) -> Self {
|
||||
fn new(tx: Sender<ShutdownMode>) -> 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<AppConfig>,
|
||||
pub app_config: Arc<ArcSwap<AppConfig>>,
|
||||
pub inference_config: Arc<MLInferenceConfig>,
|
||||
pub ebpf_services: Arc<EbpfServices>,
|
||||
pub app_services: Arc<AppServices>,
|
||||
@ -109,7 +109,7 @@ pub struct System {
|
||||
pub notification_service: Arc<NotificationService>,
|
||||
pub playbook_service: Arc<PlaybookService>,
|
||||
pub rate_limit_service: Arc<RateLimitService>,
|
||||
pub geoip: Option<Arc<GeoIpService>>,
|
||||
pub geoip: Option<Arc<dyn GeoLookup>>,
|
||||
pub drift_detector: DriftDetectorHandle,
|
||||
pub shutdown_handle: Option<Arc<ShutdownHandle>>,
|
||||
_ingress_program_array: Option<ProgramArray<MapData>>,
|
||||
@ -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<dyn StatsRepo>,
|
||||
self.db.clone() as Arc<dyn SettingRepo>,
|
||||
self.db.clone() as Arc<dyn SettingRepo + Send + Sync>,
|
||||
);
|
||||
stats_aggregator.start();
|
||||
|
||||
@ -260,6 +261,7 @@ impl System {
|
||||
// Start detection orchestrator (dedup + enrichment + source attribution)
|
||||
let (detection_tx, detection_rx) = mpsc::channel::<DetectionEvent>(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()) {
|
||||
|
||||
@ -32,7 +32,7 @@ pub trait AclRepo: Send + Sync {
|
||||
/// explicitly installed.
|
||||
fn has_manual_acl_rule(&self, ip_address: &str) -> Result<bool, Error>;
|
||||
|
||||
fn load_admin_whitelist(&self) -> Result<Vec<String>, Error>;
|
||||
fn list_admin_whitelist(&self) -> Result<Vec<String>, Error>;
|
||||
fn insert_admin_whitelist(&self, ip: &str) -> Result<(), Error>;
|
||||
fn delete_admin_whitelist(&self, ip: &str) -> Result<(), Error>;
|
||||
}
|
||||
|
||||
@ -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<String>);
|
||||
|
||||
/// Identity BC — API key CRUD + validation (distinct from user login,
|
||||
|
||||
10
net-guardia/src/interface/port/geo_lookup.rs
Normal file
10
net-guardia/src/interface/port/geo_lookup.rs
Normal file
@ -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<GeoLocation>;
|
||||
}
|
||||
@ -39,11 +39,11 @@ pub trait IdentityRepo: Send + Sync {
|
||||
fn get_user_group(&self, id: i64) -> Result<Option<UserGroupTuple>, Error>;
|
||||
|
||||
// --- Membership ---
|
||||
fn get_user_groups(&self, user_id: i64) -> Result<Vec<(i64, String, String, String)>, Error>;
|
||||
fn list_groups_for_user(&self, user_id: i64) -> Result<Vec<(i64, String, String, String)>, Error>;
|
||||
fn set_user_groups(&self, user_id: i64, group_ids: &[i64]) -> Result<(), Error>;
|
||||
fn get_user_permissions(&self, user_id: i64) -> Result<Vec<String>, Error>;
|
||||
fn get_group_member_ids(&self, group_id: i64) -> Result<Vec<i64>, Error>;
|
||||
fn get_group_members(&self, group_id: i64) -> Result<Vec<(i64, String)>, Error>;
|
||||
fn list_user_permissions(&self, user_id: i64) -> Result<Vec<String>, Error>;
|
||||
fn list_group_member_ids(&self, group_id: i64) -> Result<Vec<i64>, Error>;
|
||||
fn list_group_members(&self, group_id: i64) -> Result<Vec<(i64, String)>, Error>;
|
||||
|
||||
// --- Login Rate Limiting ---
|
||||
fn record_login_failure(&self, username: &str) -> Result<(u32, Option<u64>), Error>;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -7,4 +7,5 @@ use crate::model::error::Error;
|
||||
pub trait SecretStorePort: Send + Sync {
|
||||
fn get_secret(&self, key: &str) -> Result<Option<String>, Error>;
|
||||
fn set_secret(&self, key: &str, plaintext: &str) -> Result<(), Error>;
|
||||
fn encrypt_envelope(&self, plaintext: &str) -> Result<String, Error>;
|
||||
}
|
||||
|
||||
@ -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<Option<String>, 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<Option<String>, 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<Option<String>, 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>;
|
||||
}
|
||||
|
||||
@ -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, String);
|
||||
|
||||
/// Threat Response BC — SOAR aggregate repository.
|
||||
@ -33,26 +31,25 @@ pub type SoarExecutionRow = (i64, i64, Option<String>, String, String, String);
|
||||
/// `DbAdminRepo::with_transaction` + `TxRepos`.
|
||||
pub trait SoarRepo: Send + Sync {
|
||||
// --- Playbooks ---
|
||||
fn load_playbooks_with_actions(&self) -> Result<Vec<PlaybookRow>, Error>;
|
||||
fn list_playbooks_with_actions(&self) -> Result<Vec<PlaybookRow>, Error>;
|
||||
fn update_playbook_enabled(&self, id: i64, enabled: bool) -> Result<bool, Error>;
|
||||
fn delete_playbook(&self, id: i64) -> Result<bool, Error>;
|
||||
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<Vec<(i64, i64, String, String, String, Option<String>)>, Error>;
|
||||
fn list_all_playbook_conditions(&self) -> Result<Vec<(i64, i64, String, String, String, Option<String>)>, Error>;
|
||||
|
||||
// --- Block Rules ---
|
||||
fn count_active_soar_blocks(&self) -> Result<u32, Error>;
|
||||
fn get_active_soar_blocks(&self) -> Result<Vec<(i64, String, i64, String)>, Error>;
|
||||
fn get_soar_block_by_id(&self, id: i64) -> Result<Option<(i64, String, i64, String)>, Error>;
|
||||
fn get_expired_soar_blocks(&self) -> Result<Vec<(i64, String, i64)>, Error>;
|
||||
fn list_active_soar_blocks(&self) -> Result<Vec<(i64, String, i64, String)>, Error>;
|
||||
fn find_soar_block_by_id(&self, id: i64) -> Result<Option<(i64, String, i64, String)>, Error>;
|
||||
fn list_expired_soar_blocks(&self) -> Result<Vec<(i64, String, i64)>, Error>;
|
||||
fn mark_soar_block_unblocked(&self, id: i64) -> Result<(), Error>;
|
||||
|
||||
// --- Pending Unblock Recovery ---
|
||||
fn insert_pending_unblock(&self, source_ip: &str) -> Result<i64, Error>;
|
||||
fn load_pending_unblocks(&self) -> Result<Vec<(i64, String, i64)>, Error>;
|
||||
fn list_pending_unblocks(&self) -> Result<Vec<(i64, String, i64)>, 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<String>)],
|
||||
) -> Result<bool, Error>;
|
||||
|
||||
@ -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)?;
|
||||
|
||||
27
net-guardia/src/model/config/acl.rs
Normal file
27
net-guardia/src/model/config/acl.rs
Normal file
@ -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<Self, Error> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
25
net-guardia/src/model/config/auth.rs
Normal file
25
net-guardia/src/model/config/auth.rs
Normal file
@ -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<Self, Error> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
64
net-guardia/src/model/config/correlation.rs
Normal file
64
net-guardia/src/model/config/correlation.rs
Normal file
@ -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<Self, Error> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
119
net-guardia/src/model/config/detection.rs
Normal file
119
net-guardia/src/model/config/detection.rs
Normal file
@ -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<Self, Error> {
|
||||
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<Self, Error> {
|
||||
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<Self, Error> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
27
net-guardia/src/model/config/dns_filter.rs
Normal file
27
net-guardia/src/model/config/dns_filter.rs
Normal file
@ -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<Self, Error> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
75
net-guardia/src/model/config/ebpf.rs
Normal file
75
net-guardia/src/model/config/ebpf.rs
Normal file
@ -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<Self, Error> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
54
net-guardia/src/model/config/helpers.rs
Normal file
54
net-guardia/src/model/config/helpers.rs
Normal file
@ -0,0 +1,54 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use crate::interface::port::setting::SettingRepo;
|
||||
use crate::model::error::Error;
|
||||
|
||||
pub(super) fn override_parsed<T: FromStr>(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<String>, 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(())
|
||||
}
|
||||
35
net-guardia/src/model/config/http_server.rs
Normal file
35
net-guardia/src/model/config/http_server.rs
Normal file
@ -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<String>,
|
||||
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<Self, Error> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
219
net-guardia/src/model/config/ml.rs
Normal file
219
net-guardia/src/model/config/ml.rs
Normal file
@ -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<Self, Error> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@ -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<Self, Error> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
95
net-guardia/src/model/config/notification.rs
Normal file
95
net-guardia/src/model/config/notification.rs
Normal file
@ -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<Self, Error> {
|
||||
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<Self, Error> {
|
||||
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<Self, Error> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
71
net-guardia/src/model/config/observability.rs
Normal file
71
net-guardia/src/model/config/observability.rs
Normal file
@ -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<Self, Error> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
35
net-guardia/src/model/config/pipeline.rs
Normal file
35
net-guardia/src/model/config/pipeline.rs
Normal file
@ -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<String>,
|
||||
pub egress: Vec<String>,
|
||||
}
|
||||
|
||||
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<Self, Error> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
115
net-guardia/src/model/config/soar.rs
Normal file
115
net-guardia/src/model/config/soar.rs
Normal file
@ -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<Self, Error> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
75
net-guardia/src/model/config/suricata.rs
Normal file
75
net-guardia/src/model/config/suricata.rs
Normal file
@ -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<Self, Error> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
34
net-guardia/src/model/config/system.rs
Normal file
34
net-guardia/src/model/config/system.rs
Normal file
@ -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<Self, Error> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
|
||||
@ -12,9 +12,11 @@ pub type RunnableModel = SimplePlan<TypedFact, Box<dyn TypedOp>, Graph<TypedFact
|
||||
pub struct EngineConfig {
|
||||
pub max_flows: usize,
|
||||
pub min_packets: usize,
|
||||
pub min_packets_floor: usize,
|
||||
pub batch_size: usize,
|
||||
pub inference_interval_secs: u64,
|
||||
pub aggregator_window_secs: u64,
|
||||
pub confirmation_window_fraction: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
49
net-guardia/src/model/detection/ml_inference_config.rs
Normal file
49
net-guardia/src/model/detection/ml_inference_config.rs
Normal file
@ -0,0 +1,49 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::model::detection::ml_detection::ClipParams;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MLInferenceConfig {
|
||||
pub ae_feature_names: Vec<String>,
|
||||
pub ae_clip_params: HashMap<String, ClipParams>,
|
||||
pub ae_scaler_mean: Vec<f64>,
|
||||
pub ae_scaler_std: Vec<f64>,
|
||||
pub ae_post_clip_min: f64,
|
||||
pub ae_post_clip_max: f64,
|
||||
pub ae_threshold: f32,
|
||||
pub classifier_feature_names: Vec<String>,
|
||||
pub attack_labels: HashMap<String, String>,
|
||||
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<String>,
|
||||
pub ae_feature_weights: HashMap<String, f64>,
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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<f64>,
|
||||
@ -16,6 +16,27 @@ pub struct CreateConditionInput {
|
||||
pub value2: Option<String>,
|
||||
}
|
||||
|
||||
impl CreateConditionInput {
|
||||
pub fn new(condition_type: String, operator: Option<String>, value: String, value2: Option<String>) -> 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<i64>,
|
||||
pub condition_window_secs: Option<i64>,
|
||||
pub cooldown_secs: i64,
|
||||
pub actions: Vec<ActionData>,
|
||||
pub conditions: Vec<ConditionData>,
|
||||
pub actions: Vec<ActionView>,
|
||||
pub conditions: Vec<ConditionView>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
@ -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,
|
||||
|
||||
@ -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<String>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
pub egress: Vec<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
pub ae_clip_params: HashMap<String, ClipParams>,
|
||||
pub ae_scaler_mean: Vec<f64>,
|
||||
pub ae_scaler_std: Vec<f64>,
|
||||
pub ae_post_clip_min: f64,
|
||||
pub ae_post_clip_max: f64,
|
||||
pub ae_threshold: f32,
|
||||
pub classifier_feature_names: Vec<String>,
|
||||
pub attack_labels: HashMap<String, String>,
|
||||
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<String>,
|
||||
pub ae_feature_weights: HashMap<String, f64>,
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,3 @@
|
||||
pub mod config;
|
||||
pub mod health;
|
||||
pub mod rate_limit_settings;
|
||||
pub mod readiness;
|
||||
|
||||
@ -20,7 +20,6 @@ pub enum SuricataHealth {
|
||||
}
|
||||
|
||||
impl SuricataHealth {
|
||||
#[allow(dead_code)]
|
||||
pub fn is_running(&self) -> bool {
|
||||
matches!(self, SuricataHealth::Running { .. })
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user