Compare commits

...

2 Commits

Author SHA1 Message Date
301d6ac1b8 refactor: Remove CommunicationManager 2026-04-25 13:07:57 +08:00
94d366bcbe refactor: logging and config 2026-04-25 11:22:44 +08:00
75 changed files with 1674 additions and 2163 deletions

517
macros/src/config.rs Normal file
View File

@ -0,0 +1,517 @@
use std::collections::BTreeMap;
use proc_macro::TokenStream;
use proc_macro2::TokenStream as TokenStream2;
use quote::{format_ident, quote};
use syn::parse::{Parse, ParseStream};
use syn::{Fields, Ident, ItemStruct, LitBool, LitStr, Result, Token, Type};
// ── Attribute parsing ──────────────────────────────────────────────
struct StructAttr {
default_section: Option<String>,
}
impl Parse for StructAttr {
fn parse(input: ParseStream) -> Result<Self> {
let mut section = None;
while !input.is_empty() {
let key: Ident = input.parse()?;
input.parse::<Token![=]>()?;
let val: LitStr = input.parse()?;
if key == "section" {
section = Some(val.value());
}
if !input.is_empty() {
input.parse::<Token![,]>()?;
}
}
Ok(Self {
default_section: section,
})
}
}
// ── Field model ────────────────────────────────────────────────────
enum ConfigField {
Setting(SettingField),
Flatten(FlattenField),
MappedParent(MappedParent),
}
struct SettingField {
ident: Ident,
ty: Type,
key: String,
default: String,
default_debug: Option<String>,
section: Option<String>,
api: bool,
}
struct FlattenField {
ident: Ident,
ty: Type,
}
struct MappedSetting {
key: String,
default: String,
default_debug: Option<String>,
parent: String,
sub_field: String,
section: Option<String>,
api: bool,
}
struct MappedParent {
ident: Ident,
ty: Type,
settings: Vec<MappedSetting>,
}
// ── Parsing ────────────────────────────────────────────────────────
fn parse_struct_mapped_settings(input: &mut ItemStruct, default_section: &Option<String>) -> Vec<MappedSetting> {
let mut mapped = Vec::new();
input.attrs.retain(|attr| {
if !attr.path().is_ident("setting") {
return true;
}
let mut key = None;
let mut default = None;
let mut default_debug = None;
let mut path = None;
let mut section = None;
let mut api = true;
let _ = attr.parse_nested_meta(|meta| {
if meta.path.is_ident("key") {
let val: LitStr = meta.value()?.parse()?;
key = Some(val.value());
} else if meta.path.is_ident("default") {
let val: LitStr = meta.value()?.parse()?;
default = Some(val.value());
} else if meta.path.is_ident("default_debug") {
let val: LitStr = meta.value()?.parse()?;
default_debug = Some(val.value());
} else if meta.path.is_ident("path") {
let val: LitStr = meta.value()?.parse()?;
path = Some(val.value());
} else if meta.path.is_ident("section") {
let val: LitStr = meta.value()?.parse()?;
section = Some(val.value());
} else if meta.path.is_ident("api") {
let val: LitBool = meta.value()?.parse()?;
api = val.value();
}
Ok(())
});
if let (Some(key), Some(default), Some(path)) = (key, default, path) {
let (parent, sub_field) = path
.split_once('.')
.expect("#[setting] `path` must be `parent.sub_field`");
mapped.push(MappedSetting {
key,
default,
default_debug,
parent: parent.to_string(),
sub_field: sub_field.to_string(),
section: section.or_else(|| default_section.clone()),
api,
});
false
} else {
true
}
});
mapped
}
fn parse_field(field: &mut syn::Field, default_section: &Option<String>) -> Option<ConfigField> {
let idx = field.attrs.iter().position(|a| a.path().is_ident("setting"))?;
let attr = field.attrs.remove(idx);
let mut is_flatten = false;
let mut key = None;
let mut default = None;
let mut default_debug = None;
let mut section = None;
let mut api = true;
attr.parse_nested_meta(|meta| {
if meta.path.is_ident("flatten") {
is_flatten = true;
} else if meta.path.is_ident("key") {
let val: LitStr = meta.value()?.parse()?;
key = Some(val.value());
} else if meta.path.is_ident("default") {
let val: LitStr = meta.value()?.parse()?;
default = Some(val.value());
} else if meta.path.is_ident("default_debug") {
let val: LitStr = meta.value()?.parse()?;
default_debug = Some(val.value());
} else if meta.path.is_ident("section") {
let val: LitStr = meta.value()?.parse()?;
section = Some(val.value());
} else if meta.path.is_ident("api") {
let val: LitBool = meta.value()?.parse()?;
api = val.value();
}
Ok(())
})
.unwrap_or_else(|e| panic!("invalid #[setting]: {e}"));
let ident = field.ident.clone().expect("named field");
let ty = field.ty.clone();
if is_flatten {
return Some(ConfigField::Flatten(FlattenField { ident, ty }));
}
Some(ConfigField::Setting(SettingField {
ident,
ty,
key: key.expect("#[setting] requires `key`"),
default: default.expect("#[setting] requires `default`"),
default_debug,
section: section.or_else(|| default_section.clone()),
api,
}))
}
// ── Type detection ─────────────────────────────────────────────────
fn is_type(ty: &Type, name: &str) -> bool {
matches!(ty, Type::Path(tp) if tp.path.is_ident(name))
}
fn is_vec_string(ty: &Type) -> bool {
if let Type::Path(tp) = ty
&& let Some(seg) = tp.path.segments.last()
{
return seg.ident == "Vec";
}
false
}
// ── Code generation: defaults() ────────────────────────────────────
fn make_default_val(ty: &Type, default: &str) -> TokenStream2 {
if is_type(ty, "String") {
quote! { #default.to_string() }
} else if is_type(ty, "bool") {
let val = default == "true" || default == "1";
quote! { #val }
} else if is_vec_string(ty) {
if default.is_empty() {
quote! { Vec::new() }
} else {
let items: Vec<&str> = default.split(',').map(|v| v.trim()).collect();
quote! { vec![#(#items.to_string()),*] }
}
} else {
// SAFETY: literal default, validated by tests
quote! { #default.parse().unwrap() }
}
}
fn gen_default(f: &SettingField) -> TokenStream2 {
let ident = &f.ident;
let ty = &f.ty;
match &f.default_debug {
Some(dbg) => {
let release_val = make_default_val(ty, &f.default);
let debug_val = make_default_val(ty, dbg);
quote! { #ident: if cfg!(debug_assertions) { #debug_val } else { #release_val } }
}
None => {
let val = make_default_val(ty, &f.default);
quote! { #ident: #val }
}
}
}
fn gen_flatten_default(f: &FlattenField) -> TokenStream2 {
let ident = &f.ident;
let ty = &f.ty;
quote! { #ident: #ty::defaults() }
}
fn gen_mapped_default(mp: &MappedParent) -> TokenStream2 {
let ident = &mp.ident;
let ty = &mp.ty;
let sub_fields: Vec<_> = mp
.settings
.iter()
.map(|s| {
let sub = format_ident!("{}", s.sub_field);
let val: TokenStream2 = match &s.default_debug {
Some(dbg) => {
let release = &s.default;
// SAFETY: literal default, validated by tests
quote! { if cfg!(debug_assertions) { #dbg.parse().unwrap() } else { #release.parse().unwrap() } }
}
None => {
let default = &s.default;
// SAFETY: literal default, validated by tests
quote! { #default.parse().unwrap() }
}
};
quote! { #sub: #val }
})
.collect();
quote! { #ident: #ty { #(#sub_fields,)* } }
}
// ── Code generation: from_settings() ───────────────────────────────
fn gen_override(f: &SettingField) -> TokenStream2 {
let ident = &f.ident;
let key = &f.key;
let ty = &f.ty;
if is_type(ty, "String") {
quote! {
crate::domain::common::config::helpers::override_string_nonempty(
&mut cfg.#ident, repo, #key,
)?;
}
} else if is_type(ty, "bool") {
quote! {
crate::domain::common::config::helpers::override_bool(
&mut cfg.#ident, repo, #key,
)?;
}
} else if is_vec_string(ty) {
quote! {
crate::domain::common::config::helpers::override_csv(
&mut cfg.#ident, repo, #key,
)?;
}
} else {
quote! {
crate::domain::common::config::helpers::override_parsed(
&mut cfg.#ident, repo, #key,
)?;
}
}
}
fn gen_flatten_override(f: &FlattenField) -> TokenStream2 {
let ident = &f.ident;
let ty = &f.ty;
quote! { cfg.#ident = #ty::from_settings(repo)?; }
}
fn gen_mapped_overrides(mp: &MappedParent) -> TokenStream2 {
let parent = &mp.ident;
let calls: Vec<_> = mp
.settings
.iter()
.map(|s| {
let sub = format_ident!("{}", s.sub_field);
let key = &s.key;
quote! {
crate::domain::common::config::helpers::override_parsed(
&mut cfg.#parent.#sub, repo, #key,
)?;
}
})
.collect();
quote! { #(#calls)* }
}
// ── Code generation: seed_defaults() ───────────────────────────────
fn gen_seed(f: &SettingField) -> TokenStream2 {
let key = &f.key;
let default = &f.default;
match &f.default_debug {
Some(dbg) => quote! {
crate::domain::common::config::helpers::seed_key(
repo, #key,
if cfg!(debug_assertions) { #dbg } else { #default },
)?;
},
None => quote! {
crate::domain::common::config::helpers::seed_key(repo, #key, #default)?;
},
}
}
fn gen_flatten_seed(f: &FlattenField) -> TokenStream2 {
let ty = &f.ty;
quote! { #ty::seed_defaults(repo)?; }
}
fn gen_mapped_seeds(mp: &MappedParent) -> TokenStream2 {
let calls: Vec<_> = mp
.settings
.iter()
.map(|s| {
let key = &s.key;
let default = &s.default;
match &s.default_debug {
Some(dbg) => quote! {
crate::domain::common::config::helpers::seed_key(
repo, #key,
if cfg!(debug_assertions) { #dbg } else { #default },
)?;
},
None => quote! {
crate::domain::common::config::helpers::seed_key(repo, #key, #default)?;
},
}
})
.collect();
quote! { #(#calls)* }
}
// ── Code generation: API_KEYS ──────────────────────────────────────
fn collect_api_keys(fields: &[ConfigField]) -> Vec<(&str, &str)> {
let mut keys = Vec::new();
for f in fields {
match f {
ConfigField::Setting(s) if s.api => {
let sec = s.section.as_deref().unwrap_or("default");
keys.push((sec, s.key.as_str()));
}
ConfigField::MappedParent(mp) => {
for s in &mp.settings {
if s.api {
let sec = s.section.as_deref().unwrap_or("default");
keys.push((sec, s.key.as_str()));
}
}
}
_ => {}
}
}
keys
}
fn gen_keys_consts(fields: &[ConfigField]) -> TokenStream2 {
let api_keys = collect_api_keys(fields);
if api_keys.is_empty() {
return quote! {};
}
let mut sections: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
for (sec, key) in &api_keys {
sections.entry(sec).or_default().push(key);
}
let single = sections.len() == 1;
sections
.iter()
.map(|(section, keys)| {
let name = if single {
format_ident!("API_KEYS")
} else {
format_ident!("{}_KEYS", section.to_uppercase())
};
quote! { pub const #name: &[&str] = &[#(#keys),*]; }
})
.collect()
}
// ── Entry point ────────────────────────────────────────────────────
pub fn config_settings_impl(attr: TokenStream, item: TokenStream) -> TokenStream {
let struct_attr = syn::parse_macro_input!(attr as StructAttr);
let mut input = syn::parse_macro_input!(item as ItemStruct);
let mapped_settings = parse_struct_mapped_settings(&mut input, &struct_attr.default_section);
let mut mapped_groups: BTreeMap<String, Vec<MappedSetting>> = BTreeMap::new();
for ms in mapped_settings {
mapped_groups.entry(ms.parent.clone()).or_default().push(ms);
}
let fields = match &mut input.fields {
Fields::Named(f) => f,
_ => panic!("config_settings only supports named fields"),
};
let mut config_fields = Vec::new();
for field in &mut fields.named {
let field_name = field.ident.as_ref().expect("named field").to_string();
if let Some(settings) = mapped_groups.remove(&field_name) {
config_fields.push(ConfigField::MappedParent(MappedParent {
ident: field.ident.clone().unwrap(),
ty: field.ty.clone(),
settings,
}));
} else if let Some(cf) = parse_field(field, &struct_attr.default_section) {
config_fields.push(cf);
}
}
let struct_name = &input.ident;
let keys_consts = gen_keys_consts(&config_fields);
let default_fields: Vec<_> = config_fields
.iter()
.map(|f| match f {
ConfigField::Setting(s) => gen_default(s),
ConfigField::Flatten(s) => gen_flatten_default(s),
ConfigField::MappedParent(mp) => gen_mapped_default(mp),
})
.collect();
let override_calls: Vec<_> = config_fields
.iter()
.map(|f| match f {
ConfigField::Setting(s) => gen_override(s),
ConfigField::Flatten(s) => gen_flatten_override(s),
ConfigField::MappedParent(mp) => gen_mapped_overrides(mp),
})
.collect();
let seed_calls: Vec<_> = config_fields
.iter()
.map(|f| match f {
ConfigField::Setting(s) => gen_seed(s),
ConfigField::Flatten(s) => gen_flatten_seed(s),
ConfigField::MappedParent(mp) => gen_mapped_seeds(mp),
})
.collect();
let expanded = quote! {
#input
impl #struct_name {
#keys_consts
pub fn defaults() -> Self {
Self {
#(#default_fields,)*
}
}
pub fn from_settings(
repo: &dyn crate::interface::port::setting::SettingRepo,
) -> Result<Self, crate::domain::common::error::Error> {
let mut cfg = Self::defaults();
#(#override_calls)*
Ok(cfg)
}
pub fn seed_defaults(
repo: &dyn crate::interface::port::setting::SettingRepo,
) -> Result<(), crate::domain::common::error::Error> {
#(#seed_calls)*
Ok(())
}
}
};
TokenStream::from(expanded)
}

View File

@ -1,3 +1,4 @@
mod config;
mod error_enum;
mod log;
mod loggable;
@ -5,6 +6,11 @@ mod traceable;
use proc_macro::TokenStream;
#[proc_macro_attribute]
pub fn config_settings(attr: TokenStream, item: TokenStream) -> TokenStream {
config::config_settings_impl(attr, item)
}
#[proc_macro]
pub fn log(input: TokenStream) -> TokenStream {
log::log_impl(input)

View File

@ -185,6 +185,8 @@ pub struct XskPair {
drop_monitor: Option<Arc<DropMonitor>>,
packet_buffer_size: usize,
buffer_pool_capacity: usize,
tx_packet_buf: Vec<Vec<u8>>,
tx_frame_buf: Vec<FrameDesc>,
}
impl XskPair {
@ -256,6 +258,8 @@ impl XskPair {
drop_monitor,
packet_buffer_size: config.packet_buffer_size,
buffer_pool_capacity: config.buffer_pool_capacity,
tx_packet_buf: Vec::with_capacity(64),
tx_frame_buf: Vec::with_capacity(64),
};
Ok(xsk_pair)
@ -312,13 +316,14 @@ impl XskPair {
idle_count = 0;
}
let sleep_us = match idle_count {
0..=10 => 1,
11..=100 => 10,
_ => 100,
};
thread::sleep(Duration::from_micros(sleep_us));
if idle_count > 0 {
let sleep_us = match idle_count {
1..=10 => 1,
11..=100 => 10,
_ => 100,
};
thread::sleep(Duration::from_micros(sleep_us));
}
}
log!(EbpfLog::XSKShutdown);
@ -421,15 +426,15 @@ impl XskPair {
buffer_pool: &mut BufferPool,
comp_descs: &mut [FrameDesc],
) -> Result<usize, EbpfError> {
let mut packets_to_send = Vec::with_capacity(64);
self.tx_packet_buf.clear();
while let Ok(packet) = forward_rx.try_recv() {
packets_to_send.push(packet);
if packets_to_send.len() >= 64 {
self.tx_packet_buf.push(packet);
if self.tx_packet_buf.len() >= 64 {
break;
}
}
if packets_to_send.is_empty() {
if self.tx_packet_buf.is_empty() {
return Ok(0);
}
@ -437,10 +442,10 @@ impl XskPair {
log!(EbpfLog::CompQueueError(format!("{:?}", e)));
}
let total_packets = packets_to_send.len();
let total_packets = self.tx_packet_buf.len();
if self.frame_pool.is_empty() {
for pkt in packets_to_send {
for pkt in self.tx_packet_buf.drain(..) {
buffer_pool.put(pkt);
}
log!(EbpfLog::FramePoolExhausted(total_packets));
@ -448,17 +453,19 @@ impl XskPair {
}
let available = self.frame_pool.len().min(total_packets);
let mut frames: Vec<FrameDesc> = self.frame_pool.drain(self.frame_pool.len() - available..).collect();
self.tx_frame_buf.clear();
self.tx_frame_buf
.extend(self.frame_pool.drain(self.frame_pool.len() - available..));
if frames.is_empty() {
for pkt in packets_to_send {
if self.tx_frame_buf.is_empty() {
for pkt in self.tx_packet_buf.drain(..) {
buffer_pool.put(pkt);
}
log!(EbpfLog::NoFramesAvailable);
return Ok(0);
}
for (frame, packet) in frames.iter_mut().zip(packets_to_send.iter()) {
for (frame, packet) in self.tx_frame_buf.iter_mut().zip(self.tx_packet_buf.iter()) {
unsafe {
self.umem
.data_mut(frame)
@ -468,11 +475,11 @@ impl XskPair {
}
}
let nb_submitted = unsafe { self.tx.produce(&frames) };
let nb_submitted = unsafe { self.tx.produce(&self.tx_frame_buf) };
// Return unsubmitted frames to pool to prevent frame leak
if nb_submitted < frames.len() {
for frame in frames[nb_submitted..].iter() {
if nb_submitted < self.tx_frame_buf.len() {
for frame in self.tx_frame_buf[nb_submitted..].iter() {
self.frame_pool.push(*frame);
}
}
@ -494,7 +501,7 @@ impl XskPair {
}
// Return all buffers to pool
for pkt in packets_to_send {
for pkt in self.tx_packet_buf.drain(..) {
buffer_pool.put(pkt);
}

View File

@ -11,7 +11,8 @@ use actix_files::NamedFile;
use actix_web::{HttpRequest, HttpResponse, Responder, Scope, web};
use crate::core::inference::engine::Engine;
use crate::core::inference::traffic_logger::{FLOW_TRACE_FILE_EXT, FLOW_TRACE_FILE_MARKER, list_flow_trace_files};
use crate::core::inference::traffic_logger::list_flow_trace_files;
use crate::domain::common::config::constants::{FLOW_TRACE_FILE_EXT, FLOW_TRACE_FILE_MARKER};
pub fn initialize() -> Scope {
web::scope("/flow-trace")

View File

@ -13,15 +13,10 @@ use actix_web::{HttpRequest, HttpResponse, Responder, Scope, web};
use arc_swap::ArcSwap;
use crate::domain::common::config::AppConfig;
use crate::domain::common::config::constants::FUSION_AUDIT_ACTION;
use crate::domain::detection::metrics::FusionMetrics;
use crate::interface::port::audit::{AuditLogEntry, AuditRepo};
/// Stable audit action string the fusion engine emits — kept in sync
/// with `core::detection::orchestrator::FUSION_AUDIT_ACTION`. If that
/// constant changes, the explain endpoint silently returns nothing, so
/// keep this updated at the same time.
const FUSION_AUDIT_ACTION: &str = "fused_threat_emitted";
pub fn initialize() -> Scope {
web::scope("/fusion")
.route("/metrics", web::get().to(get_metrics))

View File

@ -8,7 +8,7 @@ use actix_web::{HttpResponse, Scope, web};
use arc_swap::ArcSwap;
use serde::{Deserialize, Serialize};
use crate::core::common::observability::log_buffer::{self, LogEntry};
use crate::core::common::log_buffer::{self, LogBuffer, LogEntry};
use crate::domain::common::config::AppConfig;
/// Hardcoded log directory — not configurable via API to prevent directory traversal.
@ -49,7 +49,11 @@ struct LiveResponse {
dropped_oldest: bool,
}
async fn live_logs(query: web::Query<LiveQuery>, app_config: web::Data<Arc<ArcSwap<AppConfig>>>) -> HttpResponse {
async fn live_logs(
query: web::Query<LiveQuery>,
app_config: web::Data<Arc<ArcSwap<AppConfig>>>,
buf: web::Data<LogBuffer>,
) -> HttpResponse {
let since_id = query.since_id.unwrap_or(0);
let obs = app_config.load().observability.clone();
let limit = query
@ -62,7 +66,7 @@ async fn live_logs(query: web::Query<LiveQuery>, app_config: web::Data<Arc<ArcSw
.map(|s| log_buffer::level_severity(&s.to_ascii_uppercase()))
.unwrap_or(log_buffer::level_severity("TRACE"));
let snap = log_buffer::snapshot(since_id, min_severity, limit);
let snap = buf.snapshot(since_id, min_severity, limit);
// Signal to the UI that it lagged enough for the ring to evict rows
// between polls. Frontend can warn "older entries dropped" without
// silently skipping a gap.

View File

@ -1,12 +1,12 @@
use actix_web::{HttpResponse, Responder, Scope, web};
use tokio::sync::broadcast;
use crate::adapter::http::AUDIT_ACTOR_SECURITY_ADMIN_PREFIX;
use crate::core::identity::extractor::AuthClaims;
use crate::core::inference::engine::Engine;
use crate::core::inference::inference::Inference;
use crate::domain::common::config::constants::AUDIT_ACTOR_SECURITY_ADMIN_PREFIX;
use crate::domain::common::event::AuditEvent;
use crate::domain::detection::model_adapter::ModelSourceState;
use crate::infrastructure::communication_manager::CommunicationManager;
/// Permission required to forcibly revert the active ML source to dormant.
/// Mirrors the upload handler's gate so swap-out and revert are symmetric:
@ -65,7 +65,7 @@ async fn get_current_model(inference: web::Data<Inference>) -> impl Responder {
/// the upload path's `model_swap` so both swap-in and revert are auditable.
async fn delete_current_model(
inference: web::Data<Inference>,
comm: web::Data<CommunicationManager>,
audit_tx: web::Data<broadcast::Sender<AuditEvent>>,
claims: AuthClaims,
) -> impl Responder {
if !claims.permissions.iter().any(|p| p == DORMANT_REQUIRED_PERMISSION) {
@ -87,13 +87,11 @@ async fn delete_current_model(
"before": serde_json::to_value(&before_status).unwrap_or(serde_json::Value::Null),
})
.to_string();
let _ = comm
.publish_event(AuditEvent {
actor: format!("{AUDIT_ACTOR_SECURITY_ADMIN_PREFIX}@{}", claims.username),
action: AUDIT_ACTION_MODEL_DORMANT.to_string(),
detail: audit_detail,
})
.await;
let _ = audit_tx.send(AuditEvent {
actor: format!("{AUDIT_ACTOR_SECURITY_ADMIN_PREFIX}@{}", claims.username),
action: AUDIT_ACTION_MODEL_DORMANT.to_string(),
detail: audit_detail,
});
HttpResponse::Ok().json(serde_json::json!({
"already_dormant": false,

View File

@ -19,8 +19,3 @@ 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

@ -31,6 +31,7 @@ use serde_json::Value as JsonValue;
use sha2::{Digest, Sha256};
use tokio::fs;
use tokio::io::AsyncWriteExt;
use tokio::sync::broadcast;
use tokio::task;
use uuid::Uuid;
@ -38,11 +39,12 @@ use crate::core::identity::extractor::AuthClaims;
use crate::core::inference::inference::Inference;
use crate::core::inference::model_loader::build_adapter;
use crate::domain::common::config::AppConfig;
use crate::domain::common::config::constants::{MANIFEST_FILENAME, MODELS_DIR, STAGING_SUBDIR};
use crate::domain::common::config::constants::{
AUDIT_ACTOR_SECURITY_ADMIN_PREFIX, MANIFEST_FILENAME, MODELS_DIR, STAGING_SUBDIR,
};
use crate::domain::common::event::AuditEvent;
use crate::domain::detection::manifest::{AdapterKind, ModelManifest};
use crate::domain::detection::ml_inference_config::MLInferenceConfig;
use crate::infrastructure::communication_manager::CommunicationManager;
/// Multipart field names the client must use. Stable wire contract —
/// the frontend form generator depends on these exact strings.
@ -62,14 +64,6 @@ const ONNX_SNIFF_BYTES: usize = 16;
/// swap the ML source.
const PROMOTE_REQUIRED_PERMISSION: &str = "users:admin";
/// Actor prefix on the WORM `model_swap` audit entry. Administrators
/// that trigger the upload endpoint land on the chain as
/// `SecurityAdmin@<username>` so downstream filters can separate
/// 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.
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
/// model" views filter on it, so the rename must go through the audit
@ -139,7 +133,7 @@ pub fn initialize() -> Scope {
async fn upload(
app_config: web::Data<ArcSwap<AppConfig>>,
inference: web::Data<Inference>,
comm: web::Data<CommunicationManager>,
audit_tx: web::Data<broadcast::Sender<AuditEvent>>,
promote_lock: web::Data<PromoteGate>,
claims: AuthClaims,
payload: Multipart,
@ -174,7 +168,7 @@ async fn upload(
&staging_dir,
&summary,
inference.get_ref(),
comm.get_ref(),
audit_tx.get_ref(),
promote_lock.get_ref(),
&claims.username,
batch_size,
@ -522,7 +516,7 @@ async fn validate_and_promote(
staging_dir: &Path,
summary: &UploadSummary,
inference: &Inference,
comm: &CommunicationManager,
audit_tx: &broadcast::Sender<AuditEvent>,
promote_lock: &PromoteGate,
actor_username: &str,
batch_size: usize,
@ -611,13 +605,11 @@ async fn validate_and_promote(
"before": serde_json::to_value(&before_status).unwrap_or(JsonValue::Null),
})
.to_string();
let _ = comm
.publish_event(AuditEvent {
actor: format!("{AUDIT_ACTOR_SECURITY_ADMIN_PREFIX}@{actor_username}"),
action: AUDIT_ACTION_MODEL_SWAP.to_string(),
detail: audit_detail,
})
.await;
let _ = audit_tx.send(AuditEvent {
actor: format!("{AUDIT_ACTOR_SECURITY_ADMIN_PREFIX}@{actor_username}"),
action: AUDIT_ACTION_MODEL_SWAP.to_string(),
detail: audit_detail,
});
Ok(PromoteReport {
manifest_name: manifest.name,

View File

@ -3,13 +3,11 @@ use serde::Deserialize;
use crate::core::common::config_service::ConfigService;
use crate::core::identity::extractor::AuthClaims;
use crate::infrastructure::communication_manager::CommunicationManager;
use crate::infrastructure::enforce_mode_handler::EnforceModeHandler;
use crate::infrastructure::logger::Logger;
use crate::infrastructure::system::{ShutdownHandle, ShutdownMode};
use crate::interface::communication::command_types::ChangeEnforceModeCommand;
use crate::interface::communication::query_types::GetEnforceModeQuery;
use crate::interface::port::app_repo::AppRepo;
use crate::utils::boot_time;
use crate::utils::logging::Logging;
type Repo = dyn AppRepo;
@ -36,8 +34,8 @@ async fn get_boot_time() -> impl Responder {
HttpResponse::Ok().json(boot_time::boot_time())
}
async fn get_enforce_mode(comm: web::Data<CommunicationManager>) -> impl Responder {
match comm.send_query(GetEnforceModeQuery).await {
async fn get_enforce_mode(handler: web::Data<EnforceModeHandler>) -> impl Responder {
match handler.get_mode() {
Ok(mode) => HttpResponse::Ok().json(serde_json::json!({"mode": mode})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
@ -45,7 +43,7 @@ async fn get_enforce_mode(comm: web::Data<CommunicationManager>) -> impl Respond
async fn set_enforce_mode(
body: web::Json<EnforceModeRequest>,
comm: web::Data<CommunicationManager>,
handler: web::Data<EnforceModeHandler>,
) -> impl Responder {
let mode = &body.mode;
if mode != "monitor" && mode != "ml_only" && mode != "enforce" {
@ -53,7 +51,7 @@ async fn set_enforce_mode(
.json(serde_json::json!({"error": "Mode must be 'monitor', 'ml_only', or 'enforce'"}));
}
match comm.send_command(ChangeEnforceModeCommand { mode: mode.clone() }).await {
match handler.change_mode(mode.clone()) {
Ok(_) => HttpResponse::Ok().json(serde_json::json!({"mode": mode})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
@ -81,9 +79,9 @@ async fn get_config(svc: web::Data<ConfigService>) -> impl Responder {
HttpResponse::Ok().json(svc.get_config())
}
async fn get_log_level() -> impl Responder {
async fn get_log_level(logging: web::Data<Logger>) -> impl Responder {
HttpResponse::Ok().json(serde_json::json!({
"level": Logging::current_level(),
"level": logging.current_level(),
}))
}
@ -92,8 +90,8 @@ struct LogLevelRequest {
level: String,
}
async fn set_log_level(body: web::Json<LogLevelRequest>) -> impl Responder {
match Logging::set_level(&body.level) {
async fn set_log_level(body: web::Json<LogLevelRequest>, logging: web::Data<Logger>) -> impl Responder {
match logging.set_level(&body.level) {
Ok(new_level) => HttpResponse::Ok().json(serde_json::json!({
"level": new_level,
"message": "Log level updated",

View File

@ -59,7 +59,13 @@ impl Database {
pub fn has_manual_acl_rule(&self, ip_address: &str) -> Result<bool, Error> {
let conn = self.conn()?;
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM acl_rules WHERE ip_address = ?1 AND list_type = 'blacklist'",
"SELECT COUNT(*) FROM acl_rules
WHERE ip_address = ?1 AND list_type = 'blacklist'
AND NOT EXISTS (
SELECT 1 FROM soar_block_rules
WHERE soar_block_rules.source_ip = acl_rules.ip_address
AND soar_block_rules.unblocked_at IS NULL
)",
params![ip_address],
|row| row.get(0),
)?;

View File

@ -31,7 +31,7 @@ impl Database {
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"
"SELECT id, source_ip, playbook_id FROM soar_block_rules WHERE expires_at <= datetime('now') AND unblocked_at IS NULL",
)?;
let rows = stmt.query_map([], |row| {
Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?, row.get::<_, i64>(2)?))

View File

@ -1,8 +1,8 @@
//! WebSocket bridge for post-fusion threat events.
//!
//! `/ws/fusion` subscribes to the `ThreatDetectedEvent` broadcast that the
//! `DetectionOrchestrator` already publishes through `CommunicationManager`
//! (the same stream SOAR consumes). Each event is wrapped with a server-side
//! `/ws/fusion` subscribes to the `ThreatDetectedEvent` broadcast channel
//! that the `DetectionOrchestrator` publishes to (the same stream SOAR
//! consumes). Each event is wrapped with a server-side
//! `ts` (unix seconds) so the dashboard can render relative timestamps
//! without doing the conversion itself.
//!
@ -26,24 +26,15 @@ use crate::domain::common::error::http::HttpError;
use crate::domain::common::error::misc::MiscError;
use crate::domain::common::event::ThreatDetectedEvent;
use crate::domain::common::log::http::HttpLog;
use crate::infrastructure::communication_manager::CommunicationManager;
pub async fn websocket_fusion(
req: HttpRequest,
body: web::Payload,
comm: web::Data<CommunicationManager>,
threat_tx: web::Data<broadcast::Sender<ThreatDetectedEvent>>,
) -> Result<HttpResponse> {
let (response, session, msg_stream) = handle(&req, body)?;
let broadcast_rx = match comm.subscribe_event::<ThreatDetectedEvent>() {
Ok(rx) => rx,
Err(e) => {
log!(HttpLog::FusionSubscribeFailed(e.to_string()));
return Ok(HttpResponse::InternalServerError().json(serde_json::json!({
"error": "fusion event channel not registered",
})));
}
};
let broadcast_rx = threat_tx.subscribe();
spawn(async move {
handle_fusion_connection(session, msg_stream, broadcast_rx).await;

View File

@ -1,11 +1,12 @@
use actix_web::{HttpRequest, HttpResponse, Responder, Scope, web};
use serde::Deserialize;
use tokio::sync::broadcast;
use super::{alert_websocket, drop_websocket, flow_websocket, fusion_websocket, health_websocket};
use crate::adapter::ebpf::drop_monitor::DropMonitor;
use crate::core::identity::jwt::JwtService;
use crate::core::inference::alert::MLAlert;
use crate::infrastructure::communication_manager::CommunicationManager;
use crate::domain::common::event::ThreatDetectedEvent;
use crate::infrastructure::health::SystemHealth;
use crate::infrastructure::statistics::FlowStatistics;
@ -41,10 +42,7 @@ fn validate_ws_token(
Some(ref t) => jwt
.validate_token(t)
.map(|_| ())
.map_err(|_| {
HttpResponse::Unauthorized()
.json(serde_json::json!({"error": "Invalid or expired token"}))
}),
.map_err(|_| HttpResponse::Unauthorized().json(serde_json::json!({"error": "Invalid or expired token"}))),
None => Err(HttpResponse::Unauthorized()
.json(serde_json::json!({"error": "Missing authentication: provide Authorization header or token query parameter"}))),
}
@ -89,14 +87,14 @@ async fn alerts_ws(
async fn fusion_ws(
req: HttpRequest,
stream: web::Payload,
comm: web::Data<CommunicationManager>,
threat_tx: web::Data<broadcast::Sender<ThreatDetectedEvent>>,
query: web::Query<WsQuery>,
jwt: web::Data<JwtService>,
) -> impl Responder {
if let Err(resp) = validate_ws_token(&req, &query, &jwt) {
return resp;
}
match fusion_websocket::websocket_fusion(req, stream, comm).await {
match fusion_websocket::websocket_fusion(req, stream, threat_tx).await {
Ok(response) => response,
Err(err) => {
HttpResponse::InternalServerError().json(serde_json::json!({"error": format!("WebSocket error: {}", err)}))

View File

@ -3,192 +3,16 @@ use std::sync::Arc;
use arc_swap::ArcSwap;
use crate::domain::common::config::AppConfig;
use crate::domain::common::config::section::ConfigSection;
use crate::domain::common::error::Error;
use crate::domain::common::error::misc::MiscError;
use crate::interface::port::app_repo::AppRepo;
use crate::interface::port::secret_store::SecretStorePort;
/// Keys that must be routed through SecretStore instead of plaintext settings.
const SECRET_KEYS: &[&str] = &["smtp_password"];
/// Valid eBPF pipeline stage names.
const VALID_PIPELINE_STAGES: &[&str] = &["access_control", "rate_limit", "service"];
/// All configurable settings grouped by section.
const SETTINGS_MAP: &[(&str, &[&str])] = &[
(
"network",
&["ingress_interface", "egress_interface", "refresh_interval"],
),
("http", &["http_port", "jwt_expiry_hours", "force_https"]),
(
"inference",
&[
"max_concurrent_flows",
"min_packets_for_inference",
"inference_interval_secs",
"aggregator_window_secs",
"inference_batch_size",
"traffic_logging_mode",
"traffic_log_csv_path",
],
),
(
"xdp",
&[
"combined_queue_count",
"channel_size",
"fill_queue_size",
"comp_queue_size",
"tx_queue_size",
"rx_queue_size",
"frame_size",
"frame_count",
"packet_buffer_size",
"buffer_pool_capacity",
],
),
(
"models",
&["deep_autoencoder_name", "classifier_name", "models_config_name"],
),
// 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",
"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_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",
],
),
];
pub struct ConfigService {
db: Arc<dyn AppRepo>,
secrets: Option<Arc<dyn SecretStorePort>>,
@ -213,12 +37,12 @@ impl ConfigService {
let get = |key: &str| -> String { self.db.get_setting(key).ok().flatten().unwrap_or_default() };
let mut root = serde_json::Map::new();
for (section, keys) in SETTINGS_MAP {
for section in ConfigSection::ALL {
let mut section_obj = serde_json::Map::new();
for key in *keys {
for key in section.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(section.name().to_string(), serde_json::Value::Object(section_obj));
}
root.insert(
"pipeline".to_string(),
@ -235,9 +59,9 @@ impl ConfigService {
let mut new_cfg: Option<AppConfig> = None;
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 {
for section in ConfigSection::ALL {
if let Some(section_obj) = body.get(section.name()).and_then(|v| v.as_object()) {
for key in section.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());

View File

@ -1,6 +1,6 @@
use std::collections::VecDeque;
use std::fmt::{Arguments, Debug, Write as _};
use std::sync::OnceLock;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
@ -11,12 +11,6 @@ use tracing::{Event, Level, Subscriber};
use tracing_subscriber::Layer;
use tracing_subscriber::layer::Context;
/// 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);
static BUFFER: OnceLock<LogRingBuffer> = OnceLock::new();
#[derive(Clone, Debug, Serialize)]
pub struct LogEntry {
pub id: u64,
@ -39,6 +33,7 @@ struct LogRingBuffer {
entries: Mutex<VecDeque<LogEntry>>,
capacity: usize,
max_message_bytes: usize,
next_id: AtomicU64,
}
impl LogRingBuffer {
@ -48,6 +43,7 @@ impl LogRingBuffer {
entries: Mutex::new(VecDeque::with_capacity(cap)),
capacity: cap,
max_message_bytes: max_message_bytes.max(64),
next_id: AtomicU64::new(1),
}
}
@ -83,20 +79,14 @@ pub struct Snapshot {
pub total: usize,
}
/// Get a snapshot for the `/api/logs/live` endpoint.
///
/// `min_severity` follows tracing level numeric ordering (ERROR=1…TRACE=5);
/// an entry at level L is included when `level_severity(L) <= min_severity`.
/// Returns an empty snapshot when the buffer has not been installed yet
/// (tests, dry-runs).
pub fn snapshot(since_id: u64, min_severity: u8, limit: usize) -> Snapshot {
match BUFFER.get() {
Some(buf) => buf.snapshot(since_id, min_severity, limit),
None => Snapshot {
entries: Vec::new(),
latest_id: 0,
total: 0,
},
/// Handle for reading from the log ring buffer.
pub struct LogBuffer {
inner: Arc<LogRingBuffer>,
}
impl LogBuffer {
pub fn snapshot(&self, since_id: u64, min_severity: u8, limit: usize) -> Snapshot {
self.inner.snapshot(since_id, min_severity, limit)
}
}
@ -131,36 +121,36 @@ fn now_unix_ms() -> u64 {
/// [`Layer`] that appends each formatted event into the in-memory ring
/// buffer so the UI can tail logs without round-tripping the filesystem.
pub struct LogBufferLayer;
pub struct LogBufferLayer {
inner: Arc<LogRingBuffer>,
}
impl LogBufferLayer {
pub fn new(capacity: usize, max_message_bytes: usize) -> Self {
let _ = BUFFER.set(LogRingBuffer::new(capacity, max_message_bytes));
Self
pub fn new(capacity: usize, max_message_bytes: usize) -> (Self, LogBuffer) {
let inner = Arc::new(LogRingBuffer::new(capacity, max_message_bytes));
let handle = LogBuffer { inner: inner.clone() };
(Self { inner }, handle)
}
}
impl<S: Subscriber> Layer<S> for LogBufferLayer {
fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
let Some(buf) = BUFFER.get() else {
return;
};
let metadata = event.metadata();
let mut visitor = MessageVisitor::default();
event.record(&mut visitor);
let mut message = visitor.into_message();
if message.len() > buf.max_message_bytes {
message.truncate(buf.max_message_bytes);
if message.len() > self.inner.max_message_bytes {
message.truncate(self.inner.max_message_bytes);
message.push_str("…[truncated]");
}
let entry = LogEntry {
id: NEXT_ID.fetch_add(1, Ordering::Relaxed),
id: self.inner.next_id.fetch_add(1, Ordering::Relaxed),
ts_ms: now_unix_ms(),
level: level_str(metadata.level()),
target: metadata.target().to_string(),
message,
};
buf.push(entry);
self.inner.push(entry);
}
}

View File

@ -1,3 +1,3 @@
pub mod config_service;
pub mod log_buffer;
pub mod notification_service;
pub mod observability;

View File

@ -1 +0,0 @@
pub mod log_buffer;

View File

@ -74,13 +74,13 @@ impl CorrelationEngine {
fn process_alert(&self, alert: &AlertMessage) {
if let Some(event) = self.botnet.process(alert) {
let _ = self.detection_tx.try_send(event);
send_or_log(&self.detection_tx, event);
}
if let Some(event) = self.scan.process(alert) {
let _ = self.detection_tx.try_send(event);
send_or_log(&self.detection_tx, event);
}
if let Some(event) = self.lateral.process(alert) {
let _ = self.detection_tx.try_send(event);
send_or_log(&self.detection_tx, event);
}
}
@ -91,3 +91,13 @@ impl CorrelationEngine {
}
}
}
fn send_or_log(tx: &mpsc::Sender<DetectionEvent>, event: DetectionEvent) {
if let Err(mpsc::error::TrySendError::Full(dropped)) = tx.try_send(event) {
log!(DetectionLog::DetectionChannelDrop(
format!("{:?}", dropped.source),
dropped.attack_type,
dropped.source_ip,
));
}
}

View File

@ -60,7 +60,13 @@ impl BeaconingDetector {
}
_ = analysis_interval.tick() => {
for event in self.state.analyze() {
let _ = self.detection_tx.try_send(event);
if let Err(mpsc::error::TrySendError::Full(d)) = self.detection_tx.try_send(event) {
log!(DetectionLog::DetectionChannelDrop(
format!("{:?}", d.source),
d.attack_type,
d.source_ip,
));
}
}
self.state.cleanup();
}

View File

@ -5,25 +5,19 @@ use std::time::{Duration, Instant};
use arc_swap::ArcSwap;
use lru::LruCache;
use macros::log;
use tokio::sync::broadcast;
use tokio::sync::mpsc;
use tokio::time::interval;
use crate::domain::common::config::AppConfig;
use crate::domain::common::error::system::SystemError;
use crate::domain::common::config::constants::{FUSION_AUDIT_ACTION, FUSION_AUDIT_ACTOR};
use crate::domain::common::event::{AuditEvent, DetectionEvent, DetectionSource, ThreatDetectedEvent};
use crate::domain::detection::attack_type::translate;
use crate::domain::detection::fusion_math::{FusionWindowLengths, fused_confidence};
use crate::domain::detection::log::DetectionLog;
use crate::domain::detection::metrics::FusionMetrics;
use crate::infrastructure::communication_manager::CommunicationManager;
use crate::interface::port::geo_lookup::GeoLookup;
/// Actor recorded on every fusion-chain WORM entry. Stable across releases —
/// downstream audit tooling filters on this string.
const FUSION_AUDIT_ACTOR: &str = "FusionEngine";
/// Action recorded on every fusion-chain WORM entry. Stable across releases.
const FUSION_AUDIT_ACTION: &str = "fused_threat_emitted";
/// Per-source record within an in-flight dedup entry. Keeps the strongest
/// confidence per source so multi-hit from one source doesn't inflate the
/// fused policy. `local_attack_type` is the raw source-specific label seen
@ -55,7 +49,8 @@ struct DedupEntry {
/// re-emit with the combined confidence `1 ∏(1 c_i)`.
pub struct DetectionOrchestrator {
rx: mpsc::Receiver<DetectionEvent>,
comm: Arc<CommunicationManager>,
threat_tx: broadcast::Sender<ThreatDetectedEvent>,
audit_tx: broadcast::Sender<AuditEvent>,
geoip: Option<Arc<dyn GeoLookup>>,
metrics: Arc<FusionMetrics>,
// Enrichment state
@ -75,7 +70,8 @@ impl DetectionOrchestrator {
pub fn new(
app_config: &Arc<ArcSwap<AppConfig>>,
rx: mpsc::Receiver<DetectionEvent>,
comm: Arc<CommunicationManager>,
threat_tx: broadcast::Sender<ThreatDetectedEvent>,
audit_tx: broadcast::Sender<AuditEvent>,
geoip: Option<Arc<dyn GeoLookup>>,
metrics: Arc<FusionMetrics>,
) -> Self {
@ -84,7 +80,8 @@ impl DetectionOrchestrator {
let max_dedup = NonZero::new(fusion.max_dedup_entries.max(1)).unwrap_or(NonZero::<usize>::MIN);
Self {
rx,
comm,
threat_tx,
audit_tx,
geoip,
metrics,
// SAFETY: NonZero::new on non-zero literals.
@ -256,9 +253,7 @@ impl DetectionOrchestrator {
self.publish_fusion_audit(trigger_event, fused, &per_source_samples)
.await;
if let Err(e) = self.comm.publish_event(threat_event).await {
log!(SystemError::MlSoarBridgeFailed(e));
}
let _ = self.threat_tx.send(threat_event);
}
/// Emit a WORM AuditEvent so the eventual "why was this IP blocked?"
@ -272,9 +267,7 @@ impl DetectionOrchestrator {
detail: build_fusion_audit_detail(&trigger_event.source_ip, &trigger_event.attack_type, fused, per_source),
};
if let Err(e) = self.comm.publish_event(audit).await {
log!(DetectionLog::FusionAuditPublishFailed(e.to_string()));
}
let _ = self.audit_tx.send(audit);
}
async fn enrich(&mut self, event: &DetectionEvent) -> ThreatDetectedEvent {
@ -363,7 +356,7 @@ impl DetectionOrchestrator {
/// Serialize the WORM audit evidence payload for a fused threat emission.
/// Extracted as a free function so tests can cover schema shape without a
/// live CommunicationManager harness.
/// live broadcast harness.
fn build_fusion_audit_detail(src_ip: &str, attack_type: &str, fused: f32, per_source: &[SourceSample]) -> String {
let per_source_json: Vec<serde_json::Value> = per_source
.iter()
@ -386,8 +379,7 @@ fn build_fusion_audit_detail(src_ip: &str, attack_type: &str, fused: f32, per_so
#[cfg(test)]
mod tests {
//! Orchestrator integration tests require an in-memory CommunicationManager
//! harness. Until then, the fusion math lives in `fusion_math::tests`,
//! Orchestrator unit tests. The fusion math lives in `fusion_math::tests`,
//! canonical translation in `model::detection::attack_type::tests`, and
//! the audit evidence schema is covered below.

View File

@ -212,16 +212,22 @@ impl Engine {
for tracker in &self.trackers {
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, self.min_packets_floor)
&& !Self::is_strong_benign(
&flow.flow_key,
flow.fwd_packets.len(),
flow.bwd_packets.len(),
flow.fwd_total_bytes + flow.bwd_total_bytes,
)
}));
all_flows.extend(
tracker
.get_uninferred_flows(self.batch_size)
.into_iter()
.filter(|flow| {
let total_packets = flow.packet_count();
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(),
flow.bwd_packets.len(),
flow.fwd_total_bytes + flow.bwd_total_bytes,
)
}),
);
}
log!(MLLog::FlowStats(

View File

@ -235,7 +235,7 @@ impl Inference {
}
});
match run_classifier_batch(classifier, &cls_input, actual) {
match run_classifier_batch(classifier, cls_input, actual) {
Ok((anomaly, class_probs, c2)) => {
all_anomaly.extend_from_slice(&anomaly);
all_class_probs.extend(class_probs);
@ -295,7 +295,11 @@ impl Inference {
}
results.push(DetectionResult {
flow_key: build_flow_key_label(flow),
flow_key: if is_attack {
build_flow_key_label(flow)
} else {
String::new()
},
flow_key_raw: flow.flow_key.clone(),
direction: flow.direction,
is_attack,
@ -323,18 +327,17 @@ impl Inference {
flows: &[FlowData],
) -> Vec<DetectionResult> {
let n = flows.len();
let all_features: Vec<Vec<f32>> = flows.iter().map(|f| self.preprocess_ae_features(f)).collect();
let mut scores = Vec::with_capacity(n);
for chunk_start in (0..n).step_by(batch_size) {
let chunk_end = (chunk_start + batch_size).min(n);
let actual = chunk_end - chunk_start;
let chunk_features: Vec<Vec<f32>> = flows[chunk_start..chunk_end]
.iter()
.map(|f| self.preprocess_ae_features(f))
.collect();
let input = tract_ndarray::Array2::<f32>::from_shape_fn((batch_size, n_features), |(i, j)| {
if i < actual {
all_features[chunk_start + i][j]
} else {
0.0
}
if i < actual { chunk_features[i][j] } else { 0.0 }
});
match run_ae_batch(model, &input, actual, n_features) {
Ok(s) => scores.extend_from_slice(&s),
@ -353,7 +356,11 @@ impl Inference {
.map(|(flow, &score)| {
let is_attack = score > thr;
DetectionResult {
flow_key: build_flow_key_label(flow),
flow_key: if is_attack {
build_flow_key_label(flow)
} else {
String::new()
},
flow_key_raw: flow.flow_key.clone(),
direction: flow.direction,
is_attack,
@ -383,21 +390,20 @@ impl Inference {
flows: &[FlowData],
) -> Vec<DetectionResult> {
let n = flows.len();
let all_features: Vec<Vec<f32>> = flows.iter().map(|f| self.preprocess_ae_features(f)).collect();
let class_min_conf = self.config.class_min_confidence;
let mut results = Vec::with_capacity(n);
for chunk_start in (0..n).step_by(batch_size) {
let chunk_end = (chunk_start + batch_size).min(n);
let actual = chunk_end - chunk_start;
let chunk_features: Vec<Vec<f32>> = flows[chunk_start..chunk_end]
.iter()
.map(|f| self.preprocess_ae_features(f))
.collect();
let input = tract_ndarray::Array2::<f32>::from_shape_fn((batch_size, n_features), |(i, j)| {
if i < actual {
all_features[chunk_start + i][j]
} else {
0.0
}
if i < actual { chunk_features[i][j] } else { 0.0 }
});
let class_probs = match run_classifier_only_batch(model, &input, actual) {
let class_probs = match run_classifier_only_batch(model, input, actual) {
Ok(cp) => cp,
Err(e) => {
log!(MLLog::InferenceFailed("ClassifierOnly".to_string(), e.to_string()));
@ -419,7 +425,11 @@ impl Inference {
"Normal".to_string()
};
results.push(DetectionResult {
flow_key: build_flow_key_label(flow),
flow_key: if is_attack {
build_flow_key_label(flow)
} else {
String::new()
},
flow_key_raw: flow.flow_key.clone(),
direction: flow.direction,
is_attack,
@ -548,10 +558,10 @@ fn run_ae_batch(
/// Run a 3-output multi-task classifier: (anomaly, class_probs, c2_score).
fn run_classifier_batch(
model: &RunnableModel,
input: &tract_ndarray::Array2<f32>,
input: tract_ndarray::Array2<f32>,
actual: usize,
) -> TractResult<ClassifierBatchOutput> {
let result = model.run(tvec![input.clone().into_tensor().into()])?;
let result = model.run(tvec![input.into_tensor().into()])?;
let anomaly_view = result[0].to_array_view::<f32>()?;
let anomaly: Vec<f32> = (0..actual)
@ -576,10 +586,10 @@ fn run_classifier_batch(
/// Run a single-output classifier (ClassifierOnly adapter).
fn run_classifier_only_batch(
model: &RunnableModel,
input: &tract_ndarray::Array2<f32>,
input: tract_ndarray::Array2<f32>,
actual: usize,
) -> TractResult<Vec<Vec<f32>>> {
let result = model.run(tvec![input.clone().into_tensor().into()])?;
let result = model.run(tvec![input.into_tensor().into()])?;
let view = result[0]
.to_array_view::<f32>()?
.into_dimensionality::<tract_ndarray::Ix2>()?;

View File

@ -6,7 +6,7 @@
//!
//! On FIFO failure (permissions, I/O error) the writer shuts down
//! cleanly, leaves inference untouched, logs through `MLLog`, and
//! (when a `CommunicationManager` is wired in) also publishes a WORM
//! (when an `audit_tx` sender is wired in) also publishes a WORM
//! `AuditEvent` so the chain records an auditor-visible reason the
//! recording stopped, not just a tracing line that may be lost.
//! Callers see the channel disconnect and stop sending rows.
@ -21,31 +21,17 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use crossbeam::channel::{Receiver, Sender, TrySendError, bounded};
use macros::log;
use tokio::sync::broadcast;
use crate::domain::common::config::constants::{FLOW_TRACE_FILE_EXT, FLOW_TRACE_FILE_MARKER};
use crate::domain::common::event::AuditEvent;
use crate::domain::detection::error::MLError;
use crate::domain::detection::log::MLLog;
use crate::infrastructure::communication_manager::CommunicationManager;
/// Default per-file size cap. A single CSV file won't grow past this
/// before the writer rolls to a fresh one.
pub const DEFAULT_MAX_FILE_BYTES: u64 = 500 * 1024 * 1024;
/// Default per-file age cap. Forces a roll even if the size cap
/// hasn't been hit so analysts have bounded-age shards to download.
pub const DEFAULT_MAX_FILE_AGE: Duration = Duration::from_secs(3600);
/// Default retained-bytes budget across every `flow-trace-*.csv` in
/// the directory. When the total exceeds this, the writer FIFO-deletes
/// the oldest files to bring the sum back under the cap.
pub const DEFAULT_TOTAL_BUDGET_BYTES: u64 = 10 * 1024 * 1024 * 1024;
/// 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-";
/// Suffix literal appended to every rotated file.
pub const FLOW_TRACE_FILE_EXT: &str = ".csv";
/// Actor recorded on the WORM `flow_trace_stopped` audit entry. Stable
/// wire string — auditors filter on it to separate system-internal
/// recording stoppages from administrator-initiated actions. Matches
@ -83,7 +69,7 @@ pub struct TrafficLogger {
impl TrafficLogger {
/// Build a rotating writer rooted at `base_path`'s parent. Any
/// existing `flow-trace-*.csv` in that directory participates in
/// the FIFO budget. `comm` is optional so tests (and paths where
/// the FIFO budget. `audit_tx` is optional so tests (and paths where
/// the bus isn't wired yet) can exercise the rotation logic without
/// the event-bus dependency; production code always passes `Some`.
pub fn new(
@ -91,7 +77,7 @@ impl TrafficLogger {
header: Vec<String>,
policy: RotationPolicy,
channel_capacity: usize,
comm: Option<Arc<CommunicationManager>>,
audit_tx: Option<broadcast::Sender<AuditEvent>>,
) -> Result<Self, io::Error> {
let directory = base_path
.parent()
@ -107,7 +93,7 @@ impl TrafficLogger {
thread::Builder::new()
.name("traffic-logger".to_string())
.spawn(move || {
writer_loop(receiver, writer_dir, writer_header, writer_policy, comm);
writer_loop(receiver, writer_dir, writer_header, writer_policy, audit_tx);
})?;
Ok(Self {
@ -202,14 +188,14 @@ fn writer_loop(
directory: PathBuf,
header: Vec<String>,
policy: RotationPolicy,
comm: Option<Arc<CommunicationManager>>,
audit_tx: Option<broadcast::Sender<AuditEvent>>,
) {
let mut active = match open_new_file(&directory, &header) {
Ok(a) => a,
Err(e) => {
let reason = e.to_string();
log!(MLLog::FlowTraceStopped(reason.clone()));
emit_flow_trace_stop_audit(comm.as_ref(), &reason, &directory);
emit_flow_trace_stop_audit(audit_tx.as_ref(), &reason, &directory);
return;
}
};
@ -227,7 +213,7 @@ fn writer_loop(
// see the disconnect and stop trying.
let reason = format!("FIFO sweep failed: {e}");
log!(MLLog::FlowTraceStopped(reason.clone()));
emit_flow_trace_stop_audit(comm.as_ref(), &reason, &directory);
emit_flow_trace_stop_audit(audit_tx.as_ref(), &reason, &directory);
return;
}
active = match open_new_file(&directory, &header) {
@ -235,7 +221,7 @@ fn writer_loop(
Err(e) => {
let reason = format!("rotate failed: {e}");
log!(MLLog::FlowTraceStopped(reason.clone()));
emit_flow_trace_stop_audit(comm.as_ref(), &reason, &directory);
emit_flow_trace_stop_audit(audit_tx.as_ref(), &reason, &directory);
return;
}
};
@ -256,14 +242,14 @@ fn writer_loop(
/// Publish a WORM `flow_trace_stopped` audit entry so an auditor can
/// later see why Flow Trace recording went dormant without grepping
/// process logs. A `None` bus (tests, or pre-wiring paths) is a
/// process logs. A `None` sender (tests, or pre-wiring paths) is a
/// deliberate no-op — the sibling `MLLog::FlowTraceStopped` tracing
/// line still fires in both cases.
///
/// Extracted as a free function so tests can cover the emit path
/// without driving a full writer-loop + failing-disk fixture.
fn emit_flow_trace_stop_audit(comm: Option<&Arc<CommunicationManager>>, reason: &str, directory: &Path) {
let Some(c) = comm else {
fn emit_flow_trace_stop_audit(audit_tx: Option<&broadcast::Sender<AuditEvent>>, reason: &str, directory: &Path) {
let Some(tx) = audit_tx else {
return;
};
let detail = serde_json::json!({
@ -271,7 +257,7 @@ fn emit_flow_trace_stop_audit(comm: Option<&Arc<CommunicationManager>>, reason:
"directory": directory.display().to_string(),
})
.to_string();
let _ = c.publish_event_sync(AuditEvent {
let _ = tx.send(AuditEvent {
actor: AUDIT_ACTOR_SYSTEM.to_string(),
action: AUDIT_ACTION_FLOW_TRACE_STOPPED.to_string(),
detail,
@ -415,13 +401,11 @@ mod tests {
}
#[tokio::test]
async fn stop_audit_reaches_subscriber_when_comm_provided() {
let comm = Arc::new(CommunicationManager::new(256));
comm.register_event_type::<AuditEvent>();
let mut rx = comm.subscribe_event::<AuditEvent>().unwrap();
async fn stop_audit_reaches_subscriber_when_sender_provided() {
let (tx, mut rx) = broadcast::channel::<AuditEvent>(256);
let dir = scratch_dir("audit-emit");
emit_flow_trace_stop_audit(Some(&comm), "FIFO sweep failed: perm denied", &dir);
emit_flow_trace_stop_audit(Some(&tx), "FIFO sweep failed: perm denied", &dir);
let event = rx.recv().await.expect("audit event must be delivered");
assert_eq!(event.actor, "system");
@ -433,7 +417,7 @@ mod tests {
}
#[test]
fn stop_audit_noop_when_comm_absent() {
fn stop_audit_noop_when_sender_absent() {
// Passing None is the explicit test-mode path — must not panic.
emit_flow_trace_stop_audit(None, "any reason", Path::new("/tmp/anywhere"));
}

View File

@ -15,11 +15,9 @@ use crate::domain::common::error::Error;
use crate::domain::common::event::ThreatDetectedEvent;
use crate::domain::detection::attack_type::canonical_from_str;
use crate::domain::response::condition::{ConditionType, PlaybookCondition};
use crate::domain::response::error::SoarError;
use crate::domain::response::log::SoarLog;
use crate::domain::response::matcher::PlaybookMatcher;
use crate::domain::response::playbook::{Playbook, PlaybookAction};
use crate::infrastructure::communication_manager::CommunicationManager;
use crate::interface::port::access_control::AccessControlPort;
use crate::interface::port::app_repo::AppRepo;
use crate::interface::port::geo_lookup::GeoLookup;
@ -61,7 +59,7 @@ impl SoarEngine {
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 rate_limit_owner = rate_limit.map(|rl| RateLimitOwnerHandle::spawn(rl, config.clone(), rate_limit_channel));
let matcher = PlaybookMatcher::new(config, freq_max_keys);
let engine = Self {
db,
@ -202,19 +200,10 @@ impl SoarEngine {
}
/// Subscribe to ThreatDetectedEvent and start processing.
/// Returns an error if subscription fails — caller must handle this as a critical failure.
pub fn start(self: Arc<Self>, comm: Arc<CommunicationManager>) -> Result<(), Error> {
let rx = comm.subscribe_event::<ThreatDetectedEvent>().map_err(|e| {
log!(SoarLog::EventHandlingFailed(format!(
"CRITICAL: SOAR engine failed to subscribe — automated threat response is DISABLED: {}",
e
)));
SoarError::ActionFailed("subscribe", e)
})?;
pub fn start(self: Arc<Self>, threat_rx: broadcast::Receiver<ThreatDetectedEvent>) {
tokio::spawn(async move {
Self::event_loop(self, rx).await;
Self::event_loop(self, threat_rx).await;
});
Ok(())
}
async fn event_loop(self: Arc<Self>, mut rx: broadcast::Receiver<ThreatDetectedEvent>) {

View File

@ -1,29 +1,20 @@
//! Owner task that serializes SOAR rate-limit adjustments.
//!
//! The "adjust" and "restore" sequences each touch two systems back-to-back
//! (the `soar_rate_limit_*` settings rows and the eBPF `RATE_LIMIT_CONFIG`
//! map). The atomicity that the original `TokioMutex<()>` was protecting is
//! exactly "no other adjust/restore interleaves between the read and the
//! write" — a SQLite transaction can't cover the eBPF half, so we move the
//! read-modify-write inside a single tokio task that owns both ports. All
//! callers dispatch over an mpsc channel and wait on a one-shot reply.
use std::sync::Arc;
use std::time::{Duration, Instant};
use chrono::{Duration as ChronoDuration, NaiveDateTime, Utc};
use arc_swap::ArcSwap;
use macros::log;
use tokio::sync::{mpsc, oneshot};
use crate::domain::common::config::AppConfig;
use crate::domain::common::error::Error;
use crate::domain::response::error::SoarError;
use crate::domain::response::log::SoarLog;
use crate::interface::port::app_repo::AppRepo;
use crate::interface::port::rate_limit_api::RateLimitPort;
/// 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";
const KEY_EXPIRES: &str = "soar_rate_limit_expires";
struct ActiveAdjustment {
original: [u64; 4],
expires: Instant,
}
enum RateLimitCmd {
Adjust {
@ -38,25 +29,18 @@ enum RateLimitCmd {
},
}
/// Lock-free handle to the rate-limit owner task. Cloning is cheap (just an
/// `mpsc::Sender`).
#[derive(Clone)]
pub struct RateLimitOwnerHandle {
tx: mpsc::Sender<RateLimitCmd>,
}
impl RateLimitOwnerHandle {
/// Spawn the owner task on the current tokio runtime. The owner holds
/// the only mutating references to the DB rate-limit settings and the
/// eBPF rate-limit map for the duration of an Adjust / Restore batch.
///
/// Each command body runs inside `spawn_blocking` because both halves
/// (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>, channel_capacity: usize) -> Self {
pub fn spawn(rate_limit: Arc<dyn RateLimitPort>, config: Arc<ArcSwap<AppConfig>>, channel_capacity: usize) -> Self {
let (tx, mut rx) = mpsc::channel::<RateLimitCmd>(channel_capacity.max(1));
tokio::spawn(async move {
let mut state: Option<ActiveAdjustment> = None;
while let Some(cmd) = rx.recv().await {
match cmd {
RateLimitCmd::Adjust {
@ -66,25 +50,72 @@ impl RateLimitOwnerHandle {
attack_type,
reply,
} => {
let db_c = db.clone();
let rl_c = rate_limit.clone();
let cfg = config.load_full();
let current_state = state.is_some();
let join = tokio::task::spawn_blocking(move || {
adjust(&db_c, rl_c.as_ref(), factor, ttl_secs, &source_ip, &attack_type)
if current_state {
Ok(None)
} else {
first_adjust(rl_c.as_ref(), &cfg, factor).map(Some)
}
})
.await;
let expires = Instant::now() + Duration::from_secs(ttl_secs);
let result = match join {
Ok(r) => r,
Ok(Ok(Some(original))) => {
log!(SoarLog::RateLimitAdjusted(
format!("{factor}"),
ttl_secs,
source_ip.clone(),
attack_type,
format_change(&original),
));
state = Some(ActiveAdjustment {
original: original.new_snapshot,
expires,
});
Ok(format!(
"Rate limits reduced by factor {factor} for {ttl_secs}s (triggered by {source_ip})"
))
}
Ok(Ok(None)) => {
if let Some(ref mut s) = state {
s.expires = expires;
}
log!(SoarLog::RateLimitAdjusted(
format!("{factor}"),
ttl_secs,
source_ip.clone(),
attack_type,
"TTL extended (rates already reduced)".to_string(),
));
Ok(format!(
"Rate limit TTL extended by {ttl_secs}s (triggered by {source_ip})"
))
}
Ok(Err(e)) => Err(e),
Err(e) => Err(SoarError::RateLimitOwnerJoinFailed(e.to_string()).into()),
};
let _ = reply.send(result);
}
RateLimitCmd::RestoreIfExpired { reply } => {
let db_c = db.clone();
let rl_c = rate_limit.clone();
let join = tokio::task::spawn_blocking(move || restore_if_expired(&db_c, rl_c.as_ref())).await;
let result = match join {
Ok(r) => r,
Err(e) => Err(SoarError::RateLimitOwnerJoinFailed(e.to_string()).into()),
let result = match &state {
Some(adj) if Instant::now() >= adj.expires => {
let original = adj.original;
let rl_c = rate_limit.clone();
let join = tokio::task::spawn_blocking(move || restore(rl_c.as_ref(), &original)).await;
match join {
Ok(Ok(())) => {
state = None;
Ok(())
}
Ok(Err(e)) => Err(e),
Err(e) => Err(SoarError::RateLimitOwnerJoinFailed(e.to_string()).into()),
}
}
_ => Ok(()),
};
let _ = reply.send(result);
}
@ -125,118 +156,65 @@ impl RateLimitOwnerHandle {
}
}
fn adjust(
db: &Arc<dyn AppRepo>,
rate_limit: &dyn RateLimitPort,
factor: f64,
ttl_secs: u64,
source_ip: &str,
attack_type: &str,
) -> Result<String, Error> {
let current_packet = rate_limit.get_packet_rate().unwrap_or(10000);
let current_syn = rate_limit.get_syn_rate().unwrap_or(1000);
let current_udp = rate_limit.get_udp_rate().unwrap_or(5000);
let current_dns = rate_limit.get_dns_rate().unwrap_or(2000);
if db.get_setting(KEY_ORIGINAL)?.filter(|s| !s.is_empty()).is_none() {
let original = serde_json::json!({
"packet_rate": current_packet,
"syn_rate": current_syn,
"udp_rate": current_udp,
"dns_rate": current_dns,
});
db.set_setting(KEY_ORIGINAL, &original.to_string())?;
}
let expires_at = Utc::now() + ChronoDuration::seconds(ttl_secs as i64);
db.set_setting(KEY_EXPIRES, &expires_at.format("%Y-%m-%d %H:%M:%S").to_string())?;
let new_packet = (current_packet as f64 * factor) as u64;
let new_syn = (current_syn as f64 * factor) as u64;
let new_udp = (current_udp as f64 * factor) as u64;
let new_dns = (current_dns as f64 * factor) as u64;
rate_limit.set_packet_rate(new_packet.max(1))?;
rate_limit.set_syn_rate(new_syn.max(1))?;
rate_limit.set_udp_rate(new_udp.max(1))?;
rate_limit.set_dns_rate(new_dns.max(1))?;
log!(SoarLog::RateLimitAdjusted(
format!("{}", factor),
ttl_secs,
source_ip.to_string(),
attack_type.to_string(),
format!(
"packet {}→{}, syn {}→{}, udp {}→{}, dns {}→{}",
current_packet,
new_packet.max(1),
current_syn,
new_syn.max(1),
current_udp,
new_udp.max(1),
current_dns,
new_dns.max(1),
),
));
Ok(format!(
"Rate limits reduced by factor {} for {}s (triggered by {})",
factor, ttl_secs, source_ip
))
struct AdjustResult {
orig: [u64; 4],
new_snapshot: [u64; 4],
}
fn restore_if_expired(db: &Arc<dyn AppRepo>, rate_limit: &dyn RateLimitPort) -> Result<(), Error> {
let expires_str = match db.get_setting(KEY_EXPIRES)?.filter(|s| !s.is_empty()) {
Some(s) => s,
None => return Ok(()),
};
fn first_adjust(rate_limit: &dyn RateLimitPort, config: &AppConfig, factor: f64) -> Result<AdjustResult, Error> {
let ebpf = &config.ebpf;
let orig = [
rate_limit.get_packet_rate().unwrap_or(ebpf.default_packet_rate),
rate_limit.get_syn_rate().unwrap_or(ebpf.default_syn_rate),
rate_limit.get_udp_rate().unwrap_or(ebpf.default_udp_rate),
rate_limit.get_dns_rate().unwrap_or(ebpf.default_dns_rate),
];
let expires = NaiveDateTime::parse_from_str(&expires_str, "%Y-%m-%d %H:%M:%S")
.map(|dt| dt.and_utc())
.unwrap_or_else(|_| Utc::now());
let new: [u64; 4] = std::array::from_fn(|i| ((orig[i] as f64 * factor) as u64).max(1));
if Utc::now() < expires {
return Ok(());
rate_limit.set_packet_rate(new[0])?;
rate_limit.set_syn_rate(new[1])?;
rate_limit.set_udp_rate(new[2])?;
rate_limit.set_dns_rate(new[3])?;
Ok(AdjustResult {
orig,
new_snapshot: orig,
})
}
fn restore(rate_limit: &dyn RateLimitPort, original: &[u64; 4]) -> Result<(), Error> {
let mut errors = Vec::new();
if let Err(e) = rate_limit.set_packet_rate(original[0]) {
errors.push(format!("packet_rate: {e}"));
}
let original_str = match db.get_setting(KEY_ORIGINAL)?.filter(|s| !s.is_empty()) {
Some(s) => s,
None => {
db.set_setting(KEY_EXPIRES, "")?;
return Ok(());
}
};
if let Ok(original) = serde_json::from_str::<serde_json::Value>(&original_str) {
let mut restore_errors = Vec::new();
if let Some(v) = original.get("packet_rate").and_then(|v| v.as_u64())
&& let Err(e) = rate_limit.set_packet_rate(v)
{
restore_errors.push(format!("packet_rate: {}", e));
}
if let Some(v) = original.get("syn_rate").and_then(|v| v.as_u64())
&& let Err(e) = rate_limit.set_syn_rate(v)
{
restore_errors.push(format!("syn_rate: {}", e));
}
if let Some(v) = original.get("udp_rate").and_then(|v| v.as_u64())
&& let Err(e) = rate_limit.set_udp_rate(v)
{
restore_errors.push(format!("udp_rate: {}", e));
}
if let Some(v) = original.get("dns_rate").and_then(|v| v.as_u64())
&& let Err(e) = rate_limit.set_dns_rate(v)
{
restore_errors.push(format!("dns_rate: {}", e));
}
if restore_errors.is_empty() {
log!(SoarLog::RateLimitRestored);
} else {
log!(SoarLog::RateLimitRestoreFailed(restore_errors.join(", ")));
}
if let Err(e) = rate_limit.set_syn_rate(original[1]) {
errors.push(format!("syn_rate: {e}"));
}
if let Err(e) = rate_limit.set_udp_rate(original[2]) {
errors.push(format!("udp_rate: {e}"));
}
if let Err(e) = rate_limit.set_dns_rate(original[3]) {
errors.push(format!("dns_rate: {e}"));
}
if errors.is_empty() {
log!(SoarLog::RateLimitRestored);
} else {
log!(SoarLog::RateLimitRestoreFailed(errors.join(", ")));
}
db.set_setting(KEY_ORIGINAL, "")?;
db.set_setting(KEY_EXPIRES, "")?;
Ok(())
}
fn format_change(result: &AdjustResult) -> String {
format!(
"packet {}→{}, syn {}→{}, udp {}→{}, dns {}→{}",
result.orig[0],
result.new_snapshot[0],
result.orig[1],
result.new_snapshot[1],
result.orig[2],
result.new_snapshot[2],
result.orig[3],
result.new_snapshot[3],
)
}

View File

@ -1,27 +1,8 @@
use super::helpers::{override_string_nonempty, seed_key};
use crate::domain::common::error::Error;
use crate::interface::port::setting::SettingRepo;
use macros::config_settings;
#[config_settings(section = "misc")]
#[derive(Debug, Clone)]
pub struct AclConfig {
#[setting(key = "geoip_db_name", default = "net-guardia/static/geo/dbip-city-lite.mmdb")]
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

@ -1,25 +0,0 @@
use super::helpers::{override_parsed, seed_key};
use crate::domain::common::error::Error;
use crate::interface::port::setting::SettingRepo;
#[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

@ -21,3 +21,15 @@ pub const STAGING_SUBDIR: &str = ".staging";
/// 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;
// ── Audit ──────────────────────────────────────────────────────────
pub const FUSION_AUDIT_ACTOR: &str = "FusionEngine";
pub const FUSION_AUDIT_ACTION: &str = "fused_threat_emitted";
pub const AUDIT_ACTOR_SECURITY_ADMIN_PREFIX: &str = "SecurityAdmin";
// ── Flow Trace ─────────────────────────────────────────────────────
pub const FLOW_TRACE_FILE_MARKER: &str = "flow-trace-";
pub const FLOW_TRACE_FILE_EXT: &str = ".csv";
// ── Event Channels ────────────────────────────────────────────────
pub const EVENT_CHANNEL_CAPACITY: usize = 256;

View File

@ -1,64 +1,27 @@
use super::helpers::{override_parsed, seed_key};
use crate::domain::common::error::Error;
use crate::interface::port::setting::SettingRepo;
use macros::config_settings;
/// Shared shape for scan / lateral / botnet correlation detectors.
#[derive(Debug, Clone)]
pub struct CorrelationDetectorParams {
pub window_secs: u64,
pub threshold: usize,
}
#[config_settings(section = "correlation")]
#[setting(key = "correlation_scan_window_secs", default = "120", path = "scan.window_secs")]
#[setting(key = "correlation_scan_threshold", default = "20", path = "scan.threshold")]
#[setting(
key = "correlation_lateral_window_secs",
default = "300",
path = "lateral.window_secs"
)]
#[setting(key = "correlation_lateral_threshold", default = "5", path = "lateral.threshold")]
#[setting(key = "correlation_botnet_window_secs", default = "300", path = "botnet.window_secs")]
#[setting(key = "correlation_botnet_threshold", default = "10", path = "botnet.threshold")]
#[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.
#[setting(key = "correlation_max_tracked_entries", default = "10000")]
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

@ -1,119 +1,40 @@
use super::helpers::{override_parsed, seed_key};
use crate::domain::common::error::Error;
use crate::interface::port::setting::SettingRepo;
#[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(())
}
}
use macros::config_settings;
#[config_settings(section = "fusion")]
#[derive(Debug, Clone)]
pub struct FusionConfig {
#[setting(key = "fusion_dedup_window_secs", default = "30")]
pub dedup_window_secs: u64,
#[setting(key = "fusion_repeat_offender_window_secs", default = "7200")]
pub repeat_offender_window_secs: u64,
#[setting(key = "fusion_max_dedup_entries", default = "50000")]
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(())
}
}
#[config_settings(section = "beaconing")]
#[derive(Debug, Clone)]
pub struct BeaconingConfig {
#[setting(key = "beaconing_analysis_interval_secs", default = "30")]
pub analysis_interval_secs: u64,
#[setting(key = "beaconing_min_observations", default = "5")]
pub min_observations: usize,
#[setting(key = "beaconing_cv_threshold", default = "0.3")]
pub cv_threshold: f64,
#[setting(key = "beaconing_max_cache_entries", default = "50000")]
pub max_cache_entries: usize,
#[setting(key = "beaconing_expiry_secs", default = "600")]
pub expiry_secs: u64,
#[setting(key = "beaconing_alert_cooldown_secs", default = "300")]
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(())
}
#[config_settings(section = "detection")]
#[derive(Debug, Clone)]
pub struct DetectionConfig {
#[setting(flatten)]
pub fusion: FusionConfig,
#[setting(flatten)]
pub beaconing: BeaconingConfig,
#[setting(key = "detection_cleanup_interval_secs", default = "60")]
pub cleanup_interval_secs: u64,
}

View File

@ -1,27 +1,8 @@
use super::helpers::{override_parsed, seed_key};
use crate::domain::common::error::Error;
use crate::interface::port::setting::SettingRepo;
use macros::config_settings;
#[config_settings(section = "dns")]
#[derive(Debug, Clone)]
pub struct DnsFilterConfig {
#[setting(key = "dns_max_domains_per_request", default = "1000")]
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

@ -1,75 +1,45 @@
use super::helpers::{override_parsed, override_string, seed_key};
use crate::domain::common::error::Error;
use crate::interface::port::setting::SettingRepo;
use macros::config_settings;
#[config_settings]
#[derive(Debug, Clone)]
pub struct EbpfConfig {
// ── network ────────────────────────────────────────────────────
#[setting(section = "network", key = "ingress_interface", default = "eth0")]
pub ingress_ifname: String,
#[setting(section = "network", key = "egress_interface", default = "eth1")]
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,
#[setting(section = "network", key = "refresh_interval", default = "5")]
pub refresh_interval: u64,
// ── xdp ────────────────────────────────────────────────────────
#[setting(section = "xdp", key = "combined_queue_count", default = "1")]
pub combined_queue_count: u32,
#[setting(section = "xdp", key = "channel_size", default = "4096")]
pub channel_size: usize,
#[setting(section = "xdp", key = "fill_queue_size", default = "4096")]
pub fill_queue_size: u32,
#[setting(section = "xdp", key = "comp_queue_size", default = "4096")]
pub comp_queue_size: u32,
#[setting(section = "xdp", key = "tx_queue_size", default = "4096")]
pub tx_queue_size: u32,
#[setting(section = "xdp", key = "rx_queue_size", default = "4096")]
pub rx_queue_size: u32,
#[setting(section = "xdp", key = "frame_size", default = "4096")]
pub frame_size: u32,
#[setting(section = "xdp", key = "frame_count", default = "4096")]
pub frame_count: u32,
#[setting(section = "xdp", key = "packet_buffer_size", default = "2048")]
pub packet_buffer_size: usize,
#[setting(section = "xdp", key = "buffer_pool_capacity", default = "1024")]
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(())
}
// ── internal (not exposed in API) ──────────────────────────────
#[setting(section = "xdp", key = "default_packet_rate", default = "10000", api = false)]
pub default_packet_rate: u64,
#[setting(section = "xdp", key = "default_syn_rate", default = "100", api = false)]
pub default_syn_rate: u64,
#[setting(section = "xdp", key = "default_udp_rate", default = "5000", api = false)]
pub default_udp_rate: u64,
#[setting(section = "xdp", key = "default_dns_rate", default = "200", api = false)]
pub default_dns_rate: u64,
}

View File

@ -23,13 +23,6 @@ pub(in crate::domain) fn override_bool(target: &mut bool, repo: &dyn SettingRepo
Ok(())
}
pub(in crate::domain) 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(in crate::domain) fn override_string_nonempty(
target: &mut String,
repo: &dyn SettingRepo,

View File

@ -1,35 +1,14 @@
use super::helpers::{override_bool, override_csv, override_parsed, seed_key};
use crate::domain::common::error::Error;
use crate::interface::port::setting::SettingRepo;
use macros::config_settings;
#[config_settings(section = "http")]
#[derive(Debug, Clone)]
pub struct HttpServerConfig {
#[setting(key = "http_port", default = "8080")]
pub port: u16,
#[setting(key = "cors_allowed_origins", default = "", api = false)]
pub cors_allowed_origins: Vec<String>,
#[setting(key = "force_https", default = "false")]
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(())
}
#[setting(key = "jwt_expiry_hours", default = "24")]
pub jwt_expiry_hours: u64,
}

View File

@ -1,219 +1,89 @@
use super::helpers::{override_bool, override_parsed, override_string_nonempty, seed_key};
use crate::domain::common::error::Error;
use crate::interface::port::setting::SettingRepo;
use macros::config_settings;
#[config_settings]
#[derive(Debug, Clone)]
pub struct MlConfig {
// ── models ─────────────────────────────────────────────────────
#[setting(section = "models", key = "deep_autoencoder_name", default = "deep_autoencoder.onnx")]
pub deep_autoencoder_name: String,
#[setting(section = "models", key = "classifier_name", default = "classifier.onnx")]
pub classifier_name: String,
#[setting(section = "models", key = "models_config_name", default = "inference_config.json")]
pub models_config_name: String,
// ── inference ──────────────────────────────────────────────────
#[setting(section = "inference", key = "max_concurrent_flows", default = "10000")]
pub max_concurrent_flows: usize,
#[setting(section = "inference", key = "min_packets_for_inference", default = "5")]
pub min_packets_for_inference: usize,
pub min_packets_floor: usize,
#[setting(section = "inference", key = "inference_interval_secs", default = "5")]
pub inference_interval_secs: u64,
#[setting(section = "inference", key = "aggregator_window_secs", default = "30")]
pub aggregator_window_secs: u64,
#[setting(section = "inference", key = "inference_batch_size", default = "200")]
pub inference_batch_size: usize,
pub confirmation_window_fraction: u64,
#[setting(section = "inference", key = "traffic_logging_mode", default = "false")]
pub traffic_logging_mode: bool,
#[setting(section = "inference", key = "traffic_log_csv_path", default = "traffic_log.csv")]
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,
// ── ml ─────────────────────────────────────────────────────────
#[setting(section = "ml", key = "ml_min_packets_floor", default = "5")]
pub min_packets_floor: usize,
#[setting(section = "ml", key = "ml_confirmation_window_fraction", default = "2")]
pub confirmation_window_fraction: u64,
#[setting(section = "ml", key = "ml_drift_window_secs", default = "3600")]
pub drift_window_secs: u64,
#[setting(section = "ml", key = "ml_drift_max_snapshots", default = "10000")]
pub drift_max_snapshots: usize,
#[setting(section = "ml", key = "ml_drift_channel_capacity", default = "1024")]
pub drift_channel_capacity: usize,
#[setting(section = "ml", key = "ml_alert_channel_capacity", default = "1024")]
pub alert_channel_capacity: usize,
#[setting(section = "ml", key = "ml_circuit_breaker_threshold", default = "5")]
pub circuit_breaker_threshold: u32,
#[setting(section = "ml", key = "ml_circuit_breaker_window_secs", default = "60")]
pub circuit_breaker_window_secs: u64,
#[setting(section = "ml", key = "ml_circuit_breaker_cooldown_secs", default = "120")]
pub circuit_breaker_cooldown_secs: u64,
#[setting(section = "ml", key = "ml_onnx_load_timeout_secs", default = "5")]
pub onnx_load_timeout_secs: u64,
#[setting(section = "ml", key = "ml_model_watcher_debounce_secs", default = "5")]
pub model_watcher_debounce_secs: u64,
#[setting(section = "ml", key = "ml_flow_max_packets_per_direction", default = "1000")]
pub flow_max_packets_per_direction: usize,
#[setting(section = "ml", key = "ml_flow_max_periods", default = "1000")]
pub flow_max_periods: usize,
#[setting(section = "ml", key = "ml_flow_idle_threshold_us", default = "1000000")]
pub flow_idle_threshold_us: u64,
#[setting(section = "ml", key = "ml_flow_bulk_min_packets", default = "4")]
pub flow_bulk_min_packets: u64,
#[setting(section = "ml", key = "ml_flow_bulk_min_bytes", default = "1000")]
pub flow_bulk_min_bytes: u64,
#[setting(section = "ml", key = "ml_flow_idle_timeout_us", default = "120000000")]
pub flow_idle_timeout_us: u64,
#[setting(section = "ml", key = "ml_flow_terminated_timeout_us", default = "5000000")]
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(())
}
// ── flow_trace ─────────────────────────────────────────────────
#[setting(section = "flow_trace", key = "flow_trace_max_file_bytes", default = "524288000")]
pub flow_trace_max_file_bytes: u64,
#[setting(section = "flow_trace", key = "flow_trace_max_file_age_secs", default = "3600")]
pub flow_trace_max_file_age_secs: u64,
#[setting(
section = "flow_trace",
key = "flow_trace_total_budget_bytes",
default = "10737418240"
)]
pub flow_trace_total_budget_bytes: u64,
#[setting(section = "flow_trace", key = "traffic_logger_channel_capacity", default = "65536")]
pub traffic_logger_channel_capacity: usize,
// ── model_upload ───────────────────────────────────────────────
#[setting(section = "model_upload", key = "model_upload_max_onnx_bytes", default = "104857600")]
pub model_upload_max_onnx_bytes: usize,
#[setting(section = "model_upload", key = "model_upload_max_manifest_bytes", default = "65536")]
pub model_upload_max_manifest_bytes: usize,
#[setting(section = "model_upload", key = "model_upload_max_scaler_bytes", default = "65536")]
pub model_upload_max_scaler_bytes: usize,
}

View File

@ -1,5 +1,4 @@
pub mod acl;
pub mod auth;
pub mod constants;
pub mod correlation;
pub mod detection;
@ -11,12 +10,12 @@ pub mod ml;
pub mod notification;
pub mod observability;
pub mod pipeline;
pub mod section;
pub mod soar;
pub mod suricata;
pub mod system;
use crate::domain::common::config::acl::AclConfig;
use crate::domain::common::config::auth::AuthConfig;
use crate::domain::common::config::correlation::CorrelationConfig;
use crate::domain::common::config::detection::DetectionConfig;
use crate::domain::common::config::dns_filter::DnsFilterConfig;
@ -36,7 +35,6 @@ use crate::interface::port::setting::SettingRepo;
#[derive(Debug, Clone)]
pub struct AppConfig {
pub acl: AclConfig,
pub auth: AuthConfig,
pub correlation: CorrelationConfig,
pub detection: DetectionConfig,
pub dns_filter: DnsFilterConfig,
@ -55,7 +53,6 @@ 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)?,
@ -75,7 +72,6 @@ impl AppConfig {
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)?;
@ -172,7 +168,7 @@ mod tests {
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);
assert_eq!(cfg.http_server.jwt_expiry_hours, 24);
}
#[test]

View File

@ -1,95 +1,36 @@
use super::helpers::{override_parsed, override_string, seed_key};
use crate::domain::common::error::Error;
use crate::interface::port::setting::SettingRepo;
use macros::config_settings;
#[config_settings(section = "telegram")]
#[derive(Debug, Clone)]
pub struct TelegramConfig {
#[setting(key = "telegram_rate_limit_max_messages", default = "20")]
pub rate_limit_max_messages: u32,
#[setting(key = "telegram_rate_limit_window_secs", default = "60")]
pub rate_limit_window_secs: u32,
#[setting(key = "telegram_max_retries", default = "2")]
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(())
}
}
#[config_settings(section = "smtp")]
#[derive(Debug, Clone)]
pub struct SmtpConfig {
#[setting(key = "smtp_host", default = "")]
pub host: String,
#[setting(key = "smtp_port", default = "587")]
pub port: u16,
#[setting(key = "smtp_username", default = "")]
pub username: String,
#[setting(key = "smtp_sender", default = "")]
pub sender: String,
#[setting(key = "smtp_recipient", default = "")]
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(())
}
}
#[config_settings]
#[derive(Debug, Clone)]
pub struct NotificationConfig {
#[setting(flatten)]
pub telegram: TelegramConfig,
#[setting(flatten)]
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

@ -1,71 +1,26 @@
use super::helpers::{override_parsed, seed_key};
use crate::domain::common::error::Error;
use crate::interface::port::setting::SettingRepo;
use macros::config_settings;
#[config_settings(section = "observability")]
#[derive(Debug, Clone)]
pub struct ObservabilityConfig {
#[setting(key = "log_level", default = "info", default_debug = "debug")]
pub log_level: String,
#[setting(key = "log_buffer_capacity", default = "5000")]
pub log_buffer_capacity: usize,
#[setting(key = "log_buffer_max_message_bytes", default = "8192")]
pub log_buffer_max_message_bytes: usize,
#[setting(key = "log_max_download_size", default = "52428800")]
pub log_max_download_size: u64,
#[setting(key = "log_live_default_limit", default = "500")]
pub log_live_default_limit: usize,
#[setting(key = "log_live_max_limit", default = "2000")]
pub log_live_max_limit: usize,
#[setting(key = "fusion_explain_scan_limit", default = "5000")]
pub fusion_explain_scan_limit: i64,
#[setting(key = "fusion_explain_response_cap", default = "200")]
pub fusion_explain_response_cap: usize,
#[setting(key = "default_event_channel_capacity", default = "256")]
pub default_event_channel_capacity: usize,
#[setting(key = "drop_channel_capacity", default = "100")]
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

@ -1,35 +1,10 @@
use super::helpers::{override_csv, seed_key};
use crate::domain::common::error::Error;
use crate::interface::port::setting::SettingRepo;
use macros::config_settings;
#[config_settings(section = "pipeline")]
#[derive(Debug, Clone)]
pub struct PipelineConfig {
#[setting(key = "pipeline_ingress", default = "access_control,rate_limit,service", api = false)]
pub ingress: Vec<String>,
#[setting(key = "pipeline_egress", default = "", api = false)]
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,106 @@
use crate::domain::common::config::acl::AclConfig;
use crate::domain::common::config::correlation::CorrelationConfig;
use crate::domain::common::config::detection::{BeaconingConfig, DetectionConfig, FusionConfig};
use crate::domain::common::config::dns_filter::DnsFilterConfig;
use crate::domain::common::config::ebpf::EbpfConfig;
use crate::domain::common::config::http_server::HttpServerConfig;
use crate::domain::common::config::ml::MlConfig;
use crate::domain::common::config::notification::{SmtpConfig, TelegramConfig};
use crate::domain::common::config::observability::ObservabilityConfig;
use crate::domain::common::config::soar::SoarConfig;
use crate::domain::common::config::suricata::SuricataConfig;
#[derive(Clone, Copy)]
pub enum ConfigSection {
Network,
Http,
Inference,
Xdp,
Models,
Misc,
Soar,
Ml,
FlowTrace,
ModelUpload,
Telegram,
Dns,
Smtp,
Suricata,
Detection,
Fusion,
Beaconing,
Correlation,
Observability,
}
impl ConfigSection {
pub const ALL: &[Self] = &[
Self::Network,
Self::Http,
Self::Inference,
Self::Xdp,
Self::Models,
Self::Misc,
Self::Soar,
Self::Ml,
Self::FlowTrace,
Self::ModelUpload,
Self::Telegram,
Self::Dns,
Self::Smtp,
Self::Suricata,
Self::Detection,
Self::Fusion,
Self::Beaconing,
Self::Correlation,
Self::Observability,
];
pub fn name(self) -> &'static str {
match self {
Self::Network => "network",
Self::Http => "http",
Self::Inference => "inference",
Self::Xdp => "xdp",
Self::Models => "models",
Self::Misc => "misc",
Self::Soar => "soar",
Self::Ml => "ml",
Self::FlowTrace => "flow_trace",
Self::ModelUpload => "model_upload",
Self::Telegram => "telegram",
Self::Dns => "dns",
Self::Smtp => "smtp",
Self::Suricata => "suricata",
Self::Detection => "detection",
Self::Fusion => "fusion",
Self::Beaconing => "beaconing",
Self::Correlation => "correlation",
Self::Observability => "observability",
}
}
pub fn keys(self) -> &'static [&'static str] {
match self {
Self::Network => EbpfConfig::NETWORK_KEYS,
Self::Http => HttpServerConfig::API_KEYS,
Self::Inference => MlConfig::INFERENCE_KEYS,
Self::Xdp => EbpfConfig::XDP_KEYS,
Self::Models => MlConfig::MODELS_KEYS,
Self::Misc => AclConfig::API_KEYS,
Self::Soar => SoarConfig::API_KEYS,
Self::Ml => MlConfig::ML_KEYS,
Self::FlowTrace => MlConfig::FLOW_TRACE_KEYS,
Self::ModelUpload => MlConfig::MODEL_UPLOAD_KEYS,
Self::Telegram => TelegramConfig::API_KEYS,
Self::Dns => DnsFilterConfig::API_KEYS,
Self::Smtp => SmtpConfig::API_KEYS,
Self::Suricata => SuricataConfig::API_KEYS,
Self::Detection => DetectionConfig::API_KEYS,
Self::Fusion => FusionConfig::API_KEYS,
Self::Beaconing => BeaconingConfig::API_KEYS,
Self::Correlation => CorrelationConfig::API_KEYS,
Self::Observability => ObservabilityConfig::API_KEYS,
}
}
}

View File

@ -1,115 +1,34 @@
use super::helpers::{override_parsed, seed_key};
use crate::domain::common::error::Error;
use crate::interface::port::setting::SettingRepo;
use macros::config_settings;
#[config_settings(section = "soar")]
#[derive(Debug, Clone)]
pub struct SoarConfig {
#[setting(key = "soar_max_auto_block_cap", default = "100")]
pub max_auto_block_cap: u32,
#[setting(key = "soar_max_ttl_secs", default = "86400")]
pub max_ttl_secs: u64,
#[setting(key = "soar_handle_concurrency", default = "16")]
pub handle_concurrency: usize,
#[setting(key = "soar_max_pending_unblock_retries", default = "5")]
pub max_pending_unblock_retries: i64,
#[setting(key = "soar_default_block_ttl_secs", default = "1800")]
pub default_block_ttl_secs: u64,
#[setting(key = "soar_default_rate_limit_factor", default = "0.5")]
pub default_rate_limit_factor: f64,
#[setting(key = "soar_default_rate_limit_ttl_secs", default = "600")]
pub default_rate_limit_ttl_secs: u64,
#[setting(key = "soar_default_webhook_timeout_secs", default = "10")]
pub default_webhook_timeout_secs: u64,
#[setting(key = "soar_default_frequency_window_secs", default = "60")]
pub default_frequency_window_secs: u64,
#[setting(key = "soar_default_single_source_high_min_confidence", default = "0.95")]
pub default_single_source_high_min_confidence: f32,
#[setting(key = "soar_default_cooldown_expiry_secs", default = "3600")]
pub default_cooldown_expiry_secs: u64,
#[setting(key = "soar_rate_limit_cmd_channel_capacity", default = "64")]
pub rate_limit_cmd_channel_capacity: usize,
#[setting(key = "soar_frequency_max_tracked_keys", default = "50000")]
pub frequency_max_tracked_keys: usize,
#[setting(key = "soar_fallback_cooldown_secs", default = "300")]
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

@ -1,75 +1,30 @@
use super::helpers::{override_bool, override_parsed, override_string_nonempty, seed_key};
use crate::domain::common::error::Error;
use crate::interface::port::setting::SettingRepo;
use macros::config_settings;
#[config_settings(section = "suricata")]
#[derive(Debug, Clone)]
pub struct SuricataConfig {
#[setting(key = "suricata_enabled", default = "false")]
pub enabled: bool,
#[setting(key = "suricata_binary_path", default = "/usr/bin/suricata")]
pub binary_path: String,
#[setting(key = "suricata_config_path", default = "/etc/netguardia/suricata.yaml")]
pub config_path: String,
#[setting(key = "suricata_eve_log_path", default = "/var/log/netguardia/eve.json")]
pub eve_log_path: String,
#[setting(key = "suricata_auto_restart_on_crash", default = "true")]
pub auto_restart_on_crash: bool,
#[setting(key = "suricata_restart_backoff_secs", default = "10")]
pub restart_backoff_secs: u64,
#[setting(key = "suricata_poll_interval_ms", default = "200")]
pub poll_interval_ms: u64,
#[setting(key = "suricata_file_wait_interval_secs", default = "1")]
pub file_wait_interval_secs: u64,
#[setting(key = "suricata_confidence_high", default = "0.95")]
pub confidence_high: f32,
#[setting(key = "suricata_confidence_medium", default = "0.80")]
pub confidence_medium: f32,
#[setting(key = "suricata_confidence_low", default = "0.65")]
pub confidence_low: f32,
#[setting(key = "suricata_confidence_info", default = "0.50")]
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

@ -1,34 +1,12 @@
use super::helpers::{override_string_nonempty, seed_key};
use crate::domain::common::error::Error;
use crate::interface::port::setting::SettingRepo;
use macros::config_settings;
#[config_settings(section = "system")]
#[derive(Debug, Clone)]
pub struct SystemConfig {
#[setting(key = "database_path", default = "net-guardia.db", api = false)]
pub database_path: String,
#[setting(key = "report_dir", default = "/var/lib/netguardia/reports", api = false)]
pub report_dir: String,
#[setting(key = "log_dir", default = "logs", api = false)]
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

@ -35,9 +35,6 @@ traceable! {
#[error("Failed to set user groups")]
SetUserGroupsFailed => tracing::Level::WARN,
#[error("Failed to bridge ML alert to SOAR")]
MlSoarBridgeFailed => tracing::Level::WARN,
#[error("Failed to store XDP mode")]
XdpModeStoreFailed => tracing::Level::WARN,

View File

@ -52,7 +52,7 @@ impl FromStr for DetectionSource {
// -- Detection Event (internal pipeline) --------------------------------------
/// Raw detection from any source. Sent via mpsc channel to DetectionOrchestrator.
/// Not published through CommunicationManager — this is a private internal pipeline.
/// Not published through broadcast — this is a private internal pipeline.
#[derive(Debug, Clone)]
pub struct DetectionEvent {
pub source: DetectionSource,

View File

@ -20,8 +20,5 @@ loggable! {
#[error("AuditLogger: event channel closed")]
AuditChannelClosed => tracing::Level::INFO,
#[error("AuditLogger: failed to subscribe to events")]
AuditSubscribeFailed => tracing::Level::WARN,
}
}

View File

@ -11,8 +11,5 @@ loggable! {
#[error("Setup HTTP server error: {error}")]
SetupServerError { error: String } => tracing::Level::ERROR,
#[error("Fusion WebSocket failed to subscribe to ThreatDetectedEvent: {err}")]
FusionSubscribeFailed { err: String } => tracing::Level::ERROR,
}
}

View File

@ -12,8 +12,8 @@ use crate::domain::detection::ml_detection::AlertMessage;
struct TimedSourceSet {
sources: HashSet<String>,
window_start: Instant,
/// Most recent alert to this destination (used for protocol/confidence in DetectionEvent).
last_alert: AlertMessage,
last_alerted: Option<Instant>,
}
/// Detects coordinated attacks: multiple source IPs targeting the same destination IP:port.
@ -47,6 +47,7 @@ impl BotnetDetector {
sources: HashSet::new(),
window_start: now,
last_alert: alert.clone(),
last_alerted: None,
});
let set = entry.value_mut();
@ -61,6 +62,13 @@ impl BotnetDetector {
set.last_alert = alert.clone();
if set.sources.len() >= self.threshold {
if let Some(last) = set.last_alerted {
if now.duration_since(last) < self.window {
set.sources.clear();
set.window_start = now;
return None;
}
}
Some(set.sources.len())
} else {
None
@ -90,10 +98,10 @@ impl BotnetDetector {
c2_score: 0.0,
};
// Reset after alerting to avoid repeated alerts within same window
if let Some(mut entry) = self.state.get_mut(&key) {
entry.sources.clear();
entry.window_start = now;
entry.last_alerted = Some(now);
}
return Some(event);

View File

@ -152,7 +152,7 @@ impl FlowData {
if packet.is_forward {
if self.fwd_packets.len() < limits.max_packets_per_direction {
self.fwd_packets.push(packet_data.clone());
self.fwd_packets.push(packet_data);
}
self.fwd_total_bytes += packet.packet_length as u64;
self.fwd_header_bytes += packet.header_length as u64;
@ -162,7 +162,7 @@ impl FlowData {
Self::update_bulk_state(&mut self.fwd_bulk_state, &packet_data, limits);
} else {
if self.bwd_packets.len() < limits.max_packets_per_direction {
self.bwd_packets.push(packet_data.clone());
self.bwd_packets.push(packet_data);
}
self.bwd_total_bytes += packet.packet_length as u64;
self.bwd_header_bytes += packet.header_length as u64;
@ -309,12 +309,21 @@ impl FlowTracker {
/// Get flows that received new packets since their last inference, and
/// mark them as inferred. Used by ML engine.
pub fn get_uninferred_flows(&self) -> Vec<FlowData> {
pub fn get_uninferred_flows(&self, limit: usize) -> Vec<FlowData> {
let mut result = Vec::new();
for (_, entry) in self.active.iter() {
if result.len() >= limit {
break;
}
let mut flow = entry.lock();
if flow.last_time_us > flow.last_inferred_us {
let snapshot = flow.clone();
let snapshot = FlowData {
fwd_packets: std::mem::take(&mut flow.fwd_packets),
bwd_packets: std::mem::take(&mut flow.bwd_packets),
active_periods: std::mem::take(&mut flow.active_periods),
idle_periods: std::mem::take(&mut flow.idle_periods),
..flow.clone()
};
flow.last_inferred_us = flow.last_time_us;
result.push(snapshot);
}

View File

@ -13,6 +13,7 @@ use crate::domain::detection::ml_detection::AlertMessage;
struct TimedDestSet {
dests: HashSet<String>,
window_start: Instant,
last_alerted: Option<Instant>,
}
/// Detects lateral movement: an internal IP reaching many other internal IPs.
@ -48,6 +49,7 @@ impl LateralMovementDetector {
let mut entry = self.state.entry(key.clone()).or_insert_with(|| TimedDestSet {
dests: HashSet::new(),
window_start: now,
last_alerted: None,
});
let set = entry.value_mut();
@ -60,6 +62,13 @@ impl LateralMovementDetector {
set.dests.insert(alert.dst_ip.clone());
if set.dests.len() >= self.threshold {
if let Some(last) = set.last_alerted {
if now.duration_since(last) < self.window {
set.dests.clear();
set.window_start = now;
return None;
}
}
Some(set.dests.len())
} else {
None
@ -87,10 +96,10 @@ impl LateralMovementDetector {
c2_score: 0.0,
};
// Reset after alerting
if let Some(mut entry) = self.state.get_mut(&key) {
entry.dests.clear();
entry.window_start = now;
entry.last_alerted = Some(now);
}
return Some(event);

View File

@ -127,11 +127,8 @@ loggable! {
#[error("Fusion: window evicted under LRU pressure ({key_src} {key_type})")]
FusionWindowEvicted { key_src: String, key_type: String } => tracing::Level::WARN,
#[error("Fusion: failed to publish ThreatDetectedEvent: {err}")]
FusionPublishFailed { err: String } => tracing::Level::ERROR,
#[error("Fusion: failed to publish AuditEvent: {err}")]
FusionAuditPublishFailed { err: String } => tracing::Level::ERROR,
#[error("Detection event dropped (channel full): {detector} {attack_type} from {source_ip}")]
DetectionChannelDrop { detector: String, attack_type: String, source_ip: String } => tracing::Level::WARN,
}
}

View File

@ -76,7 +76,7 @@ impl FlowKey {
}
}
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Copy)]
pub struct PacketData {
pub timestamp_us: u64,
pub length: u32,

View File

@ -13,6 +13,7 @@ struct TimedPortSet {
ports: HashSet<u16>,
window_start: Instant,
last_dst_ip: String,
last_alerted: Option<Instant>,
}
/// Detects port scanning: a single source IP probing many destination ports.
@ -45,6 +46,7 @@ impl ScanDetector {
ports: HashSet::new(),
window_start: now,
last_dst_ip: alert.dst_ip.clone(),
last_alerted: None,
});
let set = entry.value_mut();
@ -59,6 +61,13 @@ impl ScanDetector {
set.last_dst_ip = alert.dst_ip.clone();
if set.ports.len() >= self.threshold {
if let Some(last) = set.last_alerted {
if now.duration_since(last) < self.window {
set.ports.clear();
set.window_start = now;
return None;
}
}
Some((set.ports.len(), set.last_dst_ip.clone()))
} else {
None
@ -82,10 +91,10 @@ impl ScanDetector {
c2_score: 0.0,
};
// Reset after alerting
if let Some(mut entry) = self.state.get_mut(&key) {
entry.ports.clear();
entry.window_start = now;
entry.last_alerted = Some(now);
}
return Some(event);

View File

@ -229,7 +229,7 @@ impl PlaybookMatcher {
let key = (playbook_id, source_ip.to_string());
if let Some(last_exec) = self.cooldowns.get(&key) {
let elapsed = last_exec.elapsed();
if elapsed.as_secs() < cooldown_secs as u64 {
if elapsed.as_secs() < cooldown_secs.max(0) as u64 {
return true;
}
}
@ -247,7 +247,7 @@ impl PlaybookMatcher {
.playbooks
.load()
.iter()
.map(|p| p.cooldown_secs as u64)
.map(|p| p.cooldown_secs.max(0) as u64)
.max()
.unwrap_or(default_cooldown_expiry);
let expiry = Duration::from_secs(max_cooldown_secs.saturating_mul(2).max(default_cooldown_expiry));

View File

@ -5,6 +5,7 @@ use std::time::{Duration, SystemTime};
use arc_swap::ArcSwap;
use crossbeam::queue::SegQueue;
use macros::log;
use tokio::sync::broadcast;
use tokio::sync::oneshot;
use crate::core::inference::alert::MLAlert;
@ -18,6 +19,7 @@ use crate::domain::common::config::constants::{MANIFEST_FILENAME, MODELS_DIR};
use crate::domain::common::error::Error;
use crate::domain::common::error::misc::MiscError;
use crate::domain::common::error::system::SystemError;
use crate::domain::common::event::AuditEvent;
use crate::domain::common::log::system::SystemLog;
use crate::domain::common::system::health::EbpfHealth;
use crate::domain::detection::flow_features::FlowFeatures;
@ -29,7 +31,6 @@ use crate::domain::detection::ml_detection::EngineConfig;
use crate::domain::detection::ml_inference_config::MLInferenceConfig;
use crate::domain::detection::model_adapter::ModelSourceState;
use crate::domain::detection::model_source::ModelInfo;
use crate::infrastructure::communication_manager::CommunicationManager;
use crate::infrastructure::health::SystemHealth;
use crate::infrastructure::statistics::FlowStatistics;
@ -53,7 +54,7 @@ impl AppServices {
ml_manifest: Option<ModelManifest>,
drift_detector: DriftDetectorHandle,
ebpf_health: Arc<ArcSwap<EbpfHealth>>,
comm: Arc<CommunicationManager>,
audit_tx: broadcast::Sender<AuditEvent>,
) -> Result<Self, Error> {
let health = SystemHealth::new(app_config.clone(), ebpf_health)?;
@ -126,7 +127,7 @@ impl AppServices {
header,
policy,
config.ml.traffic_logger_channel_capacity,
Some(comm.clone()),
Some(audit_tx.clone()),
)
.map_err(|e| MiscError::TrafficLogCreateError(csv_path.clone(), e.to_string()))?;
log!(SystemLog::TrafficLoggingEnabled(csv_path));

View File

@ -1,11 +1,11 @@
use std::sync::Arc;
use macros::log;
use tokio::sync::broadcast;
use tokio::sync::broadcast::error::RecvError;
use crate::domain::common::event::{AuditEvent, DriftDetectedEvent};
use crate::domain::common::log::audit::AuditLog;
use crate::infrastructure::communication_manager::CommunicationManager;
use crate::interface::port::audit::AuditRepo;
/// Subscribes to `AuditEvent` and persists each entry to the `audit_log` table.
@ -19,12 +19,16 @@ impl AuditLogger {
Self { db }
}
/// Subscribe to AuditEvent and DriftDetectedEvent on the communication manager
/// and start background tasks that persist events to DB + structured logs.
pub fn start(self: Arc<Self>, comm: &CommunicationManager) {
// Subscribe to AuditEvent
if let Ok(mut rx) = comm.subscribe_event::<AuditEvent>() {
/// Start background tasks that persist audit and drift events to DB + structured logs.
pub fn start(
self: Arc<Self>,
audit_rx: broadcast::Receiver<AuditEvent>,
drift_rx: broadcast::Receiver<DriftDetectedEvent>,
) {
// Drain AuditEvent receiver
{
let this = self.clone();
let mut rx = audit_rx;
tokio::spawn(async move {
loop {
match rx.recv().await {
@ -41,13 +45,12 @@ impl AuditLogger {
}
}
});
} else {
log!(AuditLog::AuditSubscribeFailed);
}
// Subscribe to DriftDetectedEvent — log as audit trail entry
if let Ok(mut rx) = comm.subscribe_event::<DriftDetectedEvent>() {
// Drain DriftDetectedEvent receiver — log as audit trail entry
{
let this = self;
let mut rx = drift_rx;
tokio::spawn(async move {
loop {
match rx.recv().await {
@ -64,8 +67,6 @@ impl AuditLogger {
}
}
});
} else {
log!(AuditLog::AuditSubscribeFailed);
}
}

View File

@ -1,373 +0,0 @@
//! Cross-BC in-process event bus (technical service, not a BC).
//!
//! Per `docs/strategy/DOMAIN_MAP.md` §2, Communication Bus is a Technical
//! Service — it has no ubiquitous language, no domain expert, no aggregate.
//! It stays in `infrastructure/` and never takes a BC folder name. The trait
//! surface (`Event`, `Command`, `Query`, `EventBroadcaster`, `CommandHandler`)
//! lives at `interface/communication/` and remains untouched.
use std::any::{Any, TypeId};
use std::sync::Arc;
use dashmap::DashMap;
use tokio::sync::broadcast;
use crate::domain::common::error::Error;
use crate::domain::common::error::misc::MiscError;
use crate::interface::communication::command::*;
use crate::interface::communication::event::Event;
use crate::interface::communication::event::EventBroadcaster;
use crate::interface::communication::query::*;
/// Inline TypedEventBroadcaster (adapted from MirrorSphere's model).
pub struct TypedEventBroadcaster<E: Event> {
pub sender: broadcast::Sender<E>,
}
impl<E: Event + 'static> EventBroadcaster for TypedEventBroadcaster<E> {
fn subscribe_typed(&self) -> Box<dyn Any + Send> {
Box::new(self.sender.subscribe())
}
fn broadcast_event(&self, event: Box<dyn Any + Send>) -> Result<(), Error> {
let typed_event = *event.downcast::<E>().map_err(|_| MiscError::TypeMismatch)?;
let _ = self.sender.send(typed_event);
Ok(())
}
}
/// Central communication hub using the command/query/event pattern.
/// Adapted from MirrorSphere's CommunicationManager for NetGuardia.
pub struct CommunicationManager {
command_handlers: DashMap<TypeId, CommandHandlerFn>,
query_handlers: DashMap<TypeId, QueryHandlerFn>,
event_broadcasters: DashMap<TypeId, Box<dyn EventBroadcaster>>,
channel_capacity: usize,
}
impl CommunicationManager {
pub fn new(channel_capacity: usize) -> Self {
Self {
command_handlers: DashMap::new(),
query_handlers: DashMap::new(),
event_broadcasters: DashMap::new(),
channel_capacity: channel_capacity.max(1),
}
}
pub fn with_service<S: Send + Sync + 'static>(self: Arc<Self>, service: Arc<S>) -> ServiceRegistrar<S> {
ServiceRegistrar::new(service, self)
}
pub fn register_command_handler<C: Command + 'static>(&self, handler: Arc<dyn CommandHandler<C> + Send + Sync>) {
let type_id = TypeId::of::<C>();
let boxed_handler: CommandHandlerFn = Box::new(move |command: Box<dyn Any + Send>| {
let handler = handler.clone();
Box::pin(async move {
let command = *command.downcast::<C>().map_err(|_| MiscError::TypeMismatch)?;
handler.handle_command(command).await
}) as CommandFuture
});
self.command_handlers.insert(type_id, boxed_handler);
}
pub async fn send_command<C: Command + 'static>(&self, command: C) -> Result<(), Error> {
let type_id = TypeId::of::<C>();
if let Some(handler) = self.command_handlers.get(&type_id) {
handler(Box::new(command)).await
} else {
Err(MiscError::HandlerNotFound)?
}
}
pub fn register_query_handler<Q: Query + 'static>(&self, handler: Arc<dyn QueryHandler<Q> + Send + Sync>) {
let type_id = TypeId::of::<Q>();
let boxed_handler: QueryHandlerFn = Box::new(move |query: Box<dyn Any + Send>| {
let handler = handler.clone();
Box::pin(async move {
let query = *query.downcast::<Q>().map_err(|_| MiscError::TypeMismatch)?;
let response = handler.handle_query(query).await?;
Ok(Box::new(response) as Box<dyn Any + Send>)
}) as QueryFuture
});
self.query_handlers.insert(type_id, boxed_handler);
}
pub async fn send_query<Q: Query + 'static>(&self, query: Q) -> Result<Q::Response, Error> {
let type_id = TypeId::of::<Q>();
if let Some(handler) = self.query_handlers.get(&type_id) {
let response = handler(Box::new(query)).await?;
Ok(*response
.downcast::<Q::Response>()
.map_err(|_| MiscError::TypeMismatch)?)
} else {
Err(MiscError::HandlerNotFound)?
}
}
pub fn register_event_type<E: Event + 'static>(&self) {
let type_id = TypeId::of::<E>();
let (tx, _) = broadcast::channel::<E>(self.channel_capacity);
let broadcaster = TypedEventBroadcaster { sender: tx };
self.event_broadcasters.insert(type_id, Box::new(broadcaster));
}
pub fn subscribe_event<E: Event + 'static>(&self) -> Result<broadcast::Receiver<E>, Error> {
let type_id = TypeId::of::<E>();
let broadcaster = self
.event_broadcasters
.get(&type_id)
.ok_or(MiscError::TypeNotRegistered)?;
let receiver_box = broadcaster.subscribe_typed();
let receiver = *receiver_box
.downcast::<broadcast::Receiver<E>>()
.map_err(|_| MiscError::TypeMismatch)?;
Ok(receiver)
}
pub async fn publish_event<E: Event + 'static>(&self, event: E) -> Result<(), Error> {
let type_id = TypeId::of::<E>();
let broadcaster = self
.event_broadcasters
.get(&type_id)
.ok_or(MiscError::TypeNotRegistered)?;
broadcaster.broadcast_event(Box::new(event))
}
/// Synchronous counterpart for callers that live outside the tokio
/// runtime — in particular, the Flow Trace writer thread, which
/// runs on a dedicated `std::thread` and can't `.await`. The
/// internal broadcast channel is already non-blocking, so the
/// `async fn` sibling never actually yields; this variant exposes
/// the same work without the ceremony.
pub fn publish_event_sync<E: Event + 'static>(&self, event: E) -> Result<(), Error> {
let type_id = TypeId::of::<E>();
let broadcaster = self
.event_broadcasters
.get(&type_id)
.ok_or(MiscError::TypeNotRegistered)?;
broadcaster.broadcast_event(Box::new(event))
}
}
/// Fluent builder for registering a service's command/query/event handlers.
pub struct ServiceRegistrar<S> {
service: Arc<S>,
comm: Arc<CommunicationManager>,
}
impl<S: Send + Sync + 'static> ServiceRegistrar<S> {
fn new(service: Arc<S>, comm: Arc<CommunicationManager>) -> Self {
Self { service, comm }
}
pub fn command<C: Command + 'static>(self) -> Self
where
S: CommandHandler<C>,
{
let handler: Arc<dyn CommandHandler<C> + Send + Sync> = self.service.clone();
self.comm.register_command_handler::<C>(handler);
self
}
pub fn query<Q: Query + 'static>(self) -> Self
where
S: QueryHandler<Q>,
{
let handler: Arc<dyn QueryHandler<Q> + Send + Sync> = self.service.clone();
self.comm.register_query_handler::<Q>(handler);
self
}
pub fn build(self) -> Arc<CommunicationManager> {
self.comm
}
}
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use async_trait::async_trait;
use super::*;
use crate::interface::communication::command::Command;
use crate::interface::communication::event::Event;
use crate::interface::communication::message::Message;
use crate::interface::communication::query::Query;
// ── Test Command ─────────────────────────────────────────────────
struct TestCommand {
value: String,
}
impl Message for TestCommand {
type Response = ();
}
impl Command for TestCommand {}
struct TestCommandHandler {
received: Arc<Mutex<Vec<String>>>,
}
#[async_trait]
impl CommandHandler<TestCommand> for TestCommandHandler {
async fn handle_command(&self, command: TestCommand) -> Result<(), Error> {
self.received.lock().unwrap().push(command.value);
Ok(())
}
}
// ── Test Query ───────────────────────────────────────────────────
struct TestQuery {
input: i32,
}
impl Message for TestQuery {
type Response = i32;
}
impl Query for TestQuery {}
struct TestQueryHandler;
#[async_trait]
impl QueryHandler<TestQuery> for TestQueryHandler {
async fn handle_query(&self, query: TestQuery) -> Result<i32, Error> {
Ok(query.input * 2)
}
}
// ── Test Event ───────────────────────────────────────────────────
#[derive(Debug, Clone)]
struct TestEvent {
message: String,
}
impl Event for TestEvent {}
// ── Tests ────────────────────────────────────────────────────────
#[tokio::test]
async fn test_command_dispatch() {
let received = Arc::new(Mutex::new(Vec::new()));
let handler = Arc::new(TestCommandHandler {
received: received.clone(),
});
let comm = Arc::new(CommunicationManager::new(256));
comm.register_command_handler::<TestCommand>(handler);
comm.send_command(TestCommand { value: "hello".into() }).await.unwrap();
let msgs = received.lock().unwrap();
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0], "hello");
}
#[tokio::test]
async fn test_command_not_found() {
let comm = CommunicationManager::new(256);
let result = comm.send_command(TestCommand { value: "nope".into() }).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_query_dispatch() {
let handler = Arc::new(TestQueryHandler);
let comm = Arc::new(CommunicationManager::new(256));
comm.register_query_handler::<TestQuery>(handler);
let result = comm.send_query(TestQuery { input: 21 }).await.unwrap();
assert_eq!(result, 42);
}
#[tokio::test]
async fn test_query_not_found() {
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(256);
comm.register_event_type::<TestEvent>();
let mut receiver = comm.subscribe_event::<TestEvent>().unwrap();
comm.publish_event(TestEvent { message: "ping".into() }).await.unwrap();
let event = receiver.recv().await.unwrap();
assert_eq!(event.message, "ping");
}
#[tokio::test]
async fn test_event_not_registered() {
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(256);
comm.register_event_type::<TestEvent>();
let mut rx = comm.subscribe_event::<TestEvent>().unwrap();
comm.publish_event_sync(TestEvent { message: "sync".into() }).unwrap();
let event = rx.recv().await.unwrap();
assert_eq!(event.message, "sync");
}
#[test]
fn publish_event_sync_errors_when_type_unregistered() {
let comm = CommunicationManager::new(256);
let result = comm.publish_event_sync(TestEvent {
message: "dropped".into(),
});
assert!(result.is_err());
}
#[tokio::test]
async fn test_event_multiple_subscribers() {
let comm = CommunicationManager::new(256);
comm.register_event_type::<TestEvent>();
let mut rx1 = comm.subscribe_event::<TestEvent>().unwrap();
let mut rx2 = comm.subscribe_event::<TestEvent>().unwrap();
comm.publish_event(TestEvent {
message: "broadcast".into(),
})
.await
.unwrap();
assert_eq!(rx1.recv().await.unwrap().message, "broadcast");
assert_eq!(rx2.recv().await.unwrap().message, "broadcast");
}
#[tokio::test]
async fn test_service_registrar() {
let received = Arc::new(Mutex::new(Vec::new()));
let handler = Arc::new(TestCommandHandler {
received: received.clone(),
});
let comm = Arc::new(CommunicationManager::new(256));
let _comm = comm.clone().with_service(handler).command::<TestCommand>().build();
comm.send_command(TestCommand {
value: "via_registrar".into(),
})
.await
.unwrap();
let msgs = received.lock().unwrap();
assert_eq!(msgs[0], "via_registrar");
}
}

View File

@ -1,17 +1,12 @@
use std::sync::Arc;
use std::sync::atomic::{AtomicU8, Ordering};
use async_trait::async_trait;
use macros::log;
use tokio::sync::broadcast;
use crate::domain::common::error::Error;
use crate::domain::common::event::AuditEvent;
use crate::domain::common::log::system::SystemLog;
use crate::infrastructure::communication_manager::CommunicationManager;
use crate::interface::communication::command::CommandHandler;
use crate::interface::communication::command_types::ChangeEnforceModeCommand;
use crate::interface::communication::query::QueryHandler;
use crate::interface::communication::query_types::GetEnforceModeQuery;
use crate::interface::port::app_repo::AppRepo;
/// Map enforce-mode string to u8: monitor=0, ml_only=1, enforce=2.
@ -26,46 +21,36 @@ pub fn enforce_mode_to_u8(mode: &str) -> u8 {
/// Handles enforce-mode commands and queries by delegating to the repository.
pub struct EnforceModeHandler {
db: Arc<dyn AppRepo>,
comm: Arc<CommunicationManager>,
audit_tx: broadcast::Sender<AuditEvent>,
/// Shared AtomicU8 cache: Monitor=0, MlOnly=1, Enforce=2.
enforce_cache: Arc<AtomicU8>,
}
impl EnforceModeHandler {
pub fn new(db: Arc<dyn AppRepo>, comm: Arc<CommunicationManager>, enforce_cache: Arc<AtomicU8>) -> Self {
pub fn new(db: Arc<dyn AppRepo>, audit_tx: broadcast::Sender<AuditEvent>, enforce_cache: Arc<AtomicU8>) -> Self {
Self {
db,
comm,
audit_tx,
enforce_cache,
}
}
}
#[async_trait]
impl CommandHandler<ChangeEnforceModeCommand> for EnforceModeHandler {
async fn handle_command(&self, command: ChangeEnforceModeCommand) -> Result<(), Error> {
self.db.set_setting("enforce_mode", &command.mode)?;
self.enforce_cache
.store(enforce_mode_to_u8(&command.mode), Ordering::SeqCst);
log!(SystemLog::EnforceModeChanged(command.mode.clone()));
pub fn change_mode(&self, mode: String) -> Result<(), Error> {
self.db.set_setting("enforce_mode", &mode)?;
self.enforce_cache.store(enforce_mode_to_u8(&mode), Ordering::SeqCst);
log!(SystemLog::EnforceModeChanged(mode.clone()));
// Publish audit event for the mode change
let _ = self
.comm
.publish_event(AuditEvent {
actor: "admin".to_string(),
action: "enforce_mode_changed".to_string(),
detail: serde_json::json!({ "new_mode": command.mode }).to_string(),
})
.await;
let _ = self.audit_tx.send(AuditEvent {
actor: "admin".to_string(),
action: "enforce_mode_changed".to_string(),
detail: serde_json::json!({ "new_mode": mode }).to_string(),
});
Ok(())
}
}
#[async_trait]
impl QueryHandler<GetEnforceModeQuery> for EnforceModeHandler {
async fn handle_query(&self, _query: GetEnforceModeQuery) -> Result<String, Error> {
pub fn get_mode(&self) -> Result<String, Error> {
match self.db.get_setting("enforce_mode")? {
Some(mode) => Ok(mode),
None => Ok("monitor".to_string()),
@ -77,73 +62,53 @@ impl QueryHandler<GetEnforceModeQuery> for EnforceModeHandler {
mod tests {
use super::*;
use crate::adapter::persistence::Database;
use crate::infrastructure::communication_manager::CommunicationManager;
use crate::interface::communication::command_types::ChangeEnforceModeCommand;
use crate::interface::communication::query_types::GetEnforceModeQuery;
use crate::domain::common::config::constants::EVENT_CHANNEL_CAPACITY;
fn test_handler() -> (Arc<EnforceModeHandler>, Arc<CommunicationManager>) {
fn test_handler() -> EnforceModeHandler {
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(256));
comm.register_event_type::<AuditEvent>();
let handler = Arc::new(EnforceModeHandler::new(db, comm.clone(), cache));
let _ = comm
.clone()
.with_service(handler.clone())
.command::<ChangeEnforceModeCommand>()
.query::<GetEnforceModeQuery>()
.build();
(handler, comm)
let (audit_tx, _rx) = broadcast::channel::<AuditEvent>(EVENT_CHANNEL_CAPACITY);
EnforceModeHandler::new(db, audit_tx, cache)
}
#[tokio::test]
async fn test_default_mode_is_monitor() {
let (_, comm) = test_handler();
let mode = comm.send_query(GetEnforceModeQuery).await.unwrap();
#[test]
fn test_default_mode_is_monitor() {
let handler = test_handler();
let mode = handler.get_mode().unwrap();
assert_eq!(mode, "monitor");
}
#[tokio::test]
async fn test_change_to_enforce() {
let (_, comm) = test_handler();
comm.send_command(ChangeEnforceModeCommand { mode: "enforce".into() })
.await
.unwrap();
let mode = comm.send_query(GetEnforceModeQuery).await.unwrap();
#[test]
fn test_change_to_enforce() {
let handler = test_handler();
handler.change_mode("enforce".into()).unwrap();
let mode = handler.get_mode().unwrap();
assert_eq!(mode, "enforce");
}
#[tokio::test]
async fn test_change_back_to_monitor() {
let (_, comm) = test_handler();
comm.send_command(ChangeEnforceModeCommand { mode: "enforce".into() })
.await
.unwrap();
comm.send_command(ChangeEnforceModeCommand { mode: "monitor".into() })
.await
.unwrap();
let mode = comm.send_query(GetEnforceModeQuery).await.unwrap();
#[test]
fn test_change_back_to_monitor() {
let handler = test_handler();
handler.change_mode("enforce".into()).unwrap();
handler.change_mode("monitor".into()).unwrap();
let mode = handler.get_mode().unwrap();
assert_eq!(mode, "monitor");
}
#[tokio::test]
async fn test_change_to_ml_only() {
let (_, comm) = test_handler();
comm.send_command(ChangeEnforceModeCommand { mode: "ml_only".into() })
.await
.unwrap();
let mode = comm.send_query(GetEnforceModeQuery).await.unwrap();
#[test]
fn test_change_to_ml_only() {
let handler = test_handler();
handler.change_mode("ml_only".into()).unwrap();
let mode = handler.get_mode().unwrap();
assert_eq!(mode, "ml_only");
}
#[tokio::test]
async fn test_cycle_all_modes() {
let (_, comm) = test_handler();
#[test]
fn test_cycle_all_modes() {
let handler = test_handler();
for mode_str in ["enforce", "ml_only", "monitor"] {
comm.send_command(ChangeEnforceModeCommand { mode: mode_str.into() })
.await
.unwrap();
let mode = comm.send_query(GetEnforceModeQuery).await.unwrap();
handler.change_mode(mode_str.into()).unwrap();
let mode = handler.get_mode().unwrap();
assert_eq!(mode, mode_str);
}
}

View File

@ -8,6 +8,7 @@ use actix_web::web::route;
use actix_web::{App, HttpResponse, HttpServer, web};
use arc_swap::ArcSwap;
use macros::log;
use tokio::sync::broadcast;
use crate::adapter::ebpf::EbpfServices;
use crate::adapter::http::model_upload::PromoteGate;
@ -19,6 +20,7 @@ use crate::adapter::http::{
use crate::adapter::persistence::Database;
use crate::adapter::websocket::routes as ws;
use crate::core::common::config_service::ConfigService;
use crate::core::common::log_buffer::LogBuffer;
use crate::core::common::notification_service::NotificationService;
use crate::core::data_plane::acl_service::AclService;
use crate::core::data_plane::dns_filter_service::DnsFilterService;
@ -34,11 +36,13 @@ use crate::domain::common::config::AppConfig;
use crate::domain::common::config::constants::HTTP_FALLBACK_PORT;
use crate::domain::common::error::Error;
use crate::domain::common::error::http::HttpError;
use crate::domain::common::event::{AuditEvent, ThreatDetectedEvent};
use crate::domain::common::log::http::HttpLog;
use crate::domain::common::system::readiness::ReadinessState;
use crate::domain::detection::ml_inference_config::MLInferenceConfig;
use crate::infrastructure::app_services::AppServices;
use crate::infrastructure::communication_manager::CommunicationManager;
use crate::infrastructure::enforce_mode_handler::EnforceModeHandler;
use crate::infrastructure::logger::Logger;
use crate::infrastructure::secret_store::SecretStore;
use crate::infrastructure::suricata_manager::SuricataManager;
use crate::infrastructure::system::ShutdownHandle;
@ -58,7 +62,9 @@ pub struct HttpServerParams {
pub db: Arc<Database>,
pub secret_store: Arc<SecretStore>,
pub jwt_service: Arc<JwtService>,
pub comm: Arc<CommunicationManager>,
pub threat_tx: broadcast::Sender<ThreatDetectedEvent>,
pub audit_tx: broadcast::Sender<AuditEvent>,
pub enforce_handler: Arc<EnforceModeHandler>,
pub setup_complete: SetupCompleteFlag,
pub ready: ReadyFlag,
pub readiness_state: Arc<ReadinessState>,
@ -70,6 +76,8 @@ pub struct HttpServerParams {
pub rate_limit_service: Arc<RateLimitService>,
pub force_https: ForceHttpsFlag,
pub shutdown_handle: Arc<ShutdownHandle>,
pub logger: Arc<Logger>,
pub log_buffer: Arc<LogBuffer>,
pub suricata_manager: Arc<SuricataManager>,
pub soar_engine: Arc<SoarEngine>,
}
@ -224,7 +232,9 @@ pub async fn run(params: HttpServerParams) -> Result<(), Error> {
let db = params.db;
let secret_store = params.secret_store;
let jwt_service = params.jwt_service;
let comm = params.comm;
let threat_tx = params.threat_tx;
let audit_tx = params.audit_tx;
let enforce_handler = params.enforce_handler;
let setup_complete = params.setup_complete;
let ready = params.ready;
let readiness_state = params.readiness_state;
@ -236,6 +246,8 @@ pub async fn run(params: HttpServerParams) -> Result<(), Error> {
let rate_limit_service = params.rate_limit_service;
let force_https = params.force_https;
let shutdown_handle = params.shutdown_handle;
let logger = params.logger;
let log_buffer = params.log_buffer;
let suricata_manager = params.suricata_manager;
let soar_engine = params.soar_engine;
let port = app_config.load().http_server.port;
@ -272,7 +284,9 @@ pub async fn run(params: HttpServerParams) -> Result<(), Error> {
.app_data(web::Data::from(db.clone()))
.app_data(web::Data::from(secret_store.clone()))
.app_data(web::Data::from(jwt_service.clone()))
.app_data(web::Data::from(comm.clone()))
.app_data(web::Data::new(threat_tx.clone()))
.app_data(web::Data::new(audit_tx.clone()))
.app_data(web::Data::from(enforce_handler.clone()))
.app_data(web::Data::new(setup_complete.clone()))
.app_data(web::Data::new(ready.clone()))
.app_data(web::Data::from(readiness_state.clone()))
@ -282,6 +296,8 @@ pub async fn run(params: HttpServerParams) -> Result<(), Error> {
.app_data(web::Data::from(notification_service.clone()))
.app_data(web::Data::from(playbook_service.clone()))
.app_data(web::Data::from(rate_limit_service.clone()))
.app_data(web::Data::from(logger.clone()))
.app_data(web::Data::from(log_buffer.clone()))
.app_data(web::Data::from(suricata_manager.clone()))
.app_data(web::Data::from(soar_engine.clone()))
.app_data(web::Data::from(promote_lock.clone()));

View File

@ -0,0 +1,177 @@
use std::fs;
use std::{env, io};
use tracing::Level;
use tracing::level_filters::LevelFilter;
use tracing_appender::rolling::{RollingFileAppender, Rotation};
use tracing_subscriber::filter::Directive;
use tracing_subscriber::filter::EnvFilter;
use tracing_subscriber::fmt::layer as fmt_layer;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::{Layer, filter, reload};
use crate::core::common::log_buffer::{LogBuffer, LogBufferLayer};
use crate::domain::common::config::observability::ObservabilityConfig;
use crate::domain::common::error::Error;
use crate::domain::common::error::io::IOError;
use crate::interface::utils::logging::FilterControl;
pub struct Logger {
filter_handle: Box<dyn FilterControl>,
preserved_directives: Vec<String>,
}
impl Logger {
pub fn initialize(config: &ObservabilityConfig) -> Result<(Self, LogBuffer), Error> {
let log_directory = "logs";
fs::create_dir_all(log_directory).map_err(|err| IOError::CreateDirectoryFailed(log_directory, err))?;
let file_appender = RollingFileAppender::new(Rotation::DAILY, log_directory, "NetGuardia");
let stdout_layer = fmt_layer()
.with_file(true)
.with_line_number(true)
.with_thread_ids(true)
.with_target(false)
.with_ansi(true);
let file_layer = fmt_layer()
.with_file(false)
.with_line_number(false)
.with_thread_ids(false)
.with_target(true)
.with_ansi(false)
.with_writer(file_appender);
let level: Level = config
.log_level
.parse()
.ok()
.or_else(|| env::var("RUST_LOG").ok().and_then(|s| s.parse().ok()))
.unwrap_or(Level::INFO);
let mut preserved: Vec<String> = env::var("RUST_LOG")
.ok()
.map(|raw| {
raw.split(',')
.map(|s| s.trim().to_string())
.filter(|d| !d.is_empty() && d.contains('='))
.collect()
})
.unwrap_or_default();
if !preserved.iter().any(|d| d == "maxminddb=warn") {
preserved.push("maxminddb=warn".to_string());
}
let mut filter = EnvFilter::new(level.to_string());
filter = Self::apply_directives(filter, &preserved);
let (filter_layer, reload_handle) = reload::Layer::new(filter);
let (log_buffer_layer, log_buffer) =
LogBufferLayer::new(config.log_buffer_capacity, config.log_buffer_max_message_bytes);
tracing_subscriber::registry()
.with(filter_layer)
.with(stdout_layer)
.with(file_layer)
.with(log_buffer_layer)
.init();
let logger = Self {
filter_handle: Box::new(reload_handle),
preserved_directives: preserved,
};
Ok((logger, log_buffer))
}
pub fn initialize_cli() -> Result<(), Error> {
let stdout_layer = fmt_layer()
.without_time()
.with_level(false)
.with_target(false)
.with_file(false)
.with_line_number(false)
.with_thread_ids(false)
.with_ansi(false)
.with_writer(io::stdout)
.with_filter(LevelFilter::INFO);
let stderr_layer = fmt_layer()
.without_time()
.with_level(false)
.with_target(false)
.with_writer(io::stderr)
.with_filter(filter::filter_fn(|m| m.level() <= &Level::WARN));
tracing_subscriber::registry()
.with(stdout_layer)
.with(stderr_layer)
.init();
Ok(())
}
fn apply_directives(mut filter: EnvFilter, directives: &[String]) -> EnvFilter {
for d in directives {
if let Ok(parsed) = d.parse::<Directive>() {
filter = filter.add_directive(parsed);
}
}
filter
}
pub fn current_level(&self) -> String {
extract_main_level(&self.filter_handle.current_filter())
}
pub fn set_level(&self, level: &str) -> Result<String, String> {
let parsed_level: Level = level.parse().map_err(|_| {
format!(
"Invalid log level '{}'. Valid levels: trace, debug, info, warn, error",
level
)
})?;
let filter = EnvFilter::new(parsed_level.to_string());
let filter = Self::apply_directives(filter, &self.preserved_directives);
self.filter_handle.reload_filter(filter)?;
Ok(parsed_level.to_string().to_lowercase())
}
}
impl<L> FilterControl for reload::Handle<EnvFilter, L> {
fn reload_filter(&self, filter: EnvFilter) -> Result<(), String> {
self.reload(filter).map_err(|e| e.to_string())
}
fn current_filter(&self) -> String {
self.with_current(|f| f.to_string())
.unwrap_or_else(|_| "unknown".to_string())
}
}
fn extract_main_level(raw: &str) -> String {
raw.split(',')
.map(str::trim)
.find(|d| !d.is_empty() && !d.contains('='))
.unwrap_or(raw)
.to_lowercase()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strips_per_target_directives() {
assert_eq!(extract_main_level("maxminddb=warn,debug"), "debug");
assert_eq!(extract_main_level("info,maxminddb=warn"), "info");
assert_eq!(extract_main_level("DEBUG"), "debug");
}
#[test]
fn falls_back_when_no_bare_level() {
assert_eq!(extract_main_level("maxminddb=warn"), "maxminddb=warn");
}
}

View File

@ -13,6 +13,7 @@ use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::{Layer, filter, reload};
use crate::core::common::observability::log_buffer::LogBufferLayer;
use crate::domain::common::config::observability::ObservabilityConfig;
use crate::domain::common::error::Error;
use crate::domain::common::error::io::IOError;
use crate::interface::utils::logging::FilterControl;
@ -30,7 +31,7 @@ static PRESERVED_DIRECTIVES: OnceLock<Vec<String>> = OnceLock::new();
pub struct Logging;
impl Logging {
pub fn initialize(log_buffer_capacity: usize, log_buffer_max_message_bytes: usize) -> Result<(), Error> {
pub fn initialize(config: &ObservabilityConfig) -> Result<(), Error> {
let log_directory = "logs";
fs::create_dir_all(log_directory).map_err(|err| IOError::CreateDirectoryFailed(log_directory, err))?;
@ -51,18 +52,13 @@ impl Logging {
.with_ansi(false)
.with_writer(file_appender);
let level = env::var("RUST_LOG")
// RUST_LOG overrides the config value when present
let level: Level = env::var("RUST_LOG")
.ok()
.and_then(|s| s.parse::<Level>().ok())
.unwrap_or(if cfg!(debug_assertions) {
Level::DEBUG
} else {
Level::INFO
});
.and_then(|s| s.parse().ok())
.or_else(|| config.log_level.parse().ok())
.unwrap_or(Level::INFO);
// Collect per-target directives from RUST_LOG plus our hardcoded
// `maxminddb=warn` so `set_level` can reapply them on each rebuild
// instead of losing them to `EnvFilter::new(level)`.
let mut preserved: Vec<String> = env::var("RUST_LOG")
.ok()
.map(|raw| {
@ -77,7 +73,7 @@ impl Logging {
}
let _ = PRESERVED_DIRECTIVES.set(preserved);
let mut filter = EnvFilter::from_default_env().add_directive(level.into());
let mut filter = EnvFilter::new(level.to_string());
if let Some(directives) = PRESERVED_DIRECTIVES.get() {
for d in directives {
if let Ok(parsed) = d.parse::<Directive>() {
@ -92,10 +88,9 @@ impl Logging {
.with(filter_layer)
.with(stdout_layer)
.with(file_layer)
.with(LogBufferLayer::new(log_buffer_capacity, log_buffer_max_message_bytes))
.with(LogBufferLayer::new(config.log_buffer_capacity, config.log_buffer_max_message_bytes))
.init();
// Store type-erased handle for runtime log level changes
let _ = FILTER_HANDLE.set(Box::new(reload_handle));
Ok(())

View File

@ -1,12 +1,12 @@
pub mod app_services;
pub mod audit_logger;
pub mod cli;
pub mod communication_manager;
pub mod ebpf_preflight;
pub mod enforce_mode_handler;
pub mod geoip;
pub mod health;
pub mod http_server;
pub mod logger;
pub mod secret_store;
pub mod service_factory;
pub mod statistics;

View File

@ -12,6 +12,7 @@ use aya::programs::{Xdp, XdpFlags};
use aya_log::EbpfLogger;
use common::define::pipeline::*;
use macros::log;
use tokio::sync::broadcast;
use crate::adapter::access_control::AccessControlAdapter;
use crate::adapter::ebpf::EbpfServices;
@ -29,6 +30,7 @@ use crate::core::response::engine::SoarEngine;
use crate::core::response::playbook_service::PlaybookService;
use crate::core::response::scheduler::TtlScheduler;
use crate::domain::common::config::AppConfig;
use crate::domain::common::config::constants::EVENT_CHANNEL_CAPACITY;
use crate::domain::common::error::Error;
use crate::domain::common::error::misc::MiscError;
use crate::domain::common::event::{AuditEvent, DriftDetectedEvent, ThreatDetectedEvent};
@ -44,14 +46,11 @@ use crate::domain::detection::log::MLLog;
use crate::domain::detection::manifest::ModelManifest;
use crate::domain::detection::ml_inference_config::MLInferenceConfig;
use crate::infrastructure::app_services::AppServices;
use crate::infrastructure::communication_manager::CommunicationManager;
use crate::infrastructure::ebpf_preflight;
use crate::infrastructure::enforce_mode_handler::EnforceModeHandler;
use crate::infrastructure::geoip::GeoIpService;
use crate::infrastructure::secret_store::SecretStore;
use crate::infrastructure::suricata_manager::SuricataManager;
use crate::interface::communication::command_types::ChangeEnforceModeCommand;
use crate::interface::communication::query_types::GetEnforceModeQuery;
use crate::interface::port::access_control::AccessControlPort;
use crate::interface::port::access_control_admin::AccessControlAdminPort;
use crate::interface::port::app_repo::AppRepo;
@ -70,10 +69,13 @@ pub struct AppState {
pub inference_config: Arc<MLInferenceConfig>,
pub ebpf_services: Arc<EbpfServices>,
pub app_services: Arc<AppServices>,
pub db: Arc<Database>,
pub database: Arc<Database>,
pub secret_store: Arc<SecretStore>,
pub jwt_service: Arc<JwtService>,
pub comm: Arc<CommunicationManager>,
pub threat_tx: broadcast::Sender<ThreatDetectedEvent>,
pub audit_tx: broadcast::Sender<AuditEvent>,
pub drift_tx: broadcast::Sender<DriftDetectedEvent>,
pub enforce_handler: Arc<EnforceModeHandler>,
pub soar_engine: Arc<SoarEngine>,
pub ttl_scheduler: TtlScheduler,
pub report_scheduler: ReportScheduler,
@ -175,7 +177,7 @@ impl ServiceFactory {
let jwt_service = Arc::new(JwtService::new(
&secret_store_port,
app_config.load().auth.jwt_expiry_hours,
app_config.load().http_server.jwt_expiry_hours,
)?);
// Initialize ML drift detector from inference config baselines
@ -199,17 +201,13 @@ impl ServiceFactory {
enforce_mode_to_u8(&mode_str)
}));
// CommunicationManager and its event channels must exist before
// AppServices spins up the TrafficLogger: the writer thread can
// 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(
app_config.load().observability.default_event_channel_capacity,
));
comm.register_event_type::<ThreatDetectedEvent>();
comm.register_event_type::<DriftDetectedEvent>();
comm.register_event_type::<AuditEvent>();
// Named broadcast channels replace the old TypeId-based CommunicationManager.
// They must exist before AppServices spins up the TrafficLogger: the writer
// thread can publish `flow_trace_stopped` audit events the moment it tries
// to open its first rotated file.
let (threat_tx, _) = broadcast::channel::<ThreatDetectedEvent>(EVENT_CHANNEL_CAPACITY);
let (audit_tx, _) = broadcast::channel::<AuditEvent>(EVENT_CHANNEL_CAPACITY);
let (drift_tx, _) = broadcast::channel::<DriftDetectedEvent>(EVENT_CHANNEL_CAPACITY);
let app_services = Arc::new(AppServices::new(
app_config.clone(),
@ -217,20 +215,14 @@ impl ServiceFactory {
ml_manifest.clone(),
drift_detector.clone(),
ebpf_health.clone(),
comm.clone(),
audit_tx.clone(),
)?);
let enforce_handler = Arc::new(EnforceModeHandler::new(
db.clone() as Arc<dyn AppRepo>,
comm.clone(),
audit_tx.clone(),
enforce_level_cache.clone(),
));
let _ = comm
.clone()
.with_service(enforce_handler)
.command::<ChangeEnforceModeCommand>()
.query::<GetEnforceModeQuery>()
.build();
// Seed default SOAR playbooks if empty
(db.as_ref() as &dyn SoarRepo).seed_default_playbooks()?;
@ -337,10 +329,13 @@ impl ServiceFactory {
inference_config,
ebpf_services,
app_services,
db,
database: db,
secret_store,
jwt_service,
comm,
threat_tx,
audit_tx,
drift_tx,
enforce_handler,
soar_engine,
ttl_scheduler,
report_scheduler,

View File

@ -28,7 +28,7 @@ use tokio::time::sleep;
use crate::domain::common::config::AppConfig;
use crate::domain::common::event::{DetectionEvent, DetectionSource};
use crate::domain::detection::log::SuricataLog;
use crate::domain::detection::log::{DetectionLog, SuricataLog};
/// IANA protocol numbers for Suricata's `proto` strings.
const IANA_PROTO_ICMP: u8 = 1;
@ -139,7 +139,13 @@ impl SuricataMonitor {
};
// mpsc is bounded; if the orchestrator is backed up, drop rather than
// block the tail (eve.json will fill the disk if we block).
let _ = self.detection_tx.try_send(event);
if let Err(tokio::sync::mpsc::error::TrySendError::Full(d)) = self.detection_tx.try_send(event) {
log!(DetectionLog::DetectionChannelDrop(
format!("{:?}", d.source),
d.attack_type,
d.source_ip,
));
}
}
/// Map a Suricata alert JSON object to a DetectionEvent. Returns None if
@ -184,9 +190,12 @@ impl SuricataMonitor {
signature,
));
let category = alert.get("category").and_then(|x| x.as_str()).unwrap_or("unknown");
let canonical = crate::domain::detection::attack_type::translate(DetectionSource::Suricata, category);
Some(DetectionEvent {
source: DetectionSource::Suricata,
attack_type: "suricata_alert".to_string(),
attack_type: canonical.as_str().to_string(),
confidence,
source_ip: src_ip,
dest_ip,

View File

@ -9,7 +9,7 @@ use aya::maps::{MapData, ProgramArray};
use macros::log;
use sd_notify::NotifyState;
use tokio::signal::ctrl_c;
use tokio::sync::broadcast::{Receiver, error::RecvError};
use tokio::sync::broadcast::{self, Receiver, error::RecvError};
use tokio::sync::mpsc::{self, Sender};
use tokio::sync::oneshot;
use tokio::time::{interval, sleep};
@ -18,6 +18,7 @@ use crate::adapter::ebpf::EbpfServices;
use crate::adapter::http::model_upload;
use crate::adapter::persistence::Database;
use crate::core::common::config_service::ConfigService;
use crate::core::common::log_buffer::LogBuffer;
use crate::core::common::notification_service::NotificationService;
use crate::core::correlation::engine::CorrelationEngine;
use crate::core::data_plane::acl_service::AclService;
@ -37,7 +38,8 @@ use crate::domain::common::config::AppConfig;
use crate::domain::common::config::constants::{MODELS_DIR, STAGING_SUBDIR};
use crate::domain::common::error::Error;
use crate::domain::common::error::system::SystemError;
use crate::domain::common::event::{DetectionEvent, DetectionSource, DriftDetectedEvent};
use crate::domain::common::event::AuditEvent;
use crate::domain::common::event::{DetectionEvent, DetectionSource, DriftDetectedEvent, ThreatDetectedEvent};
use crate::domain::common::log::system::SystemLog;
use crate::domain::common::system::health::EbpfHealth;
use crate::domain::common::system::readiness::ReadinessState;
@ -47,8 +49,9 @@ use crate::domain::detection::ml_detection::AlertMessage;
use crate::domain::detection::ml_inference_config::MLInferenceConfig;
use crate::infrastructure::app_services::AppServices;
use crate::infrastructure::audit_logger::AuditLogger;
use crate::infrastructure::communication_manager::CommunicationManager;
use crate::infrastructure::enforce_mode_handler::EnforceModeHandler;
use crate::infrastructure::http_server::{self, HttpServerParams};
use crate::infrastructure::logger::Logger;
use crate::infrastructure::secret_store::SecretStore;
use crate::infrastructure::service_factory::ServiceFactory;
use crate::infrastructure::suricata_manager::SuricataManager;
@ -59,17 +62,12 @@ use crate::interface::port::packet_sink::PacketSinkFactory;
use crate::interface::port::setting::SettingRepo;
use crate::interface::port::stats::StatsRepo;
/// API-triggered shutdown mode.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ShutdownMode {
Shutdown,
Restart,
}
/// Handle for triggering shutdown from HTTP endpoints. Backed by a
/// capacity-1 mpsc so the first trigger atomically wins via `try_send`,
/// and subsequent calls receive `TrySendError::Full` — no lock, no
/// `Option::take`, no `Mutex`.
pub struct ShutdownHandle {
tx: Sender<ShutdownMode>,
}
@ -79,25 +77,23 @@ impl ShutdownHandle {
Self { tx }
}
/// Trigger shutdown. Returns false if already triggered or the
/// receiver has been dropped.
pub fn trigger(&self, mode: ShutdownMode) -> bool {
self.tx.try_send(mode).is_ok()
}
}
/// Orchestrates system lifecycle: startup ordering and shutdown.
/// 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<ArcSwap<AppConfig>>,
pub inference_config: Arc<MLInferenceConfig>,
pub ebpf_services: Arc<EbpfServices>,
pub app_services: Arc<AppServices>,
pub db: Arc<Database>,
pub database: Arc<Database>,
pub secret_store: Arc<SecretStore>,
pub jwt_service: Arc<JwtService>,
pub comm: Arc<CommunicationManager>,
pub threat_tx: broadcast::Sender<ThreatDetectedEvent>,
pub audit_tx: broadcast::Sender<AuditEvent>,
pub drift_tx: broadcast::Sender<DriftDetectedEvent>,
pub enforce_handler: Arc<EnforceModeHandler>,
pub soar_engine: Arc<SoarEngine>,
pub ttl_scheduler: Option<TtlScheduler>,
pub report_scheduler: Option<ReportScheduler>,
@ -119,18 +115,20 @@ pub struct System {
}
impl System {
/// Build System from DB. Only called after setup is confirmed complete.
pub async fn new(db: Arc<Database>) -> Result<Self, Error> {
let state = ServiceFactory::build(db).await?;
pub async fn new(database: Arc<Database>) -> Result<Self, Error> {
let state = ServiceFactory::build(database).await?;
Ok(System {
app_config: state.app_config,
inference_config: state.inference_config,
ebpf_services: state.ebpf_services,
app_services: state.app_services,
db: state.db,
database: state.database,
secret_store: state.secret_store,
jwt_service: state.jwt_service,
comm: state.comm,
threat_tx: state.threat_tx,
audit_tx: state.audit_tx,
drift_tx: state.drift_tx,
enforce_handler: state.enforce_handler,
soar_engine: state.soar_engine,
ttl_scheduler: Some(state.ttl_scheduler),
report_scheduler: Some(state.report_scheduler),
@ -152,9 +150,7 @@ impl System {
})
}
/// Start all services and HTTP server. Setup is already complete at this point.
/// Returns the shutdown mode requested (Shutdown or Restart).
pub async fn run(&mut self) -> Result<ShutdownMode, Error> {
pub async fn run(&mut self, logger: Arc<Logger>, log_buffer: Arc<LogBuffer>) -> Result<ShutdownMode, Error> {
log!(SystemLog::Initializing);
// Sweep model-upload staging directories left over from failed
@ -226,7 +222,7 @@ impl System {
// Start SOAR engine
self.soar_engine.recover_active_blocks().await?;
self.soar_engine.clone().start(self.comm.clone())?;
self.soar_engine.clone().start(self.threat_tx.subscribe());
// Start TTL scheduler
if let Some(ttl) = self.ttl_scheduler.take() {
@ -239,22 +235,22 @@ impl System {
}
// Start audit logger (subscribe to AuditEvent + DriftDetectedEvent, persist to DB)
let audit_logger = Arc::new(AuditLogger::new(self.db.clone() as Arc<dyn AuditRepo>));
audit_logger.start(&self.comm);
let audit_logger = Arc::new(AuditLogger::new(self.database.clone() as Arc<dyn AuditRepo>));
audit_logger.start(self.audit_tx.subscribe(), self.drift_tx.subscribe());
// 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 + Send + Sync>,
self.database.clone() as Arc<dyn StatsRepo>,
self.database.clone() as Arc<dyn SettingRepo + Send + Sync>,
);
stats_aggregator.start();
// Start drift detection background task
{
let drift_detector = self.drift_detector.clone();
let comm_drift = self.comm.clone();
let drift_tx = self.drift_tx.clone();
tokio::spawn(async move {
Self::run_drift_monitor(drift_detector, comm_drift).await;
Self::run_drift_monitor(drift_detector, drift_tx).await;
});
}
@ -263,7 +259,8 @@ impl System {
let orchestrator = DetectionOrchestrator::new(
&self.app_config,
detection_rx,
self.comm.clone(),
self.threat_tx.clone(),
self.audit_tx.clone(),
self.geoip.clone(),
self.app_services.fusion_metrics.clone(),
);
@ -296,7 +293,7 @@ impl System {
// Initialize force_https flag from DB setting
let force_https = Arc::new(AtomicBool::new(
self.db
self.database
.get_setting("force_https")
.ok()
.flatten()
@ -330,10 +327,12 @@ impl System {
inference_config: self.inference_config.clone(),
ebpf_services: self.ebpf_services.clone(),
app_services: self.app_services.clone(),
db: self.db.clone(),
db: self.database.clone(),
secret_store: self.secret_store.clone(),
jwt_service: self.jwt_service.clone(),
comm: self.comm.clone(),
threat_tx: self.threat_tx.clone(),
audit_tx: self.audit_tx.clone(),
enforce_handler: self.enforce_handler.clone(),
setup_complete: setup_flag,
ready: ready_flag,
readiness_state,
@ -345,6 +344,8 @@ impl System {
rate_limit_service: self.rate_limit_service.clone(),
force_https,
shutdown_handle: shutdown_handle.clone(),
logger,
log_buffer,
suricata_manager: self.suricata_manager.clone(),
soar_engine: self.soar_engine.clone(),
};
@ -405,7 +406,6 @@ impl System {
let ebpf_services = self.ebpf_services.clone();
let app_services = self.app_services.clone();
log!(SystemLog::Terminating);
if let Some(tx) = self.suricata_shutdown.take() {
let _ = tx.send(());
}
@ -415,25 +415,8 @@ impl System {
Ok(())
}
/// Normalize ML model attack type names to SOAR playbook event names.
fn normalize_attack_type(raw: &str) -> String {
match raw {
"Brute Force" => "brute_force".to_string(),
"C2 Communication" => "c2_communication".to_string(),
"DoS/DDoS" => "threat_detected".to_string(),
"Exploitation" | "Malware" | "Web Attack" => "threat_detected".to_string(),
"Bot" | "DNS Tunneling" => "threat_detected".to_string(),
"Reconnaissance" => "port_scan".to_string(),
"Normal" => "normal".to_string(),
other => {
log!(DetectionLog::UnknownMlAttackType(other.to_string()));
"threat_detected".to_string()
}
}
}
/// Periodically check the drift detector and publish DriftDetectedEvent when drift is found.
async fn run_drift_monitor(drift_detector: DriftDetectorHandle, comm: Arc<CommunicationManager>) {
async fn run_drift_monitor(drift_detector: DriftDetectorHandle, drift_tx: broadcast::Sender<DriftDetectedEvent>) {
let mut interval = interval(Duration::from_secs(60));
loop {
interval.tick().await;
@ -447,7 +430,7 @@ impl System {
drifted_features: report.drifted_features,
max_deviation: report.max_deviation,
};
if let Err(e) = comm.publish_event(event).await {
if let Err(e) = drift_tx.send(event) {
log!(SystemError::DriftEventPublishFailed(e));
}
}
@ -498,6 +481,23 @@ impl System {
}
}
/// Normalize ML model attack type names to SOAR playbook event names.
fn normalize_attack_type(raw: &str) -> String {
match raw {
"Brute Force" => "brute_force".to_string(),
"C2 Communication" => "c2_communication".to_string(),
"DoS/DDoS" => "threat_detected".to_string(),
"Exploitation" | "Malware" | "Web Attack" => "threat_detected".to_string(),
"Bot" | "DNS Tunneling" => "threat_detected".to_string(),
"Reconnaissance" => "port_scan".to_string(),
"Normal" => "normal".to_string(),
other => {
log!(DetectionLog::UnknownMlAttackType(other.to_string()));
"threat_detected".to_string()
}
}
}
/// Attempt to attach the XDP programs to the configured interfaces.
/// If either attach fails, record the reason in `ebpf_health` and
/// continue — the rest of the system keeps running.
@ -517,10 +517,10 @@ impl System {
match (ingress_result, egress_result) {
(Ok(ingress_mode), Ok(egress_mode)) => {
if let Err(e) = self.db.set_setting("xdp_ingress_mode", &ingress_mode) {
if let Err(e) = self.database.set_setting("xdp_ingress_mode", &ingress_mode) {
log!(SystemError::XdpModeStoreFailed(e));
}
if let Err(e) = self.db.set_setting("xdp_egress_mode", &egress_mode) {
if let Err(e) = self.database.set_setting("xdp_egress_mode", &egress_mode) {
log!(SystemError::XdpModeStoreFailed(e));
}
}

View File

@ -1,18 +0,0 @@
use std::any::Any;
use std::future::Future;
use std::pin::Pin;
use async_trait::async_trait;
use crate::domain::common::error::Error;
use crate::interface::communication::message::Message;
pub type CommandFuture = Pin<Box<dyn Future<Output = Result<(), Error>> + Send + 'static>>;
pub type CommandHandlerFn = Box<dyn Fn(Box<dyn Any + Send>) -> CommandFuture + Send + Sync>;
pub trait Command: Message<Response = ()> {}
#[async_trait]
pub trait CommandHandler<C: Command> {
async fn handle_command(&self, command: C) -> Result<(), Error>;
}

View File

@ -1,13 +0,0 @@
use crate::interface::communication::command::Command;
use crate::interface::communication::message::Message;
// ── System Commands ──────────────────────────────────────────────────
pub struct ChangeEnforceModeCommand {
pub mode: String,
}
impl Message for ChangeEnforceModeCommand {
type Response = ();
}
impl Command for ChangeEnforceModeCommand {}

View File

@ -1,10 +1 @@
use std::any::Any;
use crate::domain::common::error::Error;
pub trait Event: Send + Clone + 'static {}
pub trait EventBroadcaster: Send + Sync {
fn subscribe_typed(&self) -> Box<dyn Any + Send>;
fn broadcast_event(&self, event: Box<dyn Any + Send>) -> Result<(), Error>;
}

View File

@ -1,3 +0,0 @@
pub trait Message: Send + 'static {
type Response: Send + 'static;
}

View File

@ -1,7 +1,2 @@
pub mod command;
pub mod command_types;
pub mod event;
pub mod event_types;
pub mod message;
pub mod query;
pub mod query_types;

View File

@ -1,18 +0,0 @@
use std::any::Any;
use std::future::Future;
use std::pin::Pin;
use async_trait::async_trait;
use crate::domain::common::error::Error;
use crate::interface::communication::message::Message;
pub type QueryFuture = Pin<Box<dyn Future<Output = Result<Box<dyn Any + Send>, Error>> + Send + 'static>>;
pub type QueryHandlerFn = Box<dyn Fn(Box<dyn Any + Send>) -> QueryFuture + Send + Sync>;
pub trait Query: Message {}
#[async_trait]
pub trait QueryHandler<Q: Query> {
async fn handle_query(&self, query: Q) -> Result<Q::Response, Error>;
}

View File

@ -1,11 +0,0 @@
use crate::interface::communication::message::Message;
use crate::interface::communication::query::Query;
// ── System Queries ───────────────────────────────────────────────────
pub struct GetEnforceModeQuery;
impl Message for GetEnforceModeQuery {
type Response = String;
}
impl Query for GetEnforceModeQuery {}

View File

@ -14,9 +14,6 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use clap::Parser;
use domain::common::error::Error;
use domain::common::error::system::SystemError;
use domain::common::log::system::SystemLog;
use macros::log;
use sd_notify::NotifyState;
use tokio::time::sleep;
@ -24,88 +21,79 @@ use tokio::{signal, time};
use crate::adapter::persistence::Database;
use crate::core::identity::jwt::JwtService;
use crate::domain::common::config::observability::ObservabilityConfig;
use crate::domain::common::error::Error;
use crate::domain::common::error::system::SystemError;
use crate::domain::common::log::system::SystemLog;
use crate::domain::identity::password;
use crate::infrastructure::cli::{Cli, handle_subcommand};
use crate::infrastructure::http_server;
use crate::infrastructure::logger::Logger;
use crate::infrastructure::secret_store::SecretStore;
use crate::infrastructure::system::{ShutdownMode, System};
use crate::interface::port::secret_store::SecretStorePort;
use crate::utils::logging::Logging;
#[actix_web::main]
async fn main() -> Result<(), Error> {
let cli = Cli::parse();
if cli.command.is_some() {
Logging::initialize_cli()?;
return handle_subcommand(&cli);
fn seed_default_admin(database: &Arc<Database>) -> Result<(), Error> {
if database.user_count().unwrap_or(0) != 0 {
return Ok(());
}
// 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)?;
if let Ok(groups) = db.list_user_groups()
&& let Some((group_id, _, _, _, _)) = groups.into_iter().find(|(_, name, _, _, _)| name == "Administrator")
&& let Err(e) = db.set_user_groups(admin_user_id, &[group_id])
{
log!(SystemError::SetUserGroupsFailed(e));
}
log!(SystemLog::DefaultAdminCreated);
let hash = password::hash_password("admin")?;
let admin_user_id = database.insert_user("admin", &hash, "admin", false)?;
if let Ok(groups) = database.list_user_groups()
&& let Some((group_id, _, _, _, _)) = groups.into_iter().find(|(_, name, _, _, _)| name == "Administrator")
&& let Err(err) = database.set_user_groups(admin_user_id, &[group_id])
{
log!(SystemError::SetUserGroupsFailed(err));
}
log!(SystemLog::DefaultAdminCreated);
Ok(())
}
let setup_complete = db.get_setting("setup_complete")?.map(|v| v == "true").unwrap_or(false);
if !setup_complete {
log!(SystemLog::SetupMode);
fn is_setup_complete(database: &Arc<Database>) -> Result<bool, Error> {
Ok(database
.get_setting("setup_complete")?
.map(|v| v == "true")
.unwrap_or(false))
}
let secret_store = Arc::new(SecretStore::new(db.clone()));
let secrets: Arc<dyn SecretStorePort> = secret_store.clone();
let jwt_service = Arc::new(JwtService::new(&secrets, 24)?);
let setup_flag = Arc::new(AtomicBool::new(false));
async fn run_setup_wizard(database: &Arc<Database>) -> Result<(), Error> {
log!(SystemLog::SetupMode);
let handle = http_server::start_setup_server(db.clone(), secret_store, jwt_service, setup_flag.clone(), 8080)?;
let secret_store = Arc::new(SecretStore::new(database.clone()));
let secrets: Arc<dyn SecretStorePort> = secret_store.clone();
let jwt_service = Arc::new(JwtService::new(&secrets, 24)?);
let setup_flag = Arc::new(AtomicBool::new(false));
let flag = setup_flag.clone();
let setup_done = async move {
loop {
time::sleep(Duration::from_millis(500)).await;
if flag.load(Ordering::SeqCst) {
return;
}
}
};
let handle =
http_server::start_setup_server(database.clone(), secret_store, jwt_service, setup_flag.clone(), 8080)?;
tokio::select! {
_ = setup_done => {
log!(SystemLog::SetupCompleted);
}
_ = signal::ctrl_c() => {
log!(SystemLog::ShutdownDuringSetup);
handle.stop(true).await;
return Ok(());
let flag = setup_flag.clone();
let setup_done = async move {
loop {
time::sleep(Duration::from_millis(500)).await;
if flag.load(Ordering::SeqCst) {
return;
}
}
};
handle.stop(true).await;
log!(SystemLog::SetupServerStopped);
tokio::select! {
_ = setup_done => {
log!(SystemLog::SetupCompleted);
}
_ = signal::ctrl_c() => {
log!(SystemLog::ShutdownDuringSetup);
handle.stop(true).await;
return Ok(());
}
}
let mut system = System::new(db).await?;
let mode = system.run().await?;
system.terminate().await?;
handle.stop(true).await;
log!(SystemLog::SetupServerStopped);
Ok(())
}
async fn handle_shutdown(mode: ShutdownMode, system: System) -> Result<(), Error> {
match mode {
ShutdownMode::Restart => {
log!(SystemLog::Restart);
@ -124,3 +112,29 @@ async fn main() -> Result<(), Error> {
}
Ok(())
}
#[actix_web::main]
async fn main() -> Result<(), Error> {
let cli = Cli::parse();
if cli.command.is_some() {
Logger::initialize_cli()?;
return handle_subcommand(&cli);
}
let database = Arc::new(Database::new(&cli.db_path)?);
let obs_config = ObservabilityConfig::from_settings(database.as_ref())?;
let (logger, log_buffer) = Logger::initialize(&obs_config)?;
let logger = Arc::new(logger);
let log_buffer = Arc::new(log_buffer);
seed_default_admin(&database)?;
if !is_setup_complete(&database)? {
run_setup_wizard(&database).await?;
}
let mut system = System::new(database).await?;
let mode = system.run(logger, log_buffer).await?;
system.terminate().await?;
handle_shutdown(mode, system).await
}

View File

@ -1,5 +1,4 @@
pub mod boot_time;
pub mod logging;
pub mod packet_parser;
pub mod static_files;