chore: Remove/Fix AI trash

This commit is contained in:
DaLaw2 2026-04-22 00:40:13 +08:00
parent 4bfd00d12a
commit 1282048144
101 changed files with 2857 additions and 1908 deletions

View File

@ -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 => {

View File

@ -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 {

View File

@ -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(),
}

View File

@ -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,

View File

@ -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 {

View File

@ -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,

View File

@ -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())

View File

@ -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(),

View File

@ -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 => {

View File

@ -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.

View File

@ -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";

View File

@ -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

View File

@ -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 {

View File

@ -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()))
}

View File

@ -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,

View 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()})),
}
}

View File

@ -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

View File

@ -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());
}
}

View File

@ -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")?;

View File

@ -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;

View File

@ -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(""));
}
}

View File

@ -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);
}
}

View File

@ -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());
}
}

View File

@ -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> {

View File

@ -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))
}
}

View File

@ -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,
}
}

View File

@ -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);

View File

@ -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! {

View File

@ -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

View File

@ -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 {

View File

@ -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();

View File

@ -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
}

View File

@ -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 {})",

View File

@ -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,

View File

@ -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()
}
}

View File

@ -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> {

View File

@ -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());
}
}

View File

@ -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]

View File

@ -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);

View File

@ -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));
}
}
}

View File

@ -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);

View File

@ -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()));
}

View File

@ -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();

View File

@ -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,

View File

@ -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"));
}

View File

@ -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> {

View File

@ -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,

View File

@ -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();

View File

@ -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);

View File

@ -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(&gte, &ev).met);
assert!(!simulate_condition(&lte, &ev).met);
assert!(simulate_condition(&gte, &ev, 0.95).met);
assert!(!simulate_condition(&lte, &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,

View File

@ -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 {

View File

@ -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(());

View File

@ -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");

View File

@ -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"]);
}
}

View File

@ -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()));

View File

@ -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 {

View File

@ -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

View File

@ -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
}
}

View File

@ -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

View File

@ -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()))

View File

@ -1,4 +1,3 @@
pub mod app_config;
pub mod app_services;
pub mod audit_logger;
pub mod cli;

View File

@ -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)]

View File

@ -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() {

View File

@ -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(),

View File

@ -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(

View File

@ -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()) {

View File

@ -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>;
}

View File

@ -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,

View 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>;
}

View File

@ -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>;

View File

@ -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;

View File

@ -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>;
}

View File

@ -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>;
}

View File

@ -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>;

View File

@ -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)?;

View 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(())
}
}

View 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(())
}
}

View File

@ -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;

View 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(())
}
}

View 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(())
}
}

View 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(())
}
}

View 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(())
}
}

View 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(())
}

View 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(())
}
}

View 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(())
}
}

View File

@ -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);
}
}

View 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(())
}
}

View 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(())
}
}

View 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(())
}
}

View 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(())
}
}

View 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(())
}
}

View 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(())
}
}

View File

@ -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.

View File

@ -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)]

View 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()
}
}

View File

@ -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;

View File

@ -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,

View File

@ -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()
}
}

View File

@ -1,4 +1,3 @@
pub mod config;
pub mod health;
pub mod rate_limit_settings;
pub mod readiness;

View File

@ -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