refactor: Remove CommunicationManager

This commit is contained in:
DaLaw2 2026-04-25 13:07:57 +08:00
parent 94d366bcbe
commit 301d6ac1b8
32 changed files with 390 additions and 718 deletions

View File

@ -26,7 +26,9 @@ impl Parse for StructAttr {
input.parse::<Token![,]>()?;
}
}
Ok(Self { default_section: section })
Ok(Self {
default_section: section,
})
}
}
@ -71,10 +73,7 @@ struct MappedParent {
// ── Parsing ────────────────────────────────────────────────────────
fn parse_struct_mapped_settings(
input: &mut ItemStruct,
default_section: &Option<String>,
) -> Vec<MappedSetting> {
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") {
@ -131,14 +130,8 @@ fn parse_struct_mapped_settings(
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"))?;
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;
@ -434,15 +427,11 @@ 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 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);
mapped_groups.entry(ms.parent.clone()).or_default().push(ms);
}
let fields = match &mut input.fields {
@ -452,11 +441,7 @@ pub fn config_settings_impl(attr: TokenStream, item: TokenStream) -> TokenStream
let mut config_fields = Vec::new();
for field in &mut fields.named {
let field_name = field
.ident
.as_ref()
.expect("named field")
.to_string();
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 {

View File

@ -1,4 +1,5 @@
use actix_web::{HttpResponse, Responder, Scope, web};
use tokio::sync::broadcast;
use crate::core::identity::extractor::AuthClaims;
use crate::core::inference::engine::Engine;
@ -6,7 +7,6 @@ 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

@ -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;
@ -44,7 +45,6 @@ use crate::domain::common::config::constants::{
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.
@ -133,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,
@ -168,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,
@ -516,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,
@ -605,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,11 +3,9 @@ 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;
@ -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()})),
}

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

@ -5,18 +5,17 @@ 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::config::constants::{FUSION_AUDIT_ACTION, FUSION_AUDIT_ACTOR};
use crate::domain::common::error::system::SystemError;
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;
/// Per-source record within an in-flight dedup entry. Keeps the strongest
@ -50,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
@ -70,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 {
@ -79,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.
@ -251,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?"
@ -267,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 {
@ -358,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()
@ -381,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

@ -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,12 +21,12 @@ 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;
pub const DEFAULT_MAX_FILE_BYTES: u64 = 500 * 1024 * 1024;
pub const DEFAULT_MAX_FILE_AGE: Duration = Duration::from_secs(3600);
@ -69,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(
@ -77,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()
@ -93,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 {
@ -188,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;
}
};
@ -213,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) {
@ -221,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;
}
};
@ -242,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!({
@ -257,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,
@ -401,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");
@ -419,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;
@ -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

@ -30,3 +30,6 @@ 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

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

@ -127,12 +127,6 @@ 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

@ -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;
@ -35,11 +36,12 @@ 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;
@ -60,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>,
@ -228,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;
@ -278,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()))

View File

@ -0,0 +1,200 @@
use std::fs;
use std::sync::OnceLock;
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::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;
/// Type-erased reload handle stored as a trait object.
/// We erase the complex layered type by boxing the modify closure.
static FILTER_HANDLE: OnceLock<Box<dyn FilterControl>> = OnceLock::new();
/// Snapshot of the per-target directives (e.g. `maxminddb=warn`) in effect
/// at `initialize()` time. `set_level` rebuilds the filter from scratch
/// around a new root level; reapplying these keeps any RUST_LOG overrides
/// the operator configured for specific crates from being silently lost.
static PRESERVED_DIRECTIVES: OnceLock<Vec<String>> = OnceLock::new();
pub struct Logging;
impl Logging {
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))?;
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);
// RUST_LOG overrides the config value when present
let level: Level = env::var("RUST_LOG")
.ok()
.and_then(|s| s.parse().ok())
.or_else(|| config.log_level.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 _ = PRESERVED_DIRECTIVES.set(preserved);
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>() {
filter = filter.add_directive(parsed);
}
}
}
let (filter_layer, reload_handle) = reload::Layer::new(filter);
tracing_subscriber::registry()
.with(filter_layer)
.with(stdout_layer)
.with(file_layer)
.with(LogBufferLayer::new(config.log_buffer_capacity, config.log_buffer_max_message_bytes))
.init();
let _ = FILTER_HANDLE.set(Box::new(reload_handle));
Ok(())
}
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(())
}
pub fn set_level(level: &str) -> Result<String, String> {
let handle = FILTER_HANDLE.get().ok_or("Logging not initialized")?;
let parsed_level: Level = level.parse().map_err(|_| {
format!(
"Invalid log level '{}'. Valid levels: trace, debug, info, warn, error",
level
)
})?;
let mut new_filter = EnvFilter::new(parsed_level.to_string());
if let Some(directives) = PRESERVED_DIRECTIVES.get() {
for d in directives {
if let Ok(parsed) = d.parse::<Directive>() {
new_filter = new_filter.add_directive(parsed);
}
}
}
handle.reload_filter(new_filter)?;
Ok(parsed_level.to_string().to_lowercase())
}
/// Get the current global log level as a bare lowercase directive —
/// e.g. `"info"`, not the full `"maxminddb=warn,info"` EnvFilter string.
/// Per-target overrides (like `maxminddb=warn`) are internal tuning and
/// would break the frontend `<select>` that only knows five options.
pub fn current_level() -> String {
FILTER_HANDLE
.get()
.map(|h| extract_main_level(&h.current_filter()))
.unwrap_or_else(|| "unknown".to_string())
}
}
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())
}
}
/// Strip per-target directives out of an EnvFilter string and return the
/// bare level directive in lowercase. Falls back to the raw string if no
/// bare directive is present.
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() {
// Only per-target directives → return lowercased raw so the UI at
// least shows *something* rather than silently misleading.
assert_eq!(extract_main_level("maxminddb=warn"), "maxminddb=warn");
}
}

View File

@ -1,7 +1,6 @@
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;

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;
@ -73,7 +72,10 @@ pub struct AppState {
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,
@ -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()?;
@ -340,7 +332,10 @@ impl ServiceFactory {
database: db,
secret_store,
jwt_service,
comm,
threat_tx,
audit_tx,
drift_tx,
enforce_handler,
soar_engine,
ttl_scheduler,
report_scheduler,

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};
@ -38,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;
@ -48,7 +49,7 @@ 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;
@ -89,7 +90,10 @@ pub struct System {
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>,
@ -121,7 +125,10 @@ impl System {
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),
@ -215,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() {
@ -229,7 +236,7 @@ impl System {
// Start audit logger (subscribe to AuditEvent + DriftDetectedEvent, persist to DB)
let audit_logger = Arc::new(AuditLogger::new(self.database.clone() as Arc<dyn AuditRepo>));
audit_logger.start(&self.comm);
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(
@ -241,9 +248,9 @@ impl System {
// 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;
});
}
@ -252,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(),
);
@ -322,7 +330,9 @@ impl System {
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,
@ -406,7 +416,7 @@ impl System {
}
/// 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;
@ -420,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));
}
}

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;
@ -25,6 +22,9 @@ 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;