From 231fb88efd5740da3342fcb2d3ec8eb220ad5aba Mon Sep 17 00:00:00 2001 From: DaLaw2 Date: Fri, 17 Apr 2026 23:00:34 +0800 Subject: [PATCH] =?UTF-8?q?refactor(v12):=20SYNTHESIS=20M1=E2=80=93M4=20?= =?UTF-8?q?=E2=80=94=20ports,=20SOAR=20split,=20eBPF=20inversion,=20system?= =?UTF-8?q?=20relocation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lands the architecture-review SYNTHESIS in one sweep. Every invariant verified: clippy -D warnings clean, 160/160 tests pass, no adapter→core / core→adapter / core→infrastructure reverse imports. M1 (communication_manager): kept in infrastructure/, added module doc declaring it a cross-BC technical service, not a BC. M2 (repository 9-trait split): replaced the monolithic RepositoryPort with 8 aggregate ports (AclRepo, ApiKeyRepo, AuditRepo, EnforcementRepo, IdentityRepo, SettingRepo, SoarRepo, StatsRepo) + DbAdminRepo for cross-aggregate atomic writes. AppRepo supertrait bundles them for the composition root. Dropped interface/port/repository.rs. DbAdminRepo::commit_soar_block_to_db / commit_soar_unblock_to_db wrap the SOAR block/unblock writes in a single SQLite transaction, mitigating the R2 risk where post-pool-split panics between soar_block_rules and acl_rules would leave DB + in-kernel state out of sync. actions.rs performs eBPF block_ip first, then the tx, with unblock rollback on tx failure. M2.5 (SoarEngine split): core/soar/engine.rs (1607 LOC, mixed lifecycle + domain + application) → engine.rs (918, lifecycle: tokio spawn + event subscription + command dispatch) + matcher.rs (236, pure domain: 0 async/tokio, playbook match + condition eval + cooldown) + actions.rs (509, application: eBPF + DB + notification side effects). M3 (eBPF inversion): moved core/ebpf/ to adapter/ebpf/ via a 4-step sequence — added PacketSink / PacketSinkFactory ports, migrated core consumers to ports (AccessControlAdminPort, DnsFilterPort, RateLimitPort, GeoBlockPort, DnsQueryFilter), inverted xsk_manager's core::ml::Engine dependency through PacketSinkFactory (impl on core/ml/engine.rs), then git-mv'd. Core services no longer import concrete eBPF types. M4 (system relocation): core/system.rs (wiring-only) → infrastructure/ system.rs. main.rs now calls infrastructure::system::System. Leaves the v13 fusion / AlertMessage / Suricata-attack-type work (DOMAIN_MAP §6 backlog B2/B4/B5/B6) intentionally untouched. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/adapter/access_control_adapter.rs | 2 +- .../{core => adapter}/ebpf/access_control.rs | 44 + .../src/{core => adapter}/ebpf/dns_filter.rs | 29 + .../{core => adapter}/ebpf/drop_monitor.rs | 0 .../src/{core => adapter}/ebpf/geo_block.rs | 13 + net-guardia/src/{core => adapter}/ebpf/mod.rs | 22 +- .../{core => adapter}/ebpf/protocol_filter.rs | 0 .../src/{core => adapter}/ebpf/rate_limit.rs | 34 + .../src/{core => adapter}/ebpf/xsk_manager.rs | 32 +- net-guardia/src/adapter/http/api_keys.rs | 8 +- net-guardia/src/adapter/http/auth.rs | 4 +- net-guardia/src/adapter/http/filter.rs | 2 +- net-guardia/src/adapter/http/report.rs | 8 +- net-guardia/src/adapter/http/stats.rs | 2 +- net-guardia/src/adapter/http/system.rs | 6 +- net-guardia/src/adapter/mod.rs | 1 + .../src/adapter/persistence/repository.rs | 350 ++++++-- net-guardia/src/adapter/telegram/mod.rs | 13 +- .../src/adapter/websocket/drop_websocket.rs | 2 +- net-guardia/src/adapter/websocket/routes.rs | 2 +- net-guardia/src/core/acl_service.rs | 24 +- net-guardia/src/core/auth/middleware.rs | 12 +- net-guardia/src/core/config_service.rs | 6 +- net-guardia/src/core/dns_filter_service.rs | 10 +- net-guardia/src/core/email/report.rs | 4 +- net-guardia/src/core/email/scheduler.rs | 18 +- net-guardia/src/core/ml/engine.rs | 22 + net-guardia/src/core/mod.rs | 2 - net-guardia/src/core/notification_service.rs | 15 +- net-guardia/src/core/playbook_service.rs | 100 +-- net-guardia/src/core/rate_limit_service.rs | 14 +- net-guardia/src/core/report/engine.rs | 6 +- net-guardia/src/core/soar/actions.rs | 509 ++++++++++++ net-guardia/src/core/soar/engine.rs | 756 +----------------- net-guardia/src/core/soar/matcher.rs | 236 ++++++ net-guardia/src/core/soar/mod.rs | 2 + net-guardia/src/core/soar/scheduler.rs | 21 +- net-guardia/src/core/stats_aggregator.rs | 14 +- .../src/infrastructure/audit_logger.rs | 6 +- .../infrastructure/communication_manager.rs | 8 + .../infrastructure/enforce_mode_handler.rs | 8 +- net-guardia/src/infrastructure/http_server.rs | 16 +- net-guardia/src/infrastructure/mod.rs | 1 + .../src/infrastructure/service_factory.rs | 57 +- .../src/{core => infrastructure}/system.rs | 18 +- .../interface/port/access_control_admin.rs | 50 ++ net-guardia/src/interface/port/acl.rs | 41 + net-guardia/src/interface/port/api_key.rs | 5 +- net-guardia/src/interface/port/app_repo.rs | 49 ++ net-guardia/src/interface/port/audit.rs | 30 +- net-guardia/src/interface/port/db_admin.rs | 40 + .../src/interface/port/dns_filter_api.rs | 12 + .../src/interface/port/dns_query_filter.rs | 12 + net-guardia/src/interface/port/enforcement.rs | 24 + .../src/interface/port/geo_block_api.rs | 17 + .../port/{repository.rs => identity.rs} | 58 +- net-guardia/src/interface/port/mod.rs | 13 +- .../src/interface/port/notification.rs | 6 - net-guardia/src/interface/port/packet_sink.rs | 23 + .../src/interface/port/rate_limit_api.rs | 21 + .../src/interface/port/secret_store.rs | 4 + net-guardia/src/interface/port/setting.rs | 23 + net-guardia/src/interface/port/soar.rs | 70 +- net-guardia/src/interface/port/stats.rs | 5 +- net-guardia/src/main.rs | 2 +- net-guardia/src/model/report/data.rs | 4 +- 66 files changed, 1833 insertions(+), 1135 deletions(-) rename net-guardia/src/{core => adapter}/ebpf/access_control.rs (86%) rename net-guardia/src/{core => adapter}/ebpf/dns_filter.rs (88%) rename net-guardia/src/{core => adapter}/ebpf/drop_monitor.rs (100%) rename net-guardia/src/{core => adapter}/ebpf/geo_block.rs (94%) rename net-guardia/src/{core => adapter}/ebpf/mod.rs (82%) rename net-guardia/src/{core => adapter}/ebpf/protocol_filter.rs (100%) rename net-guardia/src/{core => adapter}/ebpf/rate_limit.rs (67%) rename net-guardia/src/{core => adapter}/ebpf/xsk_manager.rs (94%) create mode 100644 net-guardia/src/core/soar/actions.rs create mode 100644 net-guardia/src/core/soar/matcher.rs rename net-guardia/src/{core => infrastructure}/system.rs (97%) create mode 100644 net-guardia/src/interface/port/access_control_admin.rs create mode 100644 net-guardia/src/interface/port/acl.rs create mode 100644 net-guardia/src/interface/port/app_repo.rs create mode 100644 net-guardia/src/interface/port/db_admin.rs create mode 100644 net-guardia/src/interface/port/dns_filter_api.rs create mode 100644 net-guardia/src/interface/port/dns_query_filter.rs create mode 100644 net-guardia/src/interface/port/enforcement.rs create mode 100644 net-guardia/src/interface/port/geo_block_api.rs rename net-guardia/src/interface/port/{repository.rs => identity.rs} (62%) create mode 100644 net-guardia/src/interface/port/packet_sink.rs create mode 100644 net-guardia/src/interface/port/rate_limit_api.rs create mode 100644 net-guardia/src/interface/port/setting.rs diff --git a/net-guardia/src/adapter/access_control_adapter.rs b/net-guardia/src/adapter/access_control_adapter.rs index 6669455..5866538 100644 --- a/net-guardia/src/adapter/access_control_adapter.rs +++ b/net-guardia/src/adapter/access_control_adapter.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use async_trait::async_trait; -use crate::core::ebpf::access_control::AccessControl; +use crate::adapter::ebpf::access_control::AccessControl; use crate::interface::port::access_control::AccessControlPort; use crate::model::access_control::list_type::ListType; use crate::model::error::Error; diff --git a/net-guardia/src/core/ebpf/access_control.rs b/net-guardia/src/adapter/ebpf/access_control.rs similarity index 86% rename from net-guardia/src/core/ebpf/access_control.rs rename to net-guardia/src/adapter/ebpf/access_control.rs index dc1f95e..74e39ef 100644 --- a/net-guardia/src/core/ebpf/access_control.rs +++ b/net-guardia/src/adapter/ebpf/access_control.rs @@ -1,12 +1,14 @@ use std::collections::HashMap; use std::net::{Ipv4Addr, Ipv6Addr, SocketAddrV4, SocketAddrV6}; +use async_trait::async_trait; use aya::maps::{HashMap as AyaHashMap, MapData}; use aya::{Ebpf, Pod}; use common::model::ip_address::{IPv4, IPv6, Port}; use common::model::port_rule::PortRule; use tokio::sync::RwLock; +use crate::interface::port::access_control_admin::AccessControlAdminPort; use crate::model::access_control::ip_address::NativeConvert; use crate::model::access_control::list_type::ListType; use crate::model::error::Error; @@ -144,6 +146,48 @@ impl AccessControl { } } +#[async_trait] +impl AccessControlAdminPort for AccessControl { + async fn add_ipv4_list( + &self, + direction: FlowDirection, + list_type: ListType, + address: SocketAddrV4, + ) -> Result<(), Error> { + self.add_ipv4_list(direction, list_type, address).await + } + async fn add_ipv6_list( + &self, + direction: FlowDirection, + list_type: ListType, + address: SocketAddrV6, + ) -> Result<(), Error> { + self.add_ipv6_list(direction, list_type, address).await + } + async fn remove_ipv4_list( + &self, + direction: FlowDirection, + list_type: ListType, + address: SocketAddrV4, + ) -> Result<(), Error> { + self.remove_ipv4_list(direction, list_type, address).await + } + async fn remove_ipv6_list( + &self, + direction: FlowDirection, + list_type: ListType, + address: SocketAddrV6, + ) -> Result<(), Error> { + self.remove_ipv6_list(direction, list_type, address).await + } + async fn get_ipv4_list(&self, direction: FlowDirection, list_type: ListType) -> HashMap> { + self.get_ipv4_list(direction, list_type).await + } + async fn get_ipv6_list(&self, direction: FlowDirection, list_type: ListType) -> HashMap> { + self.get_ipv6_list(direction, list_type).await + } +} + struct MapWrapper { map: Option>, } diff --git a/net-guardia/src/core/ebpf/dns_filter.rs b/net-guardia/src/adapter/ebpf/dns_filter.rs similarity index 88% rename from net-guardia/src/core/ebpf/dns_filter.rs rename to net-guardia/src/adapter/ebpf/dns_filter.rs index 981c250..7ecbc9e 100644 --- a/net-guardia/src/core/ebpf/dns_filter.rs +++ b/net-guardia/src/adapter/ebpf/dns_filter.rs @@ -4,6 +4,8 @@ use std::collections::HashSet; use common::model::dns_name::DnsName; use parking_lot::RwLock; +use crate::interface::port::dns_filter_api::DnsFilterPort; +use crate::interface::port::dns_query_filter::DnsQueryFilter; use crate::model::error::Error; use crate::model::error::misc::MiscError; @@ -34,6 +36,15 @@ impl DnsFilter { self.blacklist.read().iter().filter_map(wire_format_to_domain).collect() } + /// Fast-path helper combining `parse_query_name` + `is_blacklisted` — used + /// by the AF_XDP RX loop. + pub fn is_query_blacklisted(&self, raw: &[u8]) -> bool { + match Self::parse_query_name(raw) { + Some((name, name_len)) => self.is_blacklisted(&name, name_len), + None => false, + } + } + /// Check if a DNS query name (in wire format) or any of its parent domains is blacklisted. pub fn is_blacklisted(&self, name: &DnsName, name_len: usize) -> bool { let bl = self.blacklist.read(); @@ -177,6 +188,24 @@ impl DnsFilter { } } +impl DnsFilterPort for DnsFilter { + fn add_domain(&self, domain: &str) -> Result<(), Error> { + self.add_domain(domain) + } + fn remove_domain(&self, domain: &str) -> Result<(), Error> { + self.remove_domain(domain) + } + fn list_domains(&self) -> Vec { + self.list_domains() + } +} + +impl DnsQueryFilter for DnsFilter { + fn is_query_blacklisted(&self, raw: &[u8]) -> bool { + self.is_query_blacklisted(raw) + } +} + /// Convert a human-readable domain name (e.g., "example.com") to DNS wire format. /// The result is a DnsName with lowercase, length-prefixed labels, zero-terminated and zero-padded. fn domain_to_wire_format(domain: &str) -> Result { diff --git a/net-guardia/src/core/ebpf/drop_monitor.rs b/net-guardia/src/adapter/ebpf/drop_monitor.rs similarity index 100% rename from net-guardia/src/core/ebpf/drop_monitor.rs rename to net-guardia/src/adapter/ebpf/drop_monitor.rs diff --git a/net-guardia/src/core/ebpf/geo_block.rs b/net-guardia/src/adapter/ebpf/geo_block.rs similarity index 94% rename from net-guardia/src/core/ebpf/geo_block.rs rename to net-guardia/src/adapter/ebpf/geo_block.rs index f790a02..553b142 100644 --- a/net-guardia/src/core/ebpf/geo_block.rs +++ b/net-guardia/src/adapter/ebpf/geo_block.rs @@ -9,6 +9,7 @@ use maxminddb::{Reader, geoip2}; use parking_lot::RwLock; use crate::infrastructure::app_config::AppConfig; +use crate::interface::port::geo_block_api::GeoBlockPort; use crate::model::error::Error; use crate::model::error::ebpf::EbpfError; use crate::model::error::misc::MiscError; @@ -202,3 +203,15 @@ impl GeoBlock { } } } + +impl GeoBlockPort for GeoBlock { + fn block_countries(&self, codes: &[String]) -> Result { + self.block_countries(codes) + } + fn unblock_countries(&self, codes: &[String]) -> Result { + self.unblock_countries(codes) + } + fn list_blocked(&self) -> Vec { + self.get_blocked_countries() + } +} diff --git a/net-guardia/src/core/ebpf/mod.rs b/net-guardia/src/adapter/ebpf/mod.rs similarity index 82% rename from net-guardia/src/core/ebpf/mod.rs rename to net-guardia/src/adapter/ebpf/mod.rs index 130c703..d05b923 100644 --- a/net-guardia/src/core/ebpf/mod.rs +++ b/net-guardia/src/adapter/ebpf/mod.rs @@ -15,15 +15,16 @@ use macros::log; use parking_lot::Mutex; use tokio::sync::oneshot; -use crate::core::ebpf::access_control::AccessControl; -use crate::core::ebpf::dns_filter::DnsFilter; -use crate::core::ebpf::drop_monitor::DropMonitor; -use crate::core::ebpf::geo_block::GeoBlock; -use crate::core::ebpf::protocol_filter::ProtocolFilter; -use crate::core::ebpf::rate_limit::RateLimitConfig; -use crate::core::ebpf::xsk_manager::XskManager; -use crate::core::ml::engine::Engine; +use crate::adapter::ebpf::access_control::AccessControl; +use crate::adapter::ebpf::dns_filter::DnsFilter; +use crate::adapter::ebpf::drop_monitor::DropMonitor; +use crate::adapter::ebpf::geo_block::GeoBlock; +use crate::adapter::ebpf::protocol_filter::ProtocolFilter; +use crate::adapter::ebpf::rate_limit::RateLimitConfig; +use crate::adapter::ebpf::xsk_manager::XskManager; use crate::infrastructure::app_config::AppConfig; +use crate::interface::port::dns_query_filter::DnsQueryFilter; +use crate::interface::port::packet_sink::PacketSinkFactory; use crate::model::error::Error; use crate::model::error::ebpf::EbpfError; use crate::model::error::system::SystemError; @@ -83,9 +84,10 @@ impl EbpfServices { } } - pub async fn run(self: Arc, ml_engine: Arc) -> Result<(), Error> { + pub async fn run(self: Arc, sink_factory: Arc) -> Result<(), Error> { let xsk_manager = self.xsk_manager.clone(); - xsk_manager.run(Some(ml_engine), Some(self.dns_filter.clone()), &self.shutdowns)?; + let dns: Arc = self.dns_filter.clone(); + xsk_manager.run(Some(sink_factory), Some(dns), &self.shutdowns)?; let ring_buf = self.drop_ring_buf.lock().take(); if let Some(ring_buf) = ring_buf { diff --git a/net-guardia/src/core/ebpf/protocol_filter.rs b/net-guardia/src/adapter/ebpf/protocol_filter.rs similarity index 100% rename from net-guardia/src/core/ebpf/protocol_filter.rs rename to net-guardia/src/adapter/ebpf/protocol_filter.rs diff --git a/net-guardia/src/core/ebpf/rate_limit.rs b/net-guardia/src/adapter/ebpf/rate_limit.rs similarity index 67% rename from net-guardia/src/core/ebpf/rate_limit.rs rename to net-guardia/src/adapter/ebpf/rate_limit.rs index 9223581..50cf8d9 100644 --- a/net-guardia/src/core/ebpf/rate_limit.rs +++ b/net-guardia/src/adapter/ebpf/rate_limit.rs @@ -2,6 +2,7 @@ use aya::Ebpf; use aya::maps::{Array, MapData}; use parking_lot::Mutex; +use crate::interface::port::rate_limit_api::RateLimitPort; use crate::model::error::Error; use crate::model::error::ebpf::EbpfError; @@ -77,3 +78,36 @@ impl RateLimitConfig { self.get_at(4) } } + +impl RateLimitPort for RateLimitConfig { + fn set_packet_rate(&self, rate: u64) -> Result<(), Error> { + self.set_packet_rate(rate) + } + fn set_syn_rate(&self, rate: u64) -> Result<(), Error> { + self.set_syn_rate(rate) + } + fn set_udp_rate(&self, rate: u64) -> Result<(), Error> { + self.set_udp_rate(rate) + } + fn set_dns_rate(&self, rate: u64) -> Result<(), Error> { + self.set_dns_rate(rate) + } + fn set_window_ns(&self, ns: u64) -> Result<(), Error> { + self.set_window_ns(ns) + } + fn get_packet_rate(&self) -> Result { + self.get_packet_rate() + } + fn get_syn_rate(&self) -> Result { + self.get_syn_rate() + } + fn get_udp_rate(&self) -> Result { + self.get_udp_rate() + } + fn get_dns_rate(&self) -> Result { + self.get_dns_rate() + } + fn get_window_ns(&self) -> Result { + self.get_window_ns() + } +} diff --git a/net-guardia/src/core/ebpf/xsk_manager.rs b/net-guardia/src/adapter/ebpf/xsk_manager.rs similarity index 94% rename from net-guardia/src/core/ebpf/xsk_manager.rs rename to net-guardia/src/adapter/ebpf/xsk_manager.rs index 65d20a6..2355838 100644 --- a/net-guardia/src/core/ebpf/xsk_manager.rs +++ b/net-guardia/src/adapter/ebpf/xsk_manager.rs @@ -16,10 +16,9 @@ use tokio::sync::oneshot::{self, error::TryRecvError}; use xsk_rs::config::{BindFlags, FrameSize, Interface, LibxdpFlags, QueueSize, SocketConfig, UmemConfig}; use xsk_rs::{CompQueue, FillQueue, FrameDesc, RxQueue, Socket, TxQueue, Umem}; -use crate::core::ebpf::dns_filter::DnsFilter; -use crate::core::ml::engine::Engine; -use crate::core::ml::flow_tracker::FlowTracker; use crate::infrastructure::app_config::AppConfig; +use crate::interface::port::dns_query_filter::DnsQueryFilter; +use crate::interface::port::packet_sink::{PacketSink, PacketSinkFactory}; use crate::model::error::Error; use crate::model::error::ebpf::EbpfError; use crate::model::error::system::SystemError; @@ -92,8 +91,8 @@ impl XskManager { pub fn run( &self, - ml_engine: Option>, - dns_filter: Option>, + sinks: Option>, + dns_filter: Option>, shutdowns: &SegQueue>, ) -> Result<(), Error> { // If eBPF failed to load, there are no XSK maps to bind and no queues @@ -110,7 +109,7 @@ impl XskManager { let (ingress_to_egress_tx, ingress_to_egress_rx) = bounded(network.channel_size); let (egress_to_ingress_tx, egress_to_ingress_rx) = bounded(network.channel_size); - let tracker = ml_engine.as_ref().map(|engine| engine.tracker(queue_id).clone()); + let sink = sinks.as_ref().and_then(|f| f.sink_for_queue(queue_id)); let ingress_xsk = XskPair::new( network.clone(), @@ -118,7 +117,7 @@ impl XskManager { &network.ingress_ifname, &network.egress_ifname, Direction::Ingress, - tracker.clone(), + sink.clone(), dns_filter.clone(), )?; @@ -128,7 +127,7 @@ impl XskManager { &network.egress_ifname, &network.ingress_ifname, Direction::Egress, - tracker, + sink, None, )?; @@ -171,8 +170,8 @@ pub struct XskPair { tx: TxQueue, rx: RxQueue, frame_pool: Vec, - tracker: Option>>, - dns_filter: Option>, + sink: Option>, + dns_filter: Option>, packet_buffer_size: usize, buffer_pool_capacity: usize, } @@ -184,8 +183,8 @@ impl XskPair { rx_ifname: &str, _tx_ifname: &str, direction: Direction, - tracker: Option>>, - dns_filter: Option>, + sink: Option>, + dns_filter: Option>, ) -> Result { let rx_ifname_c = CString::new(rx_ifname).map_err(|_| SystemError::InvalidConfig)?; @@ -240,7 +239,7 @@ impl XskPair { tx, rx, frame_pool: pool_frames, - tracker, + sink, dns_filter, packet_buffer_size: config.packet_buffer_size, buffer_pool_capacity: config.buffer_pool_capacity, @@ -356,18 +355,17 @@ impl XskPair { // DNS blacklist check — drop blacklisted DNS queries before forwarding if let Some(ref dns) = self.dns_filter - && let Some((dns_name, name_len)) = DnsFilter::parse_query_name(raw) - && dns.is_blacklisted(&dns_name, name_len) + && dns.is_query_blacklisted(raw) { continue; } // Parse directly from UMEM (zero-copy for ML path). // Only clone for the forwarding path afterwards. - if let Some(ref tracker) = self.tracker + if let Some(ref sink) = self.sink && let Some((packet_info, _)) = parse_packet(raw) { - tracker.lock().process_packet(packet_info, is_ingress); + sink.process_packet(packet_info, is_ingress); } // Clone into pooled buffer for forwarding diff --git a/net-guardia/src/adapter/http/api_keys.rs b/net-guardia/src/adapter/http/api_keys.rs index 5b4bcf7..e3cf309 100644 --- a/net-guardia/src/adapter/http/api_keys.rs +++ b/net-guardia/src/adapter/http/api_keys.rs @@ -2,7 +2,7 @@ use actix_web::{HttpResponse, Scope, web}; use serde::Deserialize; use crate::core::auth::extractor::AuthClaims; -use crate::interface::port::api_key::ApiKeyPort; +use crate::interface::port::api_key::ApiKeyRepo; pub fn initialize() -> Scope { web::scope("/api-keys") @@ -11,7 +11,7 @@ pub fn initialize() -> Scope { .route("/{id}", web::delete().to(delete_key)) } -async fn list_keys(_auth: AuthClaims, db: web::Data) -> HttpResponse { +async fn list_keys(_auth: AuthClaims, db: web::Data) -> HttpResponse { match db.list_api_keys() { Ok(keys) => { let responses: Vec = keys @@ -40,7 +40,7 @@ struct GenerateKeyRequest { async fn generate_key( _auth: AuthClaims, - db: web::Data, + db: web::Data, body: web::Json, ) -> HttpResponse { use rand::Rng; @@ -72,7 +72,7 @@ async fn generate_key( } } -async fn delete_key(_auth: AuthClaims, db: web::Data, path: web::Path) -> HttpResponse { +async fn delete_key(_auth: AuthClaims, db: web::Data, path: web::Path) -> HttpResponse { let id = path.into_inner(); match db.delete_api_key(id) { Ok(true) => HttpResponse::Ok().json(serde_json::json!({"deleted": true})), diff --git a/net-guardia/src/adapter/http/auth.rs b/net-guardia/src/adapter/http/auth.rs index 96db684..ee6420a 100644 --- a/net-guardia/src/adapter/http/auth.rs +++ b/net-guardia/src/adapter/http/auth.rs @@ -5,10 +5,10 @@ use serde::Deserialize; use crate::core::auth::extractor::AuthClaims; use crate::core::auth::jwt::JwtService; use crate::core::auth::password; -use crate::interface::port::repository::RepositoryPort; +use crate::interface::port::app_repo::AppRepo; use crate::model::error::auth::AuthError; -type Repo = dyn RepositoryPort; +type Repo = dyn AppRepo; #[derive(Deserialize)] struct LoginRequest { diff --git a/net-guardia/src/adapter/http/filter.rs b/net-guardia/src/adapter/http/filter.rs index f2fb8a2..6e95e80 100644 --- a/net-guardia/src/adapter/http/filter.rs +++ b/net-guardia/src/adapter/http/filter.rs @@ -5,8 +5,8 @@ use actix_web::{HttpResponse, Responder, Scope, web}; use common::model::http_method::HttpMethod; use serde::Deserialize; +use crate::adapter::ebpf::protocol_filter::ProtocolFilter; use crate::core::dns_filter_service::DnsFilterService; -use crate::core::ebpf::protocol_filter::ProtocolFilter; /// Convert a fallible result into an Ok (200) or InternalServerError (500) response. fn ok_or_error(result: Result) -> HttpResponse { diff --git a/net-guardia/src/adapter/http/report.rs b/net-guardia/src/adapter/http/report.rs index 26103cd..3c50057 100644 --- a/net-guardia/src/adapter/http/report.rs +++ b/net-guardia/src/adapter/http/report.rs @@ -10,8 +10,8 @@ use crate::core::email::report::generate_weekly_report; use crate::core::email::scheduler::SmtpClient; use crate::core::report::engine; use crate::infrastructure::secret_store::SecretStore; -use crate::interface::port::repository::RepositoryPort; use crate::interface::port::secret_store::SecretStorePort; +use crate::interface::port::setting::SettingRepo; pub fn initialize() -> Scope { web::scope("/report") .route("/generate", web::post().to(generate_report)) @@ -31,7 +31,7 @@ async fn generate_report(_auth: AuthClaims, db: web::Data) -> HttpResp })); } let db_ref = db.get_ref(); - match engine::generate_html_report(db_ref as &dyn RepositoryPort, &report_dir) { + match engine::generate_html_report(db_ref as &dyn SettingRepo, &report_dir) { Ok(path) => match fs::read(&path) { Ok(content) => HttpResponse::Ok() .content_type("text/html; charset=utf-8") @@ -57,7 +57,7 @@ async fn generate_report(_auth: AuthClaims, db: web::Data) -> HttpResp async fn report_data(_auth: AuthClaims, db: web::Data) -> HttpResponse { let db_ref = db.get_ref(); - match engine::generate_report_json(db_ref as &dyn RepositoryPort) { + match engine::generate_report_json(db_ref as &dyn SettingRepo) { Ok(data) => HttpResponse::Ok().json(data), Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})), } @@ -65,7 +65,7 @@ async fn report_data(_auth: AuthClaims, db: web::Data) -> HttpResponse /// Manually trigger: generate the weekly report and send it via SMTP now. async fn send_report(_auth: AuthClaims, db: web::Data, secrets: web::Data) -> HttpResponse { - let db_ref = db.get_ref() as &dyn RepositoryPort; + let db_ref = db.get_ref() as &dyn SettingRepo; let secrets_ref = secrets.get_ref() as &dyn SecretStorePort; let smtp = match SmtpClient::from_database(db_ref, Some(secrets_ref)) { diff --git a/net-guardia/src/adapter/http/stats.rs b/net-guardia/src/adapter/http/stats.rs index e7f3892..8728d2e 100644 --- a/net-guardia/src/adapter/http/stats.rs +++ b/net-guardia/src/adapter/http/stats.rs @@ -1,6 +1,6 @@ use actix_web::{HttpResponse, Responder, Scope, web}; -use crate::core::ebpf::drop_monitor::DropMonitor; +use crate::adapter::ebpf::drop_monitor::DropMonitor; use crate::infrastructure::statistics::FlowStatistics; pub fn initialize() -> Scope { diff --git a/net-guardia/src/adapter/http/system.rs b/net-guardia/src/adapter/http/system.rs index 7bb1aaa..7fc0ce4 100644 --- a/net-guardia/src/adapter/http/system.rs +++ b/net-guardia/src/adapter/http/system.rs @@ -3,15 +3,15 @@ use serde::Deserialize; use crate::core::auth::extractor::AuthClaims; use crate::core::config_service::ConfigService; -use crate::core::system::{ShutdownHandle, ShutdownMode}; use crate::infrastructure::communication_manager::CommunicationManager; +use crate::infrastructure::system::{ShutdownHandle, ShutdownMode}; use crate::interface::communication::command_types::ChangeEnforceModeCommand; use crate::interface::communication::query_types::GetEnforceModeQuery; -use crate::interface::port::repository::RepositoryPort; +use crate::interface::port::app_repo::AppRepo; use crate::utils::boot_time; use crate::utils::logging::Logging; -type Repo = dyn RepositoryPort; +type Repo = dyn AppRepo; #[derive(Deserialize)] struct EnforceModeRequest { diff --git a/net-guardia/src/adapter/mod.rs b/net-guardia/src/adapter/mod.rs index c0d2ab8..0670e9f 100644 --- a/net-guardia/src/adapter/mod.rs +++ b/net-guardia/src/adapter/mod.rs @@ -1,4 +1,5 @@ pub mod access_control_adapter; +pub mod ebpf; pub mod http; pub mod persistence; pub mod telegram; diff --git a/net-guardia/src/adapter/persistence/repository.rs b/net-guardia/src/adapter/persistence/repository.rs index 533dcf8..a908a4e 100644 --- a/net-guardia/src/adapter/persistence/repository.rs +++ b/net-guardia/src/adapter/persistence/repository.rs @@ -8,14 +8,15 @@ use r2d2::Pool; use r2d2_sqlite::SqliteConnectionManager; use rusqlite::{self, Connection, Error as RusqliteError, params}; -use crate::interface::port::api_key::{ApiKeyListItem, ApiKeyPort}; -use crate::interface::port::audit::AuditPort; -use crate::interface::port::notification::NotificationConfigPort; -use crate::interface::port::repository::{ - AclRuleTuple, RepositoryPort, UserGroupTuple, UserListItem, UserTuple, UserWithGroups, -}; -use crate::interface::port::soar::{PlaybookRow, SoarExecutionRow, SoarPort}; -use crate::interface::port::stats::StatsPort; +use crate::interface::port::acl::{AclRepo, AclRuleTuple}; +use crate::interface::port::api_key::{ApiKeyListItem, ApiKeyRepo}; +use crate::interface::port::audit::{AuditLogEntry, AuditRepo}; +use crate::interface::port::db_admin::DbAdminRepo; +use crate::interface::port::enforcement::EnforcementRepo; +use crate::interface::port::identity::{IdentityRepo, UserGroupTuple, UserListItem, UserTuple, UserWithGroups}; +use crate::interface::port::setting::SettingRepo; +use crate::interface::port::soar::{PlaybookRow, SoarExecutionRow, SoarRepo}; +use crate::interface::port::stats::StatsRepo; use crate::model::error::Error; use crate::model::error::database::DatabaseError; use crate::model::identity::auth::Claims; @@ -51,14 +52,6 @@ impl r2d2::CustomizeConnection for Sqlite } } -pub struct AuditLogEntry { - pub id: i64, - pub actor: String, - pub action: String, - pub detail: String, - pub created_at: String, -} - pub struct Database { pool: Pool, /// HMAC-SHA256 key for API key hashing, derived from NETGUARDIA_SECRETS_KEY. @@ -1671,9 +1664,14 @@ impl Database { } } -/// Implement the RepositoryPort trait, proving Database satisfies the port contract. -/// This enables adapter-level testing with mock implementations. -impl RepositoryPort for Database { +// --- Aggregate repository trait implementations --- +// +// All trait methods are forward-only wrappers to the inherent impl above. +// The traits exist to enforce aggregate boundaries: callers take +// `Arc` instead of `Arc` so they see only the +// methods of their own aggregate. See `docs/strategy/DOMAIN_MAP.md` §2. + +impl AclRepo for Database { fn insert_acl_rule( &self, ip_version: u8, @@ -1697,6 +1695,21 @@ impl RepositoryPort for Database { fn load_acl_rules(&self) -> Result, Error> { self.load_acl_rules() } + fn has_manual_acl_rule(&self, ip_address: &str) -> Result { + self.has_manual_acl_rule(ip_address) + } + fn load_admin_whitelist(&self) -> Result, Error> { + self.load_admin_whitelist() + } + fn insert_admin_whitelist(&self, ip: &str) -> Result<(), Error> { + self.insert_admin_whitelist(ip) + } + fn delete_admin_whitelist(&self, ip: &str) -> Result<(), Error> { + self.delete_admin_whitelist(ip) + } +} + +impl EnforcementRepo for Database { fn set_rate_limit(&self, key: &str, value: u64) -> Result<(), Error> { self.set_rate_limit(key, value) } @@ -1721,15 +1734,36 @@ impl RepositoryPort for Database { fn load_geo_countries(&self) -> Result, Error> { self.load_geo_countries() } +} + +impl SettingRepo for Database { fn get_setting(&self, key: &str) -> Result, Error> { self.get_setting(key) } fn set_setting(&self, key: &str, value: &str) -> Result<(), Error> { self.set_setting(key, value) } + fn get_app_secret(&self, key: &str) -> Result, Error> { + self.get_app_secret(key) + } + fn set_app_secret(&self, key: &str, plaintext: &str) -> Result<(), Error> { + self.set_app_secret(key, plaintext) + } + fn get_notification_config(&self, channel: &str) -> Result, Error> { + self.get_notification_config(channel) + } + fn set_notification_config(&self, channel: &str, config_json: &str) -> Result<(), Error> { + self.set_notification_config(channel, config_json) + } +} + +impl IdentityRepo for Database { fn find_user(&self, username: &str) -> Result, Error> { self.find_user(username) } + fn find_user_by_id(&self, user_id: i64) -> Result, Error> { + self.find_user_by_id(user_id) + } fn insert_user( &self, username: &str, @@ -1760,9 +1794,6 @@ impl RepositoryPort for Database { fn reset_user_password(&self, user_id: i64, password_hash: &str) -> Result<(), Error> { self.reset_user_password(user_id, password_hash) } - fn find_user_by_id(&self, user_id: i64) -> Result, Error> { - self.find_user_by_id(user_id) - } fn list_user_groups(&self) -> Result, Error> { self.list_user_groups() } @@ -1807,33 +1838,7 @@ impl RepositoryPort for Database { } } -impl SoarPort for Database { - fn get_setting(&self, key: &str) -> Result, Error> { - self.get_setting(key) - } - fn set_setting(&self, key: &str, value: &str) -> Result<(), Error> { - self.set_setting(key, value) - } - fn insert_acl_rule( - &self, - ip_version: u8, - direction: &str, - list_type: &str, - ip_address: &str, - port: u16, - ) -> Result<(), Error> { - self.insert_acl_rule(ip_version, direction, list_type, ip_address, port) - } - fn delete_acl_rule( - &self, - ip_version: u8, - direction: &str, - list_type: &str, - ip_address: &str, - port: u16, - ) -> Result<(), Error> { - self.delete_acl_rule(ip_version, direction, list_type, ip_address, port) - } +impl SoarRepo for Database { fn insert_playbook( &self, name: &str, @@ -1906,9 +1911,6 @@ impl SoarPort for Database { fn mark_soar_block_unblocked(&self, id: i64) -> Result<(), Error> { self.mark_soar_block_unblocked(id) } - fn has_manual_acl_rule(&self, ip_address: &str) -> Result { - self.has_manual_acl_rule(ip_address) - } fn insert_pending_unblock(&self, source_ip: &str) -> Result { self.insert_pending_unblock(source_ip) } @@ -1933,18 +1935,89 @@ impl SoarPort for Database { fn list_soar_executions(&self, limit: i64) -> Result, Error> { self.list_soar_executions(limit) } - fn load_admin_whitelist(&self) -> Result, Error> { - self.load_admin_whitelist() + + // --- tx-4 / tx-5: intra-aggregate atomic operations --- + + fn insert_playbook_atomic( + &self, + name: &str, + trigger_event: &str, + threshold: Option, + count: Option, + window: Option, + cooldown: i64, + actions: &[(i64, String, String)], + conditions: &[(String, String, String, Option)], + ) -> Result { + let mut conn = self.conn()?; + let tx = conn.transaction()?; + tx.execute( + "INSERT INTO playbooks (name, trigger_event, condition_threshold, condition_count, condition_window_secs, cooldown_secs) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![name, trigger_event, threshold, count, window, cooldown], + )?; + let playbook_id = tx.last_insert_rowid(); + for (action_order, action_type, params_json) in actions { + tx.execute( + "INSERT INTO playbook_actions (playbook_id, action_order, action_type, params) VALUES (?1, ?2, ?3, ?4)", + params![playbook_id, action_order, action_type, params_json], + )?; + } + for (condition_type, operator, value, value2) in conditions { + tx.execute( + "INSERT INTO playbook_conditions (playbook_id, condition_type, operator, value, value2) VALUES (?1, ?2, ?3, ?4, ?5)", + params![playbook_id, condition_type, operator, value, value2.as_deref()], + )?; + } + tx.commit()?; + Ok(playbook_id) } - fn insert_admin_whitelist(&self, ip: &str) -> Result<(), Error> { - self.insert_admin_whitelist(ip) - } - fn delete_admin_whitelist(&self, ip: &str) -> Result<(), Error> { - self.delete_admin_whitelist(ip) + + fn update_playbook_atomic( + &self, + id: i64, + row: &UpdatePlaybookRow, + actions: &[(i64, String, String)], + conditions: &[(String, String, String, Option)], + ) -> Result { + let mut conn = self.conn()?; + let tx = conn.transaction()?; + let rows_updated = tx.execute( + "UPDATE playbooks SET name = ?2, trigger_event = ?3, condition_threshold = ?4, \ + condition_count = ?5, condition_window_secs = ?6, cooldown_secs = ?7, \ + updated_at = datetime('now') WHERE id = ?1", + params![ + id, + row.name, + row.trigger_event, + row.condition_threshold, + row.condition_count, + row.condition_window_secs, + row.cooldown_secs + ], + )?; + if rows_updated == 0 { + return Ok(false); + } + tx.execute("DELETE FROM playbook_actions WHERE playbook_id = ?1", params![id])?; + tx.execute("DELETE FROM playbook_conditions WHERE playbook_id = ?1", params![id])?; + for (action_order, action_type, params_json) in actions { + tx.execute( + "INSERT INTO playbook_actions (playbook_id, action_order, action_type, params) VALUES (?1, ?2, ?3, ?4)", + params![id, action_order, action_type, params_json], + )?; + } + for (condition_type, operator, value, value2) in conditions { + tx.execute( + "INSERT INTO playbook_conditions (playbook_id, condition_type, operator, value, value2) VALUES (?1, ?2, ?3, ?4, ?5)", + params![id, condition_type, operator, value, value2.as_deref()], + )?; + } + tx.commit()?; + Ok(true) } } -impl StatsPort for Database { +impl StatsRepo for Database { fn count_weekly_executions(&self, days: i64) -> Result { self.count_weekly_executions(days) } @@ -1965,22 +2038,19 @@ impl StatsPort for Database { } } -impl NotificationConfigPort for Database { - fn get_notification_config(&self, channel: &str) -> Result, Error> { - self.get_notification_config(channel) - } - fn set_notification_config(&self, channel: &str, config_json: &str) -> Result<(), Error> { - self.set_notification_config(channel, config_json) - } -} - -impl AuditPort for Database { +impl AuditRepo for Database { fn insert_audit_log(&self, actor: &str, action: &str, detail: &str) -> Result<(), Error> { self.insert_audit_log(actor, action, detail) } + fn list_audit_logs(&self) -> Result, Error> { + self.list_audit_logs() + } + fn verify_audit_log_chain(&self) -> Result { + self.verify_audit_log_chain() + } } -impl ApiKeyPort for Database { +impl ApiKeyRepo for Database { fn validate_api_key(&self, api_key: &str) -> Result, Error> { self.validate_api_key(api_key) } @@ -1998,6 +2068,52 @@ impl ApiKeyPort for Database { } } +impl DbAdminRepo for Database { + /// tx-1 — Commit a SOAR-driven block to both `soar_block_rules` and + /// `acl_rules` in one transaction. Callers must have already installed + /// the eBPF block before calling this, and are responsible for removing + /// the eBPF block if this returns Err. + fn commit_soar_block_to_db( + &self, + source_ip: &str, + ip_version: u8, + playbook_id: i64, + expires_at: &str, + ) -> Result { + let mut conn = self.conn()?; + let tx = conn.transaction()?; + tx.execute( + "INSERT INTO soar_block_rules (source_ip, playbook_id, expires_at) VALUES (?1, ?2, ?3)", + params![source_ip, playbook_id, expires_at], + )?; + let soar_block_id = tx.last_insert_rowid(); + tx.execute( + "INSERT OR IGNORE INTO acl_rules (ip_version, direction, list_type, ip_address, port) VALUES (?1, ?2, ?3, ?4, ?5)", + params![ip_version, "source", "blacklist", source_ip, 0i64], + )?; + tx.commit()?; + Ok(soar_block_id) + } + + /// tx-2 / tx-3 — Clear a SOAR-driven block: remove the `acl_rules` entry + /// and mark the `soar_block_rules` row as unblocked in one transaction. + /// Callers handle eBPF unblock separately. + fn commit_soar_unblock_to_db(&self, soar_block_id: i64, ip_version: u8, source_ip: &str) -> Result<(), Error> { + let mut conn = self.conn()?; + let tx = conn.transaction()?; + tx.execute( + "DELETE FROM acl_rules WHERE ip_version = ?1 AND direction = ?2 AND list_type = ?3 AND ip_address = ?4 AND port = ?5", + params![ip_version, "source", "blacklist", source_ip, 0i64], + )?; + tx.execute( + "UPDATE soar_block_rules SET unblocked_at = datetime('now') WHERE id = ?1", + params![soar_block_id], + )?; + tx.commit()?; + Ok(()) + } +} + #[cfg(test)] mod tests { use super::*; @@ -2148,25 +2264,89 @@ mod tests { assert!(db.find_user("nobody").unwrap().is_none()); } - /// Verify that Database satisfies the RepositoryPort trait contract. - /// This test ensures the trait impl compiles and can be used via trait object. + /// Verify that Database satisfies each aggregate Repo trait contract + /// (AclRepo / SettingRepo / IdentityRepo). Exercises the trait-object + /// path so callers that take `Arc` compile end-to-end. #[test] - fn test_repository_port_trait_object() { - use crate::interface::port::repository::RepositoryPort; - + fn test_aggregate_repo_trait_objects() { let db = test_db(); - let repo: &dyn RepositoryPort = &db; - // Use via trait object — proves the abstraction works - repo.set_setting("test_key", "test_value").unwrap(); - assert_eq!(repo.get_setting("test_key").unwrap(), Some("test_value".to_string())); + let setting: &dyn SettingRepo = &db; + setting.set_setting("test_key", "test_value").unwrap(); + assert_eq!(setting.get_setting("test_key").unwrap(), Some("test_value".to_string())); - repo.insert_acl_rule(4, "source", "blacklist", "10.0.0.1", 443).unwrap(); - let rules = repo.load_acl_rules().unwrap(); + let acl: &dyn AclRepo = &db; + acl.insert_acl_rule(4, "source", "blacklist", "10.0.0.1", 443).unwrap(); + let rules = acl.load_acl_rules().unwrap(); assert_eq!(rules.len(), 1); - assert_eq!(repo.user_count().unwrap(), 0); - repo.insert_user("test", "hash", "viewer", false).unwrap(); - assert_eq!(repo.user_count().unwrap(), 1); + let identity: &dyn IdentityRepo = &db; + assert_eq!(identity.user_count().unwrap(), 0); + identity.insert_user("test", "hash", "viewer", false).unwrap(); + assert_eq!(identity.user_count().unwrap(), 1); + } + + /// tx-1 — happy path. Verifies `commit_soar_block_to_db` writes both + /// `soar_block_rules` and `acl_rules` atomically. + #[test] + fn test_commit_soar_block_happy_path() { + let db = test_db(); + + // Seed a playbook so the foreign-key-ish playbook_id refers to something real. + let pb_id = db + .insert_playbook("test_pb", "threat_detected", Some(0.9), None, None, 300) + .unwrap(); + + let soar_block_id = db + .commit_soar_block_to_db("10.0.0.99", 4, pb_id, "2099-01-01 00:00:00") + .unwrap(); + assert!(soar_block_id > 0); + + // soar_block_rules has the row + let active = db.get_active_soar_blocks().unwrap(); + assert_eq!(active.len(), 1); + assert_eq!(active[0].1, "10.0.0.99"); + + // acl_rules has the matching row + let rules = db.load_acl_rules().unwrap(); + assert_eq!(rules.len(), 1); + assert_eq!(rules[0].3, "10.0.0.99"); + } + + /// tx-2 / tx-3 — verifies `commit_soar_unblock_to_db` removes the ACL row + /// and marks the SOAR row as unblocked in one transaction. + #[test] + fn test_commit_soar_unblock_clears_both_tables() { + let db = test_db(); + let pb_id = db + .insert_playbook("test_pb", "threat_detected", Some(0.9), None, None, 300) + .unwrap(); + let soar_block_id = db + .commit_soar_block_to_db("10.0.0.99", 4, pb_id, "2099-01-01 00:00:00") + .unwrap(); + + db.commit_soar_unblock_to_db(soar_block_id, 4, "10.0.0.99").unwrap(); + + // acl_rules row gone + assert!(db.load_acl_rules().unwrap().is_empty()); + // soar_block_rules row no longer in "active" view (unblocked_at is set) + assert!(db.get_active_soar_blocks().unwrap().is_empty()); + } + + /// tx-4 — `insert_playbook_atomic` writes playbook + actions + conditions + /// atomically. + #[test] + fn test_insert_playbook_atomic_writes_all_three_tables() { + let db = test_db(); + let actions = vec![(1i64, "block_ip".to_string(), "{}".to_string())]; + let conditions = vec![("threshold".to_string(), ">=".to_string(), "0.8".to_string(), None)]; + let id = db + .insert_playbook_atomic("atom_pb", "threat", Some(0.8), None, None, 300, &actions, &conditions) + .unwrap(); + assert!(id > 0); + let loaded = db.load_playbooks_with_actions().unwrap(); + assert!(!loaded.is_empty()); + let cond_rows = db.load_all_playbook_conditions().unwrap(); + assert_eq!(cond_rows.len(), 1); } } diff --git a/net-guardia/src/adapter/telegram/mod.rs b/net-guardia/src/adapter/telegram/mod.rs index 425fb37..6f05efd 100644 --- a/net-guardia/src/adapter/telegram/mod.rs +++ b/net-guardia/src/adapter/telegram/mod.rs @@ -7,9 +7,10 @@ use parking_lot::Mutex; use reqwest::Client; use tokio::time::sleep; -use crate::interface::port::notification::{AlertNotifier, AlertPayload, NotificationConfigPort}; -use crate::interface::port::repository::RepositoryPort; +use crate::interface::port::app_repo::AppRepo; +use crate::interface::port::notification::{AlertNotifier, AlertPayload}; use crate::interface::port::secret_store::SecretStorePort; +use crate::interface::port::setting::SettingRepo; use crate::model::config::constants::TELEGRAM_MAX_RETRIES; use crate::model::error::Error; use crate::model::error::notification::NotificationError; @@ -18,8 +19,8 @@ use crate::model::log::system::SystemLog; /// Telegram Bot API adapter implementing AlertNotifier. pub struct TelegramAdapter { client: Client, - notif: Arc, - repo: Arc, + notif: Arc, + repo: Arc, secrets: Option>, /// Rate limiter: (count, window_start) rate_state: Mutex<(u32, Instant)>, @@ -27,8 +28,8 @@ pub struct TelegramAdapter { impl TelegramAdapter { pub fn new( - notif: Arc, - repo: Arc, + notif: Arc, + repo: Arc, secrets: Option>, ) -> Result { let client = Client::builder() diff --git a/net-guardia/src/adapter/websocket/drop_websocket.rs b/net-guardia/src/adapter/websocket/drop_websocket.rs index cbc09f7..cb662ef 100644 --- a/net-guardia/src/adapter/websocket/drop_websocket.rs +++ b/net-guardia/src/adapter/websocket/drop_websocket.rs @@ -6,7 +6,7 @@ use macros::log; use tokio::sync::broadcast; use tokio::sync::broadcast::error::RecvError; -use crate::core::ebpf::drop_monitor::DropMonitor; +use crate::adapter::ebpf::drop_monitor::DropMonitor; use crate::model::error::http::HttpError; use crate::model::error::misc::MiscError; use crate::model::log::http::HttpLog; diff --git a/net-guardia/src/adapter/websocket/routes.rs b/net-guardia/src/adapter/websocket/routes.rs index 51732e5..1bf7f0a 100644 --- a/net-guardia/src/adapter/websocket/routes.rs +++ b/net-guardia/src/adapter/websocket/routes.rs @@ -2,8 +2,8 @@ use actix_web::{HttpRequest, HttpResponse, Responder, Scope, web}; use serde::Deserialize; use super::{alert_websocket, drop_websocket, flow_websocket, health_websocket}; +use crate::adapter::ebpf::drop_monitor::DropMonitor; use crate::core::auth::jwt::JwtService; -use crate::core::ebpf::drop_monitor::DropMonitor; use crate::core::ml::alert::MLAlert; use crate::infrastructure::health::SystemHealth; use crate::infrastructure::statistics::FlowStatistics; diff --git a/net-guardia/src/core/acl_service.rs b/net-guardia/src/core/acl_service.rs index 55a6c7a..c2f25c5 100644 --- a/net-guardia/src/core/acl_service.rs +++ b/net-guardia/src/core/acl_service.rs @@ -1,9 +1,9 @@ use std::net::{SocketAddrV4, SocketAddrV6}; use std::sync::Arc; -use crate::core::ebpf::access_control::AccessControl; -use crate::core::ebpf::geo_block::GeoBlock; -use crate::interface::port::repository::RepositoryPort; +use crate::interface::port::access_control_admin::AccessControlAdminPort; +use crate::interface::port::app_repo::AppRepo; +use crate::interface::port::geo_block_api::GeoBlockPort; use crate::model::monitoring::direction::FlowDirection; use macros::log; @@ -14,13 +14,17 @@ use crate::model::error::ebpf::EbpfError; /// Domain service that coordinates ACL changes between DB persistence and eBPF data plane. /// Atomic write: eBPF first, then DB. If DB fails, rollback eBPF. pub struct AclService { - db: Arc, - access_control: Arc, - geo_block: Arc, + db: Arc, + access_control: Arc, + geo_block: Arc, } impl AclService { - pub fn new(db: Arc, access_control: Arc, geo_block: Arc) -> Self { + pub fn new( + db: Arc, + access_control: Arc, + geo_block: Arc, + ) -> Self { Self { db, access_control, @@ -147,11 +151,11 @@ impl AclService { } pub fn get_blocked_countries(&self) -> Vec { - self.geo_block.get_blocked_countries() + self.geo_block.list_blocked() } - pub fn access_control(&self) -> &AccessControl { - &self.access_control + pub fn access_control(&self) -> &dyn AccessControlAdminPort { + self.access_control.as_ref() } } diff --git a/net-guardia/src/core/auth/middleware.rs b/net-guardia/src/core/auth/middleware.rs index c3cd6b1..e0854b9 100644 --- a/net-guardia/src/core/auth/middleware.rs +++ b/net-guardia/src/core/auth/middleware.rs @@ -11,8 +11,8 @@ use actix_web::{Error as ActixError, HttpMessage, HttpResponse, web}; use macros::log; use crate::core::auth::jwt::JwtService; -use crate::interface::port::api_key::ApiKeyPort; -use crate::interface::port::repository::RepositoryPort; +use crate::interface::port::api_key::ApiKeyRepo; +use crate::interface::port::app_repo::AppRepo; use crate::model::error::auth::AuthError; pub struct AuthMiddleware; @@ -142,19 +142,19 @@ where } else if let Some(api_key_header) = req.headers().get("X-API-Key") { // API key auth with rate limiting let api_key = api_key_header.to_str().unwrap_or(""); - let api_key_port = match req.app_data::>() { + let api_key_port = match req.app_data::>() { Some(d) => d.clone(), None => { let resp = HttpResponse::InternalServerError() - .json(serde_json::json!({"error": "ApiKeyPort not configured"})); + .json(serde_json::json!({"error": "ApiKeyRepo not configured"})); return Ok(req.into_response(resp).map_into_right_body()); } }; - let repo = match req.app_data::>() { + let repo = match req.app_data::>() { Some(d) => d.clone(), None => { let resp = HttpResponse::InternalServerError() - .json(serde_json::json!({"error": "RepositoryPort not configured"})); + .json(serde_json::json!({"error": "AppRepo not configured"})); return Ok(req.into_response(resp).map_into_right_body()); } }; diff --git a/net-guardia/src/core/config_service.rs b/net-guardia/src/core/config_service.rs index 145a526..bfefd56 100644 --- a/net-guardia/src/core/config_service.rs +++ b/net-guardia/src/core/config_service.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use serde_json::Value; -use crate::interface::port::repository::RepositoryPort; +use crate::interface::port::app_repo::AppRepo; use crate::interface::port::secret_store::SecretStorePort; use crate::model::error::Error; use crate::model::error::misc::MiscError; @@ -63,12 +63,12 @@ const SETTINGS_MAP: &[(&str, &[&str])] = &[ /// Domain service for system configuration read/write. pub struct ConfigService { - db: Arc, + db: Arc, secrets: Option>, } impl ConfigService { - pub fn new(db: Arc) -> Self { + pub fn new(db: Arc) -> Self { Self { db, secrets: None } } diff --git a/net-guardia/src/core/dns_filter_service.rs b/net-guardia/src/core/dns_filter_service.rs index 01de1a6..8d5ce50 100644 --- a/net-guardia/src/core/dns_filter_service.rs +++ b/net-guardia/src/core/dns_filter_service.rs @@ -1,19 +1,19 @@ use std::sync::Arc; -use crate::core::ebpf::dns_filter::DnsFilter; -use crate::interface::port::repository::RepositoryPort; +use crate::interface::port::app_repo::AppRepo; +use crate::interface::port::dns_filter_api::DnsFilterPort; use crate::model::error::Error; use crate::model::error::misc::MiscError; /// Domain service that coordinates DNS filter changes between DB and in-memory service. /// Write order: eBPF/in-memory first, then DB — if eBPF fails, DB remains clean. pub struct DnsFilterService { - db: Arc, - dns_filter: Arc, + db: Arc, + dns_filter: Arc, } impl DnsFilterService { - pub fn new(db: Arc, dns_filter: Arc) -> Self { + pub fn new(db: Arc, dns_filter: Arc) -> Self { Self { db, dns_filter } } diff --git a/net-guardia/src/core/email/report.rs b/net-guardia/src/core/email/report.rs index 8c529c4..4a932c9 100644 --- a/net-guardia/src/core/email/report.rs +++ b/net-guardia/src/core/email/report.rs @@ -1,6 +1,6 @@ use chrono::Local; -use crate::interface::port::repository::RepositoryPort; +use crate::interface::port::setting::SettingRepo; use crate::model::error::Error; /// Generate an HTML weekly report email body. @@ -14,7 +14,7 @@ use crate::model::error::Error; /// - `weekly_system_health` (JSON object with cpu, memory, disk fields) /// /// If a key is missing the report uses empty/zero defaults. -pub fn generate_weekly_report(db: &dyn RepositoryPort) -> Result { +pub fn generate_weekly_report(db: &dyn SettingRepo) -> Result { let threats_count = db .get_setting("weekly_threats_count")? .unwrap_or_else(|| "0".to_string()); diff --git a/net-guardia/src/core/email/scheduler.rs b/net-guardia/src/core/email/scheduler.rs index 7f48cff..10bff78 100644 --- a/net-guardia/src/core/email/scheduler.rs +++ b/net-guardia/src/core/email/scheduler.rs @@ -9,9 +9,8 @@ use tokio::task::{JoinHandle, spawn_blocking}; use tokio::time::{self, Duration}; use super::report; -use crate::interface::port::repository::RepositoryPort; use crate::interface::port::secret_store::SecretStorePort; -use crate::interface::port::soar::SoarPort; +use crate::interface::port::setting::SettingRepo; use crate::model::error::Error; use crate::model::error::notification::NotificationError; use crate::model::log::system::SystemLog; @@ -33,10 +32,7 @@ impl SmtpClient { /// Returns `None` if any required setting (`smtp_host`, `smtp_port`, /// `smtp_username`, `smtp_password`) is missing. /// If a `SecretStorePort` is provided, reads the password from the secret store. - pub fn from_database( - db: &dyn RepositoryPort, - secrets: Option<&dyn SecretStorePort>, - ) -> Result, Error> { + pub fn from_database(db: &dyn SettingRepo, secrets: Option<&dyn SecretStorePort>) -> Result, Error> { let host = match db.get_setting("smtp_host")? { Some(v) if !v.is_empty() => v, _ => return Ok(None), @@ -79,9 +75,9 @@ impl SmtpClient { })) } - /// Try to construct an `SmtpClient` from a SOAR port (which also provides `get_setting`). - /// Same logic as `from_database`, but accepts `&dyn SoarPort` instead of `&dyn RepositoryPort`. - pub fn from_soar_port(db: &dyn SoarPort, secrets: Option<&dyn SecretStorePort>) -> Result, Error> { + /// Try to construct an `SmtpClient` from any SettingRepo implementation. + /// Kept as a separate method name for call-site clarity (SOAR actions). + pub fn from_soar_port(db: &dyn SettingRepo, secrets: Option<&dyn SecretStorePort>) -> Result, Error> { let host = match db.get_setting("smtp_host")? { Some(v) if !v.is_empty() => v, _ => return Ok(None), @@ -181,12 +177,12 @@ impl SmtpClient { /// Scheduler that checks once per hour whether it is time to send the weekly /// report (Monday 08:00 local time) and dispatches it via SMTP. pub struct ReportScheduler { - db: Arc, + db: Arc, secrets: Option>, } impl ReportScheduler { - pub fn new(db: Arc, secrets: Option>) -> Self { + pub fn new(db: Arc, secrets: Option>) -> Self { Self { db, secrets } } diff --git a/net-guardia/src/core/ml/engine.rs b/net-guardia/src/core/ml/engine.rs index f632312..b80eabb 100644 --- a/net-guardia/src/core/ml/engine.rs +++ b/net-guardia/src/core/ml/engine.rs @@ -14,9 +14,11 @@ use super::flow_tracker::{FlowData, FlowTracker}; use super::inference::Inference; use super::model_loader::MLModels; use super::traffic_logger::TrafficLogger; +use crate::interface::port::packet_sink::{PacketSink, PacketSinkFactory}; use crate::model::detection::flow_features::FlowFeatures; use crate::model::detection::ml_detection::{EngineConfig, FlowKey, InferenceStats}; use crate::model::log::ml::MLLog; +use crate::model::monitoring::user_packet::UserPacket; use crate::model::system::config::MLInferenceConfig; /// Per-queue tracker. With symmetric hash in eBPF, both directions of a flow @@ -269,3 +271,23 @@ impl Engine { } } } + +/// Adapter that exposes one `ThreadTracker` (per AF_XDP queue) as a +/// `PacketSink`. `XskManager` holds `Arc` per queue and never +/// touches `FlowTracker` concrete types. +struct QueueTrackerSink { + tracker: ThreadTracker, +} + +impl PacketSink for QueueTrackerSink { + fn process_packet(&self, packet: UserPacket, is_ingress: bool) { + self.tracker.lock().process_packet(packet, is_ingress); + } +} + +impl PacketSinkFactory for Engine { + fn sink_for_queue(&self, queue_id: u32) -> Option> { + let tracker = self.tracker(queue_id).clone(); + Some(Arc::new(QueueTrackerSink { tracker })) + } +} diff --git a/net-guardia/src/core/mod.rs b/net-guardia/src/core/mod.rs index 746b348..334ac08 100644 --- a/net-guardia/src/core/mod.rs +++ b/net-guardia/src/core/mod.rs @@ -4,7 +4,6 @@ pub mod config_service; pub mod correlation; pub mod detection; pub mod dns_filter_service; -pub mod ebpf; pub mod email; pub mod ml; pub mod notification_service; @@ -13,4 +12,3 @@ pub mod rate_limit_service; pub mod report; pub mod soar; pub mod stats_aggregator; -pub mod system; diff --git a/net-guardia/src/core/notification_service.rs b/net-guardia/src/core/notification_service.rs index 80889e9..791acea 100644 --- a/net-guardia/src/core/notification_service.rs +++ b/net-guardia/src/core/notification_service.rs @@ -4,26 +4,23 @@ use serde_json::Value; use crate::adapter::telegram::TelegramAdapter; use crate::core::email::scheduler::SmtpClient; -use crate::interface::port::notification::{AlertNotifier, NotificationConfigPort}; -use crate::interface::port::repository::RepositoryPort; +use crate::interface::port::app_repo::AppRepo; +use crate::interface::port::notification::AlertNotifier; use crate::interface::port::secret_store::SecretStorePort; +use crate::interface::port::setting::SettingRepo; use crate::model::error::Error; use crate::model::error::misc::MiscError; /// Domain service for notification config (Telegram, SMTP). /// Coordinates DB persistence and external service testing. pub struct NotificationService { - notif: Arc, - repo: Arc, + notif: Arc, + repo: Arc, secrets: Arc, } impl NotificationService { - pub fn new( - notif: Arc, - repo: Arc, - secrets: Arc, - ) -> Self { + pub fn new(notif: Arc, repo: Arc, secrets: Arc) -> Self { Self { notif, repo, secrets } } diff --git a/net-guardia/src/core/playbook_service.rs b/net-guardia/src/core/playbook_service.rs index 5074b9a..f4e8cac 100644 --- a/net-guardia/src/core/playbook_service.rs +++ b/net-guardia/src/core/playbook_service.rs @@ -2,12 +2,11 @@ use std::collections::HashMap; use std::net::IpAddr; use std::sync::Arc; -use macros::log; use serde_json::Value; use crate::core::soar::engine::SoarEngine; use crate::interface::port::access_control::AccessControlPort; -use crate::interface::port::soar::SoarPort; +use crate::interface::port::app_repo::AppRepo; use crate::model::error::Error; use crate::model::error::soar::SoarError; use crate::model::soar::playbook_data::{ @@ -17,17 +16,13 @@ use crate::model::soar::playbook_data::{ /// Domain service for SOAR playbook CRUD operations. /// Coordinates DB reads/writes, SOAR engine cache refresh, and eBPF unblock. pub struct PlaybookService { - db: Arc, + db: Arc, soar_engine: Arc, access_control: Arc, } impl PlaybookService { - pub fn new( - db: Arc, - soar_engine: Arc, - access_control: Arc, - ) -> Self { + pub fn new(db: Arc, soar_engine: Arc, access_control: Arc) -> Self { Self { db, soar_engine, @@ -126,27 +121,35 @@ impl PlaybookService { } pub fn create_playbook(&self, input: &CreatePlaybookInput) -> Result { - let playbook_id = self.db.insert_playbook( + // tx-4: single atomic insert (playbook + actions + conditions) + let actions: Vec<(i64, String, String)> = input + .actions + .iter() + .enumerate() + .map(|(i, (ty, params))| ((i + 1) as i64, ty.clone(), params.clone())) + .collect(); + let conditions: Vec<(String, String, String, Option)> = input + .conditions + .iter() + .map(|c| { + ( + c.condition_type.clone(), + c.operator.clone(), + c.value.clone(), + c.value2.clone(), + ) + }) + .collect(); + let playbook_id = self.db.insert_playbook_atomic( &input.name, &input.trigger_event, input.condition_threshold, input.condition_count, input.condition_window_secs, input.cooldown_secs, + &actions, + &conditions, )?; - for (i, (action_type, params_str)) in input.actions.iter().enumerate() { - self.db - .insert_playbook_action(playbook_id, (i + 1) as i64, action_type, params_str)?; - } - for cond in &input.conditions { - self.db.insert_playbook_condition( - playbook_id, - &cond.condition_type, - &cond.operator, - &cond.value, - cond.value2.as_deref(), - )?; - } self.soar_engine.reload_cache()?; Ok(playbook_id) } @@ -160,28 +163,29 @@ impl PlaybookService { condition_window_secs: input.condition_window_secs, cooldown_secs: input.cooldown_secs, }; - let updated = self.db.update_playbook(id, &row)?; + // tx-5: single atomic update (playbook metadata + replace actions/conditions) + let actions: Vec<(i64, String, String)> = input + .actions + .iter() + .enumerate() + .map(|(i, (ty, params))| ((i + 1) as i64, ty.clone(), params.clone())) + .collect(); + let conditions: Vec<(String, String, String, Option)> = input + .conditions + .iter() + .map(|c| { + ( + c.condition_type.clone(), + c.operator.clone(), + c.value.clone(), + c.value2.clone(), + ) + }) + .collect(); + let updated = self.db.update_playbook_atomic(id, &row, &actions, &conditions)?; if !updated { return Ok(false); } - - // Delete old actions and conditions, then re-insert - self.db.delete_playbook_actions(id)?; - self.db.delete_playbook_conditions(id)?; - - for (i, (action_type, params_str)) in input.actions.iter().enumerate() { - self.db - .insert_playbook_action(id, (i + 1) as i64, action_type, params_str)?; - } - for cond in &input.conditions { - self.db.insert_playbook_condition( - id, - &cond.condition_type, - &cond.operator, - &cond.value, - cond.value2.as_deref(), - )?; - } self.soar_engine.reload_cache()?; Ok(true) } @@ -215,7 +219,9 @@ impl PlaybookService { .collect()) } - /// Manually unblock an IP: remove from eBPF, mark DB, decrement counter. + /// Manually unblock an IP: remove from eBPF, atomically clear both DB + /// tables via `DbAdminRepo::commit_soar_unblock_to_db` (tx-3), decrement + /// counter. pub async fn manual_unblock(&self, id: i64) -> Result<(), Error> { // Look up the block to get source_ip let block = self @@ -227,14 +233,10 @@ impl PlaybookService { // Remove from eBPF ACL self.access_control.unblock_ip(source_ip).await?; - // Also remove the auto-added acl_rules entry + // Atomically drop acl_rules entry AND mark soar_block_rules unblocked + // in one transaction (R2 mitigation, tx-3 per M2_CARVE_PLAN §4b). let ip_version = ip_version_from_str(source_ip); - if let Err(e) = self.db.delete_acl_rule(ip_version, "source", "blacklist", source_ip, 0) { - log!(SoarError::AclCleanupFailed(e)); - } - - // Mark as unblocked in DB - self.db.mark_soar_block_unblocked(id)?; + self.db.commit_soar_unblock_to_db(id, ip_version, source_ip)?; // Decrement active block counter self.soar_engine.decrement_block_count(); diff --git a/net-guardia/src/core/rate_limit_service.rs b/net-guardia/src/core/rate_limit_service.rs index 31927b0..7746245 100644 --- a/net-guardia/src/core/rate_limit_service.rs +++ b/net-guardia/src/core/rate_limit_service.rs @@ -1,22 +1,22 @@ use std::sync::Arc; -use crate::core::ebpf::rate_limit::RateLimitConfig; -use crate::interface::port::repository::RepositoryPort; +use crate::interface::port::app_repo::AppRepo; +use crate::interface::port::rate_limit_api::RateLimitPort; use crate::model::error::Error; /// Domain service that coordinates rate limit config updates between DB and eBPF. pub struct RateLimitService { - db: Arc, - config: Arc, + db: Arc, + config: Arc, } impl RateLimitService { - pub fn new(db: Arc, config: Arc) -> Self { + pub fn new(db: Arc, config: Arc) -> Self { Self { db, config } } - pub fn config(&self) -> &RateLimitConfig { - &self.config + pub fn config(&self) -> &dyn RateLimitPort { + self.config.as_ref() } pub fn update(&self, settings: &RateLimitSettings) -> Result<(), Error> { diff --git a/net-guardia/src/core/report/engine.rs b/net-guardia/src/core/report/engine.rs index 25c97ca..19ad6f9 100644 --- a/net-guardia/src/core/report/engine.rs +++ b/net-guardia/src/core/report/engine.rs @@ -4,7 +4,7 @@ use std::path::PathBuf; use chrono::Local; use macros::log; -use crate::interface::port::repository::RepositoryPort; +use crate::interface::port::setting::SettingRepo; use crate::model::error::Error; use crate::model::error::io::IOError; use crate::model::error::misc::MiscError; @@ -13,7 +13,7 @@ use crate::model::report::data::ReportData; /// Generate a self-contained HTML security report and write to disk. /// Returns the path to the generated HTML file. -pub fn generate_html_report(db: &dyn RepositoryPort, output_dir: &str) -> Result { +pub fn generate_html_report(db: &dyn SettingRepo, output_dir: &str) -> Result { let data = ReportData::from_database(db)?; let html = render_html_report(&data); @@ -202,7 +202,7 @@ fn html_escape(s: &str) -> String { } /// Generate report data and format as JSON (for API responses). -pub fn generate_report_json(db: &dyn RepositoryPort) -> Result { +pub fn generate_report_json(db: &dyn SettingRepo) -> Result { let data = ReportData::from_database(db)?; serde_json::to_value(&data).map_err(|e| MiscError::SerializeError(e).into()) } diff --git a/net-guardia/src/core/soar/actions.rs b/net-guardia/src/core/soar/actions.rs new file mode 100644 index 0000000..452996f --- /dev/null +++ b/net-guardia/src/core/soar/actions.rs @@ -0,0 +1,509 @@ +//! SOAR application layer: playbook orchestration + action execution. +//! +//! Each action here reaches out to external systems (DB writes, eBPF blocks, +//! Telegram API, SMTP, webhooks). They are all invoked through `execute_action` +//! dispatch, which is itself called from `execute_playbook` after the domain +//! layer (`matcher.rs`) decided the playbook should fire. + +use std::net::{IpAddr, SocketAddr}; +use std::sync::atomic::Ordering; +use std::time::Duration; + +use chrono::{Duration as ChronoDuration, Utc}; +use macros::log; +use reqwest::Client; +use tokio::net::lookup_host; +use tokio::task::spawn_blocking; +use url::Url; + +use crate::core::playbook_service::ip_version_from_str; +use crate::core::soar::engine::SoarEngine; +use crate::interface::port::notification::AlertPayload; +use crate::model::error::Error; +use crate::model::error::soar::SoarError; +use crate::model::event::ThreatDetectedEvent; +use crate::model::log::soar::SoarLog; +use crate::model::soar::playbook::{Playbook, PlaybookAction}; + +impl SoarEngine { + /// Check if the system is in enforce mode (as opposed to monitor mode). + /// Reads from the in-memory AtomicU8 cache: Monitor=0, MlOnly=1, Enforce=2. + pub(super) fn is_enforce_mode(&self) -> bool { + self.enforce_level_cache.load(Ordering::Relaxed) == 2 + } + + /// Execute a single playbook against an event. + pub(super) async fn execute_playbook(&self, playbook: &Playbook, event: &ThreatDetectedEvent) -> Result<(), Error> { + // Check cooldown + if self.is_cooldown_active(playbook.id, &event.source_ip, playbook.cooldown_secs) { + log!(SoarLog::CooldownActive(playbook.name.clone(), event.source_ip.clone())); + return Ok(()); + } + + // Check admin whitelist + if self.admin_whitelist.read().contains(&event.source_ip) { + log!(SoarLog::WhitelistSkipped( + event.source_ip.clone(), + playbook.name.clone() + )); + return Ok(()); + } + + // Execute actions in order + let mut action_results = Vec::new(); + for action in &playbook.actions { + let result = self.execute_action(action, event, playbook.id).await; + let result_json = match &result { + Ok(msg) => serde_json::json!({"action": &action.action_type, "status": "ok", "message": msg}), + Err(e) => { + serde_json::json!({"action": &action.action_type, "status": "error", "message": e.to_string()}) + } + }; + action_results.push(result_json); + if let Err(e) = result { + log!(SoarLog::PlaybookError( + playbook.name.clone(), + format!("Action '{}': {}", action.action_type, e) + )); + } + } + + // Record cooldown + self.record_cooldown(playbook.id, &event.source_ip); + + // Write audit trail + let actions_json = serde_json::to_string(&action_results).unwrap_or_default(); + self.db + .insert_soar_execution(playbook.id, Some(&event.source_ip), &event.attack_type, &actions_json)?; + + log!(SoarLog::PlaybookExecuted( + playbook.name.clone(), + event.source_ip.clone(), + event.attack_type.clone() + )); + + Ok(()) + } + + /// Execute a single action. + pub(super) async fn execute_action( + &self, + action: &PlaybookAction, + event: &ThreatDetectedEvent, + playbook_id: i64, + ) -> Result { + match action.action_type.as_str() { + "block_ip" => { + if !self.is_enforce_mode() { + log!(SoarLog::MonitorModeSkipped( + action.action_type.clone(), + event.source_ip.clone() + )); + return Ok(format!("[monitor] Would block IP {} — skipped", event.source_ip)); + } + self.action_block_ip(action, event, playbook_id).await + } + "adjust_rate_limit" => { + if !self.is_enforce_mode() { + log!(SoarLog::MonitorModeSkipped( + action.action_type.clone(), + event.source_ip.clone() + )); + return Ok("[monitor] Would adjust rate limit — skipped".to_string()); + } + self.action_adjust_rate_limit(action, event).await + } + "send_telegram" => self.action_send_telegram(event).await, + "send_email" => self.action_send_email(event).await, + "webhook" => self.action_webhook(action, event).await, + "log" => self.action_log(action, event), + other => Err(SoarError::UnknownActionType(other))?, + } + } + + /// Block an IP via eBPF ACL with TTL. + async fn action_block_ip( + &self, + action: &PlaybookAction, + event: &ThreatDetectedEvent, + playbook_id: i64, + ) -> Result { + let ttl_secs = action.params.get("ttl_secs").and_then(|v| v.as_u64()).unwrap_or(1800); + + // Validate TTL (runtime-configurable via DB) + let max_ttl: u64 = self + .db + .get_setting("soar_max_ttl_secs") + .ok() + .flatten() + .and_then(|v| v.parse().ok()) + .unwrap_or(86400); + if ttl_secs > max_ttl { + Err(SoarError::InvalidTtl(ttl_secs, max_ttl))?; + } + + // Atomically check cap and reserve a slot using CAS loop (runtime-configurable via DB) + let max_cap: u32 = self + .db + .get_setting("soar_max_auto_block_cap") + .ok() + .flatten() + .and_then(|v| v.parse().ok()) + .unwrap_or(100); + loop { + let current_count = self.active_block_count.load(Ordering::SeqCst); + if current_count >= max_cap { + log!(SoarLog::CapReached(current_count, max_cap, event.source_ip.clone())); + Err(SoarError::CapReached(max_cap))?; + } + if self + .active_block_count + .compare_exchange(current_count, current_count + 1, Ordering::SeqCst, Ordering::SeqCst) + .is_ok() + { + break; + } + } + + // Block IP via AccessControlPort (handles IPv4/IPv6 dispatch internally) + if let Err(e) = self.access_control.block_ip(&event.source_ip).await { + self.decrement_block_count(); + return Err(e); + } + + // Calculate expiry time + let expires_at = Utc::now() + ChronoDuration::seconds(ttl_secs as i64); + let expires_str = expires_at.format("%Y-%m-%d %H:%M:%S").to_string(); + + // tx-1 (R2 mitigation): atomically write soar_block_rules + acl_rules. + // Either both commit or both roll back — no half-state possible. + let ip_version = ip_version_from_str(&event.source_ip); + if let Err(e) = self + .db + .commit_soar_block_to_db(&event.source_ip, ip_version, playbook_id, &expires_str) + { + // DB tx rolled back both rows; now roll back the eBPF block. + if let Err(unblock_err) = self.access_control.unblock_ip(&event.source_ip).await { + log!(SoarLog::EventHandlingFailed(format!( + "CRITICAL: Failed to unblock IP {} after DB error — queueing for retry: {}", + event.source_ip, unblock_err + ))); + // Write to pending_unblock table so recovery can retry later + if let Err(pend_err) = self.db.insert_pending_unblock(&event.source_ip) { + log!(SoarLog::EventHandlingFailed(format!( + "CRITICAL: Failed to queue pending unblock for IP {}: {}", + event.source_ip, pend_err + ))); + } + } + self.decrement_block_count(); + return Err(e); + } + + Ok(format!("Blocked IP {} for {}s", event.source_ip, ttl_secs)) + } + + /// Temporarily reduce global rate limits by a factor with TTL-based restoration. + /// Params: { "factor": 0.5, "ttl_secs": 600 } + /// factor < 1.0 means stricter (e.g. 0.5 = half the current rate). + async fn action_adjust_rate_limit( + &self, + action: &PlaybookAction, + event: &ThreatDetectedEvent, + ) -> Result { + let rate_limit = self.rate_limit.as_ref().ok_or(SoarError::RateLimitUnavailable)?; + + let factor = action.params.get("factor").and_then(|v| v.as_f64()).unwrap_or(0.5); + let ttl_secs = action.params.get("ttl_secs").and_then(|v| v.as_u64()).unwrap_or(600); + + if !(0.01..=1.0).contains(&factor) { + Err(SoarError::InvalidRateLimitFactor(factor))?; + } + + let max_ttl: u64 = self + .db + .get_setting("soar_max_ttl_secs") + .ok() + .flatten() + .and_then(|v| v.parse().ok()) + .unwrap_or(86400); + if ttl_secs > max_ttl { + Err(SoarError::InvalidTtl(ttl_secs, max_ttl))?; + } + + // Acquire lock to serialize rate limit read-save-write (Item 6: atomicity) + let _guard = self.rate_limit_lock.lock().await; + + // Read current rates, save originals, apply reduced rates + 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); + + // Store original rates for restoration (only if not already adjusted) + let key = "soar_rate_limit_original"; + if self.db.get_setting(key)?.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, + }); + self.db.set_setting(key, &original.to_string())?; + } + + // Store TTL for restoration + let expires_at = Utc::now() + ChronoDuration::seconds(ttl_secs as i64); + self.db.set_setting( + "soar_rate_limit_expires", + &expires_at.format("%Y-%m-%d %H:%M:%S").to_string(), + )?; + + // Apply reduced rates + 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, + event.source_ip.clone(), + event.attack_type.clone(), + 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, event.source_ip + )) + } + + /// Send Telegram notification. + async fn action_send_telegram(&self, event: &ThreatDetectedEvent) -> Result { + if let Some(notifier) = &self.alert_notifier { + let country = if let Some(geoip) = &self.geoip { + if let Ok(ip_addr) = event.source_ip.parse::() { + match geoip.lookup(ip_addr).await { + Ok(Some(loc)) => loc.country, + _ => None, + } + } else { + None + } + } else { + None + }; + let repeat_tag = if event.is_repeat_offender { " [REPEAT]" } else { "" }; + let payload = AlertPayload { + source_ip: event.source_ip.clone(), + dest_ip: event.dest_ip.clone(), + country, + threat_type: event.attack_type.clone(), + confidence: event.confidence, + action_description: format!( + "SOAR auto-response triggered (hits: {}{})", + event.flow_count, repeat_tag, + ), + timestamp: Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(), + }; + notifier.send_alert(&payload).await?; + Ok("Telegram notification sent".to_string()) + } else { + log!(SoarLog::TelegramNotConfigured); + Ok("Telegram not configured, skipped".to_string()) + } + } + + /// Send email alert. + async fn action_send_email(&self, event: &ThreatDetectedEvent) -> Result { + use crate::core::email::scheduler::SmtpClient; + match SmtpClient::from_soar_port(&*self.db, self.secrets.as_deref())? { + Some(smtp) => { + let subject = format!( + "[NetGuardia] Threat Alert: {} from {}", + event.attack_type, event.source_ip + ); + let body = format!( + "

Threat Detected

\ +

Source IP: {}

\ +

Threat Type: {}

\ +

Confidence: {:.1}%

\ +

Time: {}

", + event.source_ip, + event.attack_type, + event.confidence * 100.0, + Utc::now().format("%Y-%m-%d %H:%M:%S UTC"), + ); + if let Some(recipient) = self.db.get_setting("smtp_recipient")? { + spawn_blocking(move || smtp.send(&recipient, &subject, &body)) + .await + .map_err(|e| SoarError::ActionFailed("send_email", e))??; + Ok("Email alert sent".to_string()) + } else { + Ok("No SMTP recipient configured, skipped".to_string()) + } + } + None => Ok("SMTP not configured, skipped".to_string()), + } + } + + /// Send a webhook HTTP POST with SSRF DNS rebinding protection. + /// Params: { "url": "https://example.com/hook", "timeout_secs": 10 } + async fn action_webhook(&self, action: &PlaybookAction, event: &ThreatDetectedEvent) -> Result { + let url_str = action + .params + .get("url") + .and_then(|v| v.as_str()) + .ok_or_else(|| SoarError::WebhookMissingParam("url"))?; + + let timeout_secs = action.params.get("timeout_secs").and_then(|v| v.as_u64()).unwrap_or(10); + + // Parse URL and extract host + let parsed_url = Url::parse(url_str).map_err(|e| SoarError::ActionFailed("webhook", e))?; + + let host = parsed_url.host_str().ok_or(SoarError::WebhookUrlNoHost)?; + + // DNS resolve all IPs and verify none are private/loopback/link-local + let port = parsed_url.port_or_known_default().unwrap_or(443); + let resolve_target = format!("{}:{}", host, port); + let addrs: Vec = lookup_host(&resolve_target) + .await + .map_err(|e| SoarError::ActionFailed(format!("webhook (DNS for {})", host), e))? + .collect(); + + if addrs.is_empty() { + Err(SoarError::WebhookDnsEmpty(host))?; + } + + for addr in &addrs { + if Self::is_private_ip(&addr.ip()) { + log!(SoarLog::EventHandlingFailed(format!( + "SSRF blocked: webhook URL '{}' resolved to private IP {}", + url_str, + addr.ip() + ))); + Err(SoarError::WebhookSsrfBlocked(host, addr.ip().to_string()))?; + } + } + + // Build and send the webhook payload (includes all enriched fields) + let sources_str: Vec = event.sources.iter().map(|s| s.to_string()).collect(); + let payload = serde_json::json!({ + "source_ip": event.source_ip, + "dest_ip": event.dest_ip, + "attack_type": event.attack_type, + "confidence": event.confidence, + "flow_count": event.flow_count, + "packet_rate": event.packet_rate, + "protocol": event.protocol, + "geoip_country": event.geoip_country, + "is_repeat_offender": event.is_repeat_offender, + "detection_sources": sources_str, + "timestamp": Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(), + }); + + // Pin resolved IPs to prevent DNS rebinding: the DNS check above verified + // all resolved addresses are public, so we force reqwest to use those same + // addresses instead of re-resolving (which could return a private IP on TTL expiry). + let mut client_builder = Client::builder().timeout(Duration::from_secs(timeout_secs)); + for addr in &addrs { + client_builder = client_builder.resolve(host, *addr); + } + let client = client_builder + .build() + .map_err(|e| SoarError::ActionFailed("webhook", e))?; + + let resp = client + .post(url_str) + .json(&payload) + .send() + .await + .map_err(|e| SoarError::ActionFailed("webhook", e))?; + + let status = resp.status(); + if status.is_success() { + Ok(format!("Webhook sent to {} (status {})", url_str, status)) + } else { + Err(SoarError::WebhookHttpStatus(status.as_u16()))? + } + } + + /// Log action. + fn action_log(&self, action: &PlaybookAction, event: &ThreatDetectedEvent) -> Result { + let level = action.params.get("level").and_then(|v| v.as_str()).unwrap_or("warn"); + + log!(SoarLog::ActionLog( + level.to_string(), + event.source_ip.clone(), + event.attack_type.clone(), + format!("{:.2}", event.confidence), + format!("{:.3}", event.ae_score), + format!("{:.3}", event.anomaly_score), + format!("{:.3}", event.c2_score), + )); + + Ok(format!("Logged at level '{}'", level)) + } + + /// Fallback execution when no playbook matches. + /// Only fires when source_ip is present. + pub(super) async fn execute_fallback(&self, event: &ThreatDetectedEvent) -> Result<(), Error> { + // Check admin whitelist — never block admin IPs even in fallback + if self.admin_whitelist.read().contains(&event.source_ip) { + log!(SoarLog::WhitelistSkipped( + event.source_ip.clone(), + "fallback".to_string() + )); + return Ok(()); + } + + // Check cooldown — use playbook_id=-1 for fallback actions + if self.is_cooldown_active(-1, &event.source_ip, 300) { + log!(SoarLog::CooldownActive("fallback".to_string(), event.source_ip.clone())); + return Ok(()); + } + + // Default fallback: block IP for 30 minutes + log + let fake_action = PlaybookAction { + action_order: 1, + action_type: "block_ip".to_string(), + params: serde_json::json!({"ttl_secs": 1800}), + }; + + let block_result = self.execute_action(&fake_action, event, -1).await; + let result_json = match &block_result { + Ok(msg) => serde_json::json!({"action": "block_ip", "status": "ok", "message": msg}), + Err(e) => serde_json::json!({"action": "block_ip", "status": "error", "message": e.to_string()}), + }; + + // Record cooldown for fallback + self.record_cooldown(-1, &event.source_ip); + + // Audit trail with playbook_id = -1 + self.db.insert_soar_execution( + -1, + Some(&event.source_ip), + &event.attack_type, + &serde_json::to_string(&[result_json]).unwrap_or_default(), + )?; + + log!(SoarLog::FallbackExecuted(event.source_ip.clone())); + Ok(()) + } +} diff --git a/net-guardia/src/core/soar/engine.rs b/net-guardia/src/core/soar/engine.rs index 64d9c3c..40435ec 100644 --- a/net-guardia/src/core/soar/engine.rs +++ b/net-guardia/src/core/soar/engine.rs @@ -1,30 +1,24 @@ use std::collections::HashSet; -use std::net::{IpAddr, SocketAddr}; use std::sync::Arc; use std::sync::atomic::{AtomicU8, AtomicU32, Ordering}; -use std::time::{Duration, Instant}; +use std::time::Instant; -use chrono::{Duration as ChronoDuration, NaiveDateTime, Utc}; +use chrono::{NaiveDateTime, Utc}; use dashmap::DashMap; use macros::log; use parking_lot::RwLock; -use reqwest::Client; use serde_json::Value; -use tokio::net::lookup_host; use tokio::sync::broadcast::error::RecvError; use tokio::sync::{Mutex as TokioMutex, broadcast}; -use tokio::task::spawn_blocking; -use url::Url; -use crate::core::ebpf::rate_limit::RateLimitConfig; -use crate::core::playbook_service::ip_version_from_str; use crate::core::soar::frequency::FrequencyTracker; use crate::infrastructure::communication_manager::CommunicationManager; use crate::infrastructure::geoip::GeoIpService; use crate::interface::port::access_control::AccessControlPort; -use crate::interface::port::notification::{AlertNotifier, AlertPayload}; +use crate::interface::port::app_repo::AppRepo; +use crate::interface::port::notification::AlertNotifier; +use crate::interface::port::rate_limit_api::RateLimitPort; use crate::interface::port::secret_store::SecretStorePort; -use crate::interface::port::soar::SoarPort; use crate::model::config::constants::MAX_PENDING_UNBLOCK_RETRIES; use crate::model::error::Error; use crate::model::error::soar::SoarError; @@ -37,40 +31,49 @@ use crate::model::soar::playbook::{Playbook, PlaybookAction}; type CooldownKey = (i64, String); /// SOAR Engine — subscribes to ThreatDetectedEvent and executes matching playbooks. +/// +/// The engine is intentionally split across three files within `core::soar`: +/// - `engine.rs` (this file) — struct definition, lifecycle (new/start/event_loop), +/// cache reload, recovery, rate-limit-TTL restoration +/// - `matcher.rs` — domain: playbook matching, condition evaluation, cooldowns +/// - `actions.rs` — application: action dispatch + all action_* implementations +/// +/// Fields are `pub(super)` so the sibling files can read them; external +/// callers still see the struct via its public methods only. pub struct SoarEngine { - db: Arc, - access_control: Arc, + pub(super) db: Arc, + pub(super) access_control: Arc, /// In-memory cache of playbooks (loaded at startup, refreshed on change). - playbooks: RwLock>, + pub(super) playbooks: RwLock>, /// In-memory cache of admin whitelist IPs. - admin_whitelist: RwLock>, + pub(super) admin_whitelist: RwLock>, /// Cooldown tracker: maps (playbook_id, source_ip) → last execution time. - cooldowns: DashMap, + pub(super) cooldowns: DashMap, /// Frequency tracker for frequency-based conditions. - frequency_tracker: FrequencyTracker, + pub(super) frequency_tracker: FrequencyTracker, /// AtomicU32 counter for active auto-blocks (avoids DB query per event). - active_block_count: AtomicU32, + pub(super) active_block_count: AtomicU32, /// Optional alert notifier (Telegram, etc.). - alert_notifier: Option>, + pub(super) alert_notifier: Option>, /// Optional GeoIP service for country lookups. - geoip: Option>, + pub(super) geoip: Option>, /// Optional rate limit config for adjust_rate_limit action. - rate_limit: Option>, + pub(super) rate_limit: Option>, /// Lock to serialize rate limit read-save-write sequences (Item 6: atomicity). - rate_limit_lock: TokioMutex<()>, + pub(super) rate_limit_lock: TokioMutex<()>, /// Cached enforce level: Monitor=0, MlOnly=1, Enforce=2. - enforce_level_cache: Arc, + pub(super) enforce_level_cache: Arc, /// Secret store for decrypting SMTP passwords etc. - secrets: Option>, + pub(super) secrets: Option>, } impl SoarEngine { pub fn new( - db: Arc, + db: Arc, access_control: Arc, alert_notifier: Option>, geoip: Option>, - rate_limit: Option>, + rate_limit: Option>, enforce_level_cache: Arc, secrets: Option>, ) -> Result { @@ -260,662 +263,6 @@ impl SoarEngine { Ok(()) } - /// Find playbooks matching the event via trigger_event + multi-condition AND logic. - fn find_matching_playbooks(&self, event: &ThreatDetectedEvent) -> Vec { - let playbooks = self.playbooks.read(); - playbooks - .iter() - .filter(|pb| pb.enabled && pb.trigger_event == event.attack_type) - .filter(|pb| self.evaluate_conditions(pb, event)) - .cloned() - .collect() - } - - /// Evaluate all conditions on a playbook (AND logic). - /// If no conditions are configured, the playbook matches unconditionally. - fn evaluate_conditions(&self, pb: &Playbook, event: &ThreatDetectedEvent) -> bool { - if pb.conditions.is_empty() { - return true; - } - - // Evaluate non-frequency conditions first (avoid recording non-matching events) - for cond in &pb.conditions { - if cond.condition_type == ConditionType::Frequency { - continue; - } - if !self.evaluate_single_condition(cond, pb, event) { - return false; - } - } - - // Evaluate frequency conditions last - for cond in &pb.conditions { - if cond.condition_type == ConditionType::Frequency && !self.evaluate_single_condition(cond, pb, event) { - return false; - } - } - - true - } - - /// Evaluate a single condition against the event. - /// The `operator` field controls comparison direction: - /// - Threshold: ">=" (default) or "<=" - /// - SourceCountry/IpPattern: "in" (default) or "not_in" - /// - RepeatOffender: "==" only - /// - Frequency: ">=" only - fn evaluate_single_condition(&self, cond: &PlaybookCondition, pb: &Playbook, event: &ThreatDetectedEvent) -> bool { - match cond.condition_type { - ConditionType::Threshold => { - let threshold = match cond.value.parse::() { - Ok(v) => v, - Err(_) => return false, - }; - let confidence = event.confidence as f64; - let met = if cond.operator == "<=" { - confidence <= threshold - } else { - confidence >= threshold - }; - if !met { - log!(SoarLog::ConditionNotMet( - "threshold".to_string(), - pb.name.clone(), - format!("{:.2}", event.confidence), - )); - } - met - } - ConditionType::SourceCountry => { - let countries: Vec<&str> = cond.value.split(',').map(|s| s.trim()).collect(); - let matches = event - .geoip_country - .as_ref() - .is_some_and(|c| countries.iter().any(|&cc| cc.eq_ignore_ascii_case(c))); - let met = if cond.operator == "not_in" { !matches } else { matches }; - if !met { - log!(SoarLog::ConditionNotMet( - "source_country".to_string(), - pb.name.clone(), - event.geoip_country.clone().unwrap_or_else(|| "none".to_string()), - )); - } - met - } - ConditionType::IpPattern => { - let net = match cond.value.parse::() { - Ok(n) => n, - Err(_) => return false, - }; - let ip = match event.source_ip.parse::() { - Ok(a) => a, - Err(_) => return false, - }; - let matches = net.contains(ip); - let met = if cond.operator == "not_in" { !matches } else { matches }; - if !met { - log!(SoarLog::ConditionNotMet( - "ip_pattern".to_string(), - pb.name.clone(), - event.source_ip.clone(), - )); - } - met - } - ConditionType::RepeatOffender => { - let expected = cond.value.eq_ignore_ascii_case("true"); - let met = event.is_repeat_offender == expected; - if !met { - log!(SoarLog::ConditionNotMet( - "repeat_offender".to_string(), - pb.name.clone(), - format!("{}", event.is_repeat_offender), - )); - } - met - } - ConditionType::Frequency => { - let required = match cond.value.parse::() { - Ok(v) => v, - Err(_) => return false, - }; - let window_secs = cond.value2.as_ref().and_then(|s| s.parse::().ok()).unwrap_or(60); - let count = self - .frequency_tracker - .record_and_count(pb.id, &event.source_ip, window_secs); - let met = count >= required; - if !met { - log!(SoarLog::FrequencyNotMet(pb.name.clone(), count, required, window_secs)); - } - met - } - } - } - - /// Check if cooldown is active for this playbook + source IP combination. - fn is_cooldown_active(&self, playbook_id: i64, source_ip: &str, cooldown_secs: i64) -> bool { - 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 { - return true; - } - } - false - } - - /// Record cooldown for a playbook + source IP combination. - fn record_cooldown(&self, playbook_id: i64, source_ip: &str) { - let key = (playbook_id, source_ip.to_string()); - self.cooldowns.insert(key, Instant::now()); - } - - /// Execute a single playbook against an event. - async fn execute_playbook(&self, playbook: &Playbook, event: &ThreatDetectedEvent) -> Result<(), Error> { - // Check cooldown - if self.is_cooldown_active(playbook.id, &event.source_ip, playbook.cooldown_secs) { - log!(SoarLog::CooldownActive(playbook.name.clone(), event.source_ip.clone())); - return Ok(()); - } - - // Check admin whitelist - if self.admin_whitelist.read().contains(&event.source_ip) { - log!(SoarLog::WhitelistSkipped( - event.source_ip.clone(), - playbook.name.clone() - )); - return Ok(()); - } - - // Execute actions in order - let mut action_results = Vec::new(); - for action in &playbook.actions { - let result = self.execute_action(action, event, playbook.id).await; - let result_json = match &result { - Ok(msg) => serde_json::json!({"action": &action.action_type, "status": "ok", "message": msg}), - Err(e) => { - serde_json::json!({"action": &action.action_type, "status": "error", "message": e.to_string()}) - } - }; - action_results.push(result_json); - if let Err(e) = result { - log!(SoarLog::PlaybookError( - playbook.name.clone(), - format!("Action '{}': {}", action.action_type, e) - )); - } - } - - // Record cooldown - self.record_cooldown(playbook.id, &event.source_ip); - - // Write audit trail - let actions_json = serde_json::to_string(&action_results).unwrap_or_default(); - self.db - .insert_soar_execution(playbook.id, Some(&event.source_ip), &event.attack_type, &actions_json)?; - - log!(SoarLog::PlaybookExecuted( - playbook.name.clone(), - event.source_ip.clone(), - event.attack_type.clone() - )); - - Ok(()) - } - - /// Check if the system is in enforce mode (as opposed to monitor mode). - /// Reads from the in-memory AtomicU8 cache: Monitor=0, MlOnly=1, Enforce=2. - fn is_enforce_mode(&self) -> bool { - self.enforce_level_cache.load(Ordering::Relaxed) == 2 - } - - /// Execute a single action. - async fn execute_action( - &self, - action: &PlaybookAction, - event: &ThreatDetectedEvent, - playbook_id: i64, - ) -> Result { - match action.action_type.as_str() { - "block_ip" => { - if !self.is_enforce_mode() { - log!(SoarLog::MonitorModeSkipped( - action.action_type.clone(), - event.source_ip.clone() - )); - return Ok(format!("[monitor] Would block IP {} — skipped", event.source_ip)); - } - self.action_block_ip(action, event, playbook_id).await - } - "adjust_rate_limit" => { - if !self.is_enforce_mode() { - log!(SoarLog::MonitorModeSkipped( - action.action_type.clone(), - event.source_ip.clone() - )); - return Ok("[monitor] Would adjust rate limit — skipped".to_string()); - } - self.action_adjust_rate_limit(action, event).await - } - "send_telegram" => self.action_send_telegram(event).await, - "send_email" => self.action_send_email(event).await, - "webhook" => self.action_webhook(action, event).await, - "log" => self.action_log(action, event), - other => Err(SoarError::UnknownActionType(other))?, - } - } - - /// Block an IP via eBPF ACL with TTL. - async fn action_block_ip( - &self, - action: &PlaybookAction, - event: &ThreatDetectedEvent, - playbook_id: i64, - ) -> Result { - let ttl_secs = action.params.get("ttl_secs").and_then(|v| v.as_u64()).unwrap_or(1800); - - // Validate TTL (runtime-configurable via DB) - let max_ttl: u64 = self - .db - .get_setting("soar_max_ttl_secs") - .ok() - .flatten() - .and_then(|v| v.parse().ok()) - .unwrap_or(86400); - if ttl_secs > max_ttl { - Err(SoarError::InvalidTtl(ttl_secs, max_ttl))?; - } - - // Atomically check cap and reserve a slot using CAS loop (runtime-configurable via DB) - let max_cap: u32 = self - .db - .get_setting("soar_max_auto_block_cap") - .ok() - .flatten() - .and_then(|v| v.parse().ok()) - .unwrap_or(100); - loop { - let current_count = self.active_block_count.load(Ordering::SeqCst); - if current_count >= max_cap { - log!(SoarLog::CapReached(current_count, max_cap, event.source_ip.clone())); - Err(SoarError::CapReached(max_cap))?; - } - if self - .active_block_count - .compare_exchange(current_count, current_count + 1, Ordering::SeqCst, Ordering::SeqCst) - .is_ok() - { - break; - } - } - - // Block IP via AccessControlPort (handles IPv4/IPv6 dispatch internally) - if let Err(e) = self.access_control.block_ip(&event.source_ip).await { - self.decrement_block_count(); - return Err(e); - } - - // Calculate expiry time - let expires_at = Utc::now() + ChronoDuration::seconds(ttl_secs as i64); - let expires_str = expires_at.format("%Y-%m-%d %H:%M:%S").to_string(); - - // Record in soar_block_rules - if let Err(e) = self - .db - .insert_soar_block_rule(&event.source_ip, playbook_id, &expires_str) - { - // Attempt to roll back the eBPF block — on failure, queue for retry - if let Err(unblock_err) = self.access_control.unblock_ip(&event.source_ip).await { - log!(SoarLog::EventHandlingFailed(format!( - "CRITICAL: Failed to unblock IP {} after DB error — queueing for retry: {}", - event.source_ip, unblock_err - ))); - // Write to pending_unblock table so recovery can retry later - if let Err(pend_err) = self.db.insert_pending_unblock(&event.source_ip) { - log!(SoarLog::EventHandlingFailed(format!( - "CRITICAL: Failed to queue pending unblock for IP {}: {}", - event.source_ip, pend_err - ))); - } - } - self.decrement_block_count(); - return Err(e); - } - - // Also persist to acl_rules for consistency - let ip_version = ip_version_from_str(&event.source_ip); - self.db - .insert_acl_rule(ip_version, "source", "blacklist", &event.source_ip, 0)?; - - Ok(format!("Blocked IP {} for {}s", event.source_ip, ttl_secs)) - } - - /// Temporarily reduce global rate limits by a factor with TTL-based restoration. - /// Params: { "factor": 0.5, "ttl_secs": 600 } - /// factor < 1.0 means stricter (e.g. 0.5 = half the current rate). - async fn action_adjust_rate_limit( - &self, - action: &PlaybookAction, - event: &ThreatDetectedEvent, - ) -> Result { - let rate_limit = self.rate_limit.as_ref().ok_or(SoarError::RateLimitUnavailable)?; - - let factor = action.params.get("factor").and_then(|v| v.as_f64()).unwrap_or(0.5); - let ttl_secs = action.params.get("ttl_secs").and_then(|v| v.as_u64()).unwrap_or(600); - - if !(0.01..=1.0).contains(&factor) { - Err(SoarError::InvalidRateLimitFactor(factor))?; - } - - let max_ttl: u64 = self - .db - .get_setting("soar_max_ttl_secs") - .ok() - .flatten() - .and_then(|v| v.parse().ok()) - .unwrap_or(86400); - if ttl_secs > max_ttl { - Err(SoarError::InvalidTtl(ttl_secs, max_ttl))?; - } - - // Acquire lock to serialize rate limit read-save-write (Item 6: atomicity) - let _guard = self.rate_limit_lock.lock().await; - - // Read current rates, save originals, apply reduced rates - 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); - - // Store original rates for restoration (only if not already adjusted) - let key = "soar_rate_limit_original"; - if self.db.get_setting(key)?.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, - }); - self.db.set_setting(key, &original.to_string())?; - } - - // Store TTL for restoration - let expires_at = Utc::now() + ChronoDuration::seconds(ttl_secs as i64); - self.db.set_setting( - "soar_rate_limit_expires", - &expires_at.format("%Y-%m-%d %H:%M:%S").to_string(), - )?; - - // Apply reduced rates - 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, - event.source_ip.clone(), - event.attack_type.clone(), - 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, event.source_ip - )) - } - - /// Send Telegram notification. - async fn action_send_telegram(&self, event: &ThreatDetectedEvent) -> Result { - if let Some(notifier) = &self.alert_notifier { - let country = if let Some(geoip) = &self.geoip { - if let Ok(ip_addr) = event.source_ip.parse::() { - match geoip.lookup(ip_addr).await { - Ok(Some(loc)) => loc.country, - _ => None, - } - } else { - None - } - } else { - None - }; - let repeat_tag = if event.is_repeat_offender { " [REPEAT]" } else { "" }; - let payload = AlertPayload { - source_ip: event.source_ip.clone(), - dest_ip: event.dest_ip.clone(), - country, - threat_type: event.attack_type.clone(), - confidence: event.confidence, - action_description: format!( - "SOAR auto-response triggered (hits: {}{})", - event.flow_count, repeat_tag, - ), - timestamp: Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(), - }; - notifier.send_alert(&payload).await?; - Ok("Telegram notification sent".to_string()) - } else { - log!(SoarLog::TelegramNotConfigured); - Ok("Telegram not configured, skipped".to_string()) - } - } - - /// Send email alert. - async fn action_send_email(&self, event: &ThreatDetectedEvent) -> Result { - // Build SmtpClient from settings stored via SoarPort::get_setting - use crate::core::email::scheduler::SmtpClient; - match SmtpClient::from_soar_port(&*self.db, self.secrets.as_deref())? { - Some(smtp) => { - let subject = format!( - "[NetGuardia] Threat Alert: {} from {}", - event.attack_type, event.source_ip - ); - let body = format!( - "

Threat Detected

\ -

Source IP: {}

\ -

Threat Type: {}

\ -

Confidence: {:.1}%

\ -

Time: {}

", - event.source_ip, - event.attack_type, - event.confidence * 100.0, - Utc::now().format("%Y-%m-%d %H:%M:%S UTC"), - ); - if let Some(recipient) = self.db.get_setting("smtp_recipient")? { - spawn_blocking(move || smtp.send(&recipient, &subject, &body)) - .await - .map_err(|e| SoarError::ActionFailed("send_email", e))??; - Ok("Email alert sent".to_string()) - } else { - Ok("No SMTP recipient configured, skipped".to_string()) - } - } - None => Ok("SMTP not configured, skipped".to_string()), - } - } - - /// Send a webhook HTTP POST with SSRF DNS rebinding protection. - /// Params: { "url": "https://example.com/hook", "timeout_secs": 10 } - async fn action_webhook(&self, action: &PlaybookAction, event: &ThreatDetectedEvent) -> Result { - let url_str = action - .params - .get("url") - .and_then(|v| v.as_str()) - .ok_or_else(|| SoarError::WebhookMissingParam("url"))?; - - let timeout_secs = action.params.get("timeout_secs").and_then(|v| v.as_u64()).unwrap_or(10); - - // Parse URL and extract host - let parsed_url = Url::parse(url_str).map_err(|e| SoarError::ActionFailed("webhook", e))?; - - let host = parsed_url.host_str().ok_or(SoarError::WebhookUrlNoHost)?; - - // DNS resolve all IPs and verify none are private/loopback/link-local - let port = parsed_url.port_or_known_default().unwrap_or(443); - let resolve_target = format!("{}:{}", host, port); - let addrs: Vec = lookup_host(&resolve_target) - .await - .map_err(|e| SoarError::ActionFailed(format!("webhook (DNS for {})", host), e))? - .collect(); - - if addrs.is_empty() { - Err(SoarError::WebhookDnsEmpty(host))?; - } - - for addr in &addrs { - if Self::is_private_ip(&addr.ip()) { - log!(SoarLog::EventHandlingFailed(format!( - "SSRF blocked: webhook URL '{}' resolved to private IP {}", - url_str, - addr.ip() - ))); - Err(SoarError::WebhookSsrfBlocked(host, addr.ip().to_string()))?; - } - } - - // Build and send the webhook payload (includes all enriched fields) - let sources_str: Vec = event.sources.iter().map(|s| s.to_string()).collect(); - let payload = serde_json::json!({ - "source_ip": event.source_ip, - "dest_ip": event.dest_ip, - "attack_type": event.attack_type, - "confidence": event.confidence, - "flow_count": event.flow_count, - "packet_rate": event.packet_rate, - "protocol": event.protocol, - "geoip_country": event.geoip_country, - "is_repeat_offender": event.is_repeat_offender, - "detection_sources": sources_str, - "timestamp": Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(), - }); - - // Pin resolved IPs to prevent DNS rebinding: the DNS check above verified - // all resolved addresses are public, so we force reqwest to use those same - // addresses instead of re-resolving (which could return a private IP on TTL expiry). - let mut client_builder = Client::builder().timeout(Duration::from_secs(timeout_secs)); - for addr in &addrs { - client_builder = client_builder.resolve(host, *addr); - } - let client = client_builder - .build() - .map_err(|e| SoarError::ActionFailed("webhook", e))?; - - let resp = client - .post(url_str) - .json(&payload) - .send() - .await - .map_err(|e| SoarError::ActionFailed("webhook", e))?; - - let status = resp.status(); - if status.is_success() { - Ok(format!("Webhook sent to {} (status {})", url_str, status)) - } else { - Err(SoarError::WebhookHttpStatus(status.as_u16()))? - } - } - - /// Check if an IP address is private/loopback/link-local (SSRF protection). - fn is_private_ip(ip: &IpAddr) -> bool { - match ip { - IpAddr::V4(v4) => { - v4.is_loopback() // 127.0.0.0/8 - || v4.is_private() // 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 - || v4.is_link_local() // 169.254.0.0/16 - || v4.is_unspecified() // 0.0.0.0 - || v4.is_broadcast() // 255.255.255.255 - } - IpAddr::V6(v6) => { - v6.is_loopback() // ::1 - || v6.is_unspecified() // :: - // fe80::/10 (link-local) - || (v6.segments()[0] & 0xffc0) == 0xfe80 - // fc00::/7 (unique local: fc00::/8 + fd00::/8) - || (v6.segments()[0] & 0xfe00) == 0xfc00 - } - } - } - - /// Log action. - fn action_log(&self, action: &PlaybookAction, event: &ThreatDetectedEvent) -> Result { - let level = action.params.get("level").and_then(|v| v.as_str()).unwrap_or("warn"); - - log!(SoarLog::ActionLog( - level.to_string(), - event.source_ip.clone(), - event.attack_type.clone(), - format!("{:.2}", event.confidence), - format!("{:.3}", event.ae_score), - format!("{:.3}", event.anomaly_score), - format!("{:.3}", event.c2_score), - )); - - Ok(format!("Logged at level '{}'", level)) - } - - /// Fallback execution when no playbook matches. - /// Only fires when source_ip is present. - async fn execute_fallback(&self, event: &ThreatDetectedEvent) -> Result<(), Error> { - // Check admin whitelist — never block admin IPs even in fallback - if self.admin_whitelist.read().contains(&event.source_ip) { - log!(SoarLog::WhitelistSkipped( - event.source_ip.clone(), - "fallback".to_string() - )); - return Ok(()); - } - - // Check cooldown — use playbook_id=-1 for fallback actions - if self.is_cooldown_active(-1, &event.source_ip, 300) { - log!(SoarLog::CooldownActive("fallback".to_string(), event.source_ip.clone())); - return Ok(()); - } - - // Default fallback: block IP for 30 minutes + log - let fake_action = PlaybookAction { - action_order: 1, - action_type: "block_ip".to_string(), - params: serde_json::json!({"ttl_secs": 1800}), - }; - - let block_result = self.execute_action(&fake_action, event, -1).await; - let result_json = match &block_result { - Ok(msg) => serde_json::json!({"action": "block_ip", "status": "ok", "message": msg}), - Err(e) => serde_json::json!({"action": "block_ip", "status": "error", "message": e.to_string()}), - }; - - // Record cooldown for fallback - self.record_cooldown(-1, &event.source_ip); - - // Audit trail with playbook_id = -1 - self.db.insert_soar_execution( - -1, - Some(&event.source_ip), - &event.attack_type, - &serde_json::to_string(&[result_json]).unwrap_or_default(), - )?; - - log!(SoarLog::FallbackExecuted(event.source_ip.clone())); - Ok(()) - } - /// Recover active block rules on startup by re-applying to eBPF. pub async fn recover_active_blocks(&self) -> Result<(), Error> { // First, retry any pending unblocks from previous orphan failures @@ -1059,46 +406,6 @@ impl SoarEngine { Ok(()) } - - /// Remove expired cooldown entries to prevent unbounded growth. - /// Called by TTL scheduler every 60 seconds. - pub fn cleanup_expired_cooldowns(&self) { - let max_cooldown_secs = { - let playbooks = self.playbooks.read(); - playbooks.iter().map(|p| p.cooldown_secs as u64).max().unwrap_or(3600) - }; - let expiry = Duration::from_secs(max_cooldown_secs.saturating_mul(2).max(3600)); - let before = self.cooldowns.len(); - self.cooldowns.retain(|_, instant| instant.elapsed() < expiry); - let removed = before.saturating_sub(self.cooldowns.len()); - if removed > 0 { - log!(SoarLog::CooldownCleanup(removed as u32)); - } - - // Also clean up empty frequency tracker entries - let freq_removed = self.frequency_tracker.cleanup(); - if freq_removed > 0 { - log!(SoarLog::FrequencyCleanup(freq_removed)); - } - } - - /// Decrement the active block counter (called by TTL scheduler on unblock). - /// Uses CAS loop to avoid underflow race condition. - pub fn decrement_block_count(&self) { - loop { - let current = self.active_block_count.load(Ordering::SeqCst); - if current == 0 { - return; // Nothing to decrement - } - match self - .active_block_count - .compare_exchange(current, current - 1, Ordering::SeqCst, Ordering::SeqCst) - { - Ok(_) => return, - Err(_) => continue, // Retry on contention - } - } - } } #[cfg(test)] @@ -1106,6 +413,7 @@ mod tests { use super::*; use crate::model::error::ebpf::EbpfError; use crate::model::event::DetectionSource; + use chrono::Duration as ChronoDuration; use parking_lot::Mutex; use std::sync::atomic::AtomicBool; @@ -1145,9 +453,9 @@ mod tests { } } - fn test_db() -> Arc { + fn test_db() -> Arc { use crate::adapter::persistence::Database; - Arc::new(Database::new(":memory:").expect("Failed to create test database")) as Arc + Arc::new(Database::new(":memory:").expect("Failed to create test database")) as Arc } fn test_engine(ac: Arc) -> SoarEngine { diff --git a/net-guardia/src/core/soar/matcher.rs b/net-guardia/src/core/soar/matcher.rs new file mode 100644 index 0000000..61185d1 --- /dev/null +++ b/net-guardia/src/core/soar/matcher.rs @@ -0,0 +1,236 @@ +//! SOAR domain: playbook matching, condition evaluation, cooldown tracking. +//! +//! Pure domain logic — no external I/O, no DB writes, no network calls. +//! All methods live in an `impl SoarEngine` block so they can access the +//! engine's in-memory caches (`playbooks`, `cooldowns`, `frequency_tracker`), +//! but none of them touch anything outside those fields. + +use std::net::IpAddr; +use std::sync::atomic::Ordering; +use std::time::{Duration, Instant}; + +use macros::log; + +use crate::core::soar::engine::SoarEngine; +use crate::model::event::ThreatDetectedEvent; +use crate::model::log::soar::SoarLog; +use crate::model::soar::condition::{ConditionType, PlaybookCondition}; +use crate::model::soar::playbook::Playbook; + +impl SoarEngine { + /// Find playbooks matching the event via trigger_event + multi-condition AND logic. + pub(super) fn find_matching_playbooks(&self, event: &ThreatDetectedEvent) -> Vec { + let playbooks = self.playbooks.read(); + playbooks + .iter() + .filter(|pb| pb.enabled && pb.trigger_event == event.attack_type) + .filter(|pb| self.evaluate_conditions(pb, event)) + .cloned() + .collect() + } + + /// Evaluate all conditions on a playbook (AND logic). + /// If no conditions are configured, the playbook matches unconditionally. + pub(super) fn evaluate_conditions(&self, pb: &Playbook, event: &ThreatDetectedEvent) -> bool { + if pb.conditions.is_empty() { + return true; + } + + // Evaluate non-frequency conditions first (avoid recording non-matching events) + for cond in &pb.conditions { + if cond.condition_type == ConditionType::Frequency { + continue; + } + if !self.evaluate_single_condition(cond, pb, event) { + return false; + } + } + + // Evaluate frequency conditions last + for cond in &pb.conditions { + if cond.condition_type == ConditionType::Frequency && !self.evaluate_single_condition(cond, pb, event) { + return false; + } + } + + true + } + + /// Evaluate a single condition against the event. + /// The `operator` field controls comparison direction: + /// - Threshold: ">=" (default) or "<=" + /// - SourceCountry/IpPattern: "in" (default) or "not_in" + /// - RepeatOffender: "==" only + /// - Frequency: ">=" only + pub(super) fn evaluate_single_condition( + &self, + cond: &PlaybookCondition, + pb: &Playbook, + event: &ThreatDetectedEvent, + ) -> bool { + match cond.condition_type { + ConditionType::Threshold => { + let threshold = match cond.value.parse::() { + Ok(v) => v, + Err(_) => return false, + }; + let confidence = event.confidence as f64; + let met = if cond.operator == "<=" { + confidence <= threshold + } else { + confidence >= threshold + }; + if !met { + log!(SoarLog::ConditionNotMet( + "threshold".to_string(), + pb.name.clone(), + format!("{:.2}", event.confidence), + )); + } + met + } + ConditionType::SourceCountry => { + let countries: Vec<&str> = cond.value.split(',').map(|s| s.trim()).collect(); + let matches = event + .geoip_country + .as_ref() + .is_some_and(|c| countries.iter().any(|&cc| cc.eq_ignore_ascii_case(c))); + let met = if cond.operator == "not_in" { !matches } else { matches }; + if !met { + log!(SoarLog::ConditionNotMet( + "source_country".to_string(), + pb.name.clone(), + event.geoip_country.clone().unwrap_or_else(|| "none".to_string()), + )); + } + met + } + ConditionType::IpPattern => { + let net = match cond.value.parse::() { + Ok(n) => n, + Err(_) => return false, + }; + let ip = match event.source_ip.parse::() { + Ok(a) => a, + Err(_) => return false, + }; + let matches = net.contains(ip); + let met = if cond.operator == "not_in" { !matches } else { matches }; + if !met { + log!(SoarLog::ConditionNotMet( + "ip_pattern".to_string(), + pb.name.clone(), + event.source_ip.clone(), + )); + } + met + } + ConditionType::RepeatOffender => { + let expected = cond.value.eq_ignore_ascii_case("true"); + let met = event.is_repeat_offender == expected; + if !met { + log!(SoarLog::ConditionNotMet( + "repeat_offender".to_string(), + pb.name.clone(), + format!("{}", event.is_repeat_offender), + )); + } + met + } + ConditionType::Frequency => { + let required = match cond.value.parse::() { + Ok(v) => v, + Err(_) => return false, + }; + let window_secs = cond.value2.as_ref().and_then(|s| s.parse::().ok()).unwrap_or(60); + let count = self + .frequency_tracker + .record_and_count(pb.id, &event.source_ip, window_secs); + let met = count >= required; + if !met { + log!(SoarLog::FrequencyNotMet(pb.name.clone(), count, required, window_secs)); + } + met + } + } + } + + /// Check if cooldown is active for this playbook + source IP combination. + pub(super) fn is_cooldown_active(&self, playbook_id: i64, source_ip: &str, cooldown_secs: i64) -> bool { + 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 { + return true; + } + } + false + } + + /// Record cooldown for a playbook + source IP combination. + pub(super) fn record_cooldown(&self, playbook_id: i64, source_ip: &str) { + let key = (playbook_id, source_ip.to_string()); + self.cooldowns.insert(key, Instant::now()); + } + + /// Remove expired cooldown entries to prevent unbounded growth. + /// Called by TTL scheduler every 60 seconds. + pub fn cleanup_expired_cooldowns(&self) { + let max_cooldown_secs = { + let playbooks = self.playbooks.read(); + playbooks.iter().map(|p| p.cooldown_secs as u64).max().unwrap_or(3600) + }; + let expiry = Duration::from_secs(max_cooldown_secs.saturating_mul(2).max(3600)); + let before = self.cooldowns.len(); + self.cooldowns.retain(|_, instant| instant.elapsed() < expiry); + let removed = before.saturating_sub(self.cooldowns.len()); + if removed > 0 { + log!(SoarLog::CooldownCleanup(removed as u32)); + } + + // Also clean up empty frequency tracker entries + let freq_removed = self.frequency_tracker.cleanup(); + if freq_removed > 0 { + log!(SoarLog::FrequencyCleanup(freq_removed)); + } + } + + /// Decrement the active block counter (called by TTL scheduler on unblock). + /// Uses CAS loop to avoid underflow race condition. + pub fn decrement_block_count(&self) { + loop { + let current = self.active_block_count.load(Ordering::SeqCst); + if current == 0 { + return; // Nothing to decrement + } + match self + .active_block_count + .compare_exchange(current, current - 1, Ordering::SeqCst, Ordering::SeqCst) + { + Ok(_) => return, + Err(_) => continue, // Retry on contention + } + } + } + + /// Check if an IP address is private/loopback/link-local (SSRF protection). + pub(super) fn is_private_ip(ip: &IpAddr) -> bool { + match ip { + IpAddr::V4(v4) => { + v4.is_loopback() // 127.0.0.0/8 + || v4.is_private() // 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 + || v4.is_link_local() // 169.254.0.0/16 + || v4.is_unspecified() // 0.0.0.0 + || v4.is_broadcast() // 255.255.255.255 + } + IpAddr::V6(v6) => { + v6.is_loopback() // ::1 + || v6.is_unspecified() // :: + // fe80::/10 (link-local) + || (v6.segments()[0] & 0xffc0) == 0xfe80 + // fc00::/7 (unique local: fc00::/8 + fd00::/8) + || (v6.segments()[0] & 0xfe00) == 0xfc00 + } + } + } +} diff --git a/net-guardia/src/core/soar/mod.rs b/net-guardia/src/core/soar/mod.rs index f010e26..5fdc96f 100644 --- a/net-guardia/src/core/soar/mod.rs +++ b/net-guardia/src/core/soar/mod.rs @@ -1,3 +1,5 @@ +pub mod actions; pub mod engine; pub mod frequency; +pub mod matcher; pub mod scheduler; diff --git a/net-guardia/src/core/soar/scheduler.rs b/net-guardia/src/core/soar/scheduler.rs index 77f3682..ef45348 100644 --- a/net-guardia/src/core/soar/scheduler.rs +++ b/net-guardia/src/core/soar/scheduler.rs @@ -7,25 +7,20 @@ use tokio::time::{self, Duration}; use crate::core::playbook_service::ip_version_from_str; use crate::core::soar::engine::SoarEngine; use crate::interface::port::access_control::AccessControlPort; -use crate::interface::port::soar::SoarPort; +use crate::interface::port::app_repo::AppRepo; use crate::model::error::Error; -use crate::model::error::soar::SoarError; use crate::model::log::soar::SoarLog; /// TTL expiry scheduler: runs every 60 seconds, removes expired auto-block rules. /// Before removing from eBPF, checks if a manual ACL rule exists for the same IP. pub struct TtlScheduler { - db: Arc, + db: Arc, access_control: Arc, soar_engine: Arc, } impl TtlScheduler { - pub fn new( - db: Arc, - access_control: Arc, - soar_engine: Arc, - ) -> Self { + pub fn new(db: Arc, access_control: Arc, soar_engine: Arc) -> Self { Self { db, access_control, @@ -94,14 +89,10 @@ impl TtlScheduler { )); } - // Also remove from acl_rules DB table (the auto-added entry) + // Atomically drop acl_rules entry AND mark soar_block_rules unblocked + // in one transaction (R2 mitigation, tx-2 per M2_CARVE_PLAN §4b). let ip_version = ip_version_from_str(source_ip); - if let Err(e) = self.db.delete_acl_rule(ip_version, "source", "blacklist", source_ip, 0) { - log!(SoarError::AclCleanupFailed(e)); - } - - // Mark as unblocked - self.db.mark_soar_block_unblocked(*id)?; + self.db.commit_soar_unblock_to_db(*id, ip_version, source_ip)?; self.soar_engine.decrement_block_count(); removed += 1; } diff --git a/net-guardia/src/core/stats_aggregator.rs b/net-guardia/src/core/stats_aggregator.rs index 0241a2a..5cd0b05 100644 --- a/net-guardia/src/core/stats_aggregator.rs +++ b/net-guardia/src/core/stats_aggregator.rs @@ -5,20 +5,20 @@ use serde_json::Value; use tokio::task::JoinHandle; use tokio::time::{self, Duration}; -use crate::interface::port::repository::RepositoryPort; -use crate::interface::port::stats::StatsPort; +use crate::interface::port::setting::SettingRepo; +use crate::interface::port::stats::StatsRepo; use crate::model::error::Error; use crate::model::log::system::SystemLog; /// Background service that periodically aggregates statistics from SOAR/ML tables /// and writes them to the settings table for the Report engine to consume. pub struct StatsAggregator { - stats: Arc, - repo: Arc, + stats: Arc, + repo: Arc, } impl StatsAggregator { - pub fn new(stats: Arc, repo: Arc) -> Self { + pub fn new(stats: Arc, repo: Arc) -> Self { Self { stats, repo } } @@ -163,7 +163,7 @@ mod tests { db.insert_soar_execution(1, Some("5.6.7.8"), "brute_force", "[]").ok(); db.insert_soar_block_rule("1.2.3.4", 1, "2099-01-01 00:00:00").ok(); - let aggregator = StatsAggregator::new(db.clone() as Arc, db.clone() as Arc); + let aggregator = StatsAggregator::new(db.clone() as Arc, db.clone() as Arc); aggregator.aggregate().expect("aggregation should succeed"); // Verify settings were written @@ -192,7 +192,7 @@ mod tests { #[test] fn aggregator_handles_empty_db() { let db = Arc::new(Database::new(":memory:").expect("test db")); - let aggregator = StatsAggregator::new(db.clone() as Arc, db.clone() as Arc); + let aggregator = StatsAggregator::new(db.clone() as Arc, db.clone() as Arc); aggregator .aggregate() .expect("aggregation should succeed with empty data"); diff --git a/net-guardia/src/infrastructure/audit_logger.rs b/net-guardia/src/infrastructure/audit_logger.rs index 6749686..bee253e 100644 --- a/net-guardia/src/infrastructure/audit_logger.rs +++ b/net-guardia/src/infrastructure/audit_logger.rs @@ -4,18 +4,18 @@ use macros::log; use tokio::sync::broadcast::error::RecvError; use crate::infrastructure::communication_manager::CommunicationManager; -use crate::interface::port::audit::AuditPort; +use crate::interface::port::audit::AuditRepo; use crate::model::event::{AuditEvent, DriftDetectedEvent}; use crate::model::log::audit::AuditLog; /// Subscribes to `AuditEvent` and persists each entry to the `audit_log` table. /// Falls back to log-only when DB writes fail (never panics). pub struct AuditLogger { - db: Arc, + db: Arc, } impl AuditLogger { - pub fn new(db: Arc) -> Self { + pub fn new(db: Arc) -> Self { Self { db } } diff --git a/net-guardia/src/infrastructure/communication_manager.rs b/net-guardia/src/infrastructure/communication_manager.rs index 3bfc1ec..cb9ae05 100644 --- a/net-guardia/src/infrastructure/communication_manager.rs +++ b/net-guardia/src/infrastructure/communication_manager.rs @@ -1,3 +1,11 @@ +//! 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 crate::interface::communication::command::*; use crate::interface::communication::event::Event; use crate::interface::communication::event::EventBroadcaster; diff --git a/net-guardia/src/infrastructure/enforce_mode_handler.rs b/net-guardia/src/infrastructure/enforce_mode_handler.rs index 5935bec..69f26e0 100644 --- a/net-guardia/src/infrastructure/enforce_mode_handler.rs +++ b/net-guardia/src/infrastructure/enforce_mode_handler.rs @@ -9,7 +9,7 @@ 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::repository::RepositoryPort; +use crate::interface::port::app_repo::AppRepo; use crate::model::error::Error; use crate::model::event::AuditEvent; use crate::model::log::system::SystemLog; @@ -25,14 +25,14 @@ 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, + db: Arc, comm: Arc, /// Shared AtomicU8 cache: Monitor=0, MlOnly=1, Enforce=2. enforce_cache: Arc, } impl EnforceModeHandler { - pub fn new(db: Arc, comm: Arc, enforce_cache: Arc) -> Self { + pub fn new(db: Arc, comm: Arc, enforce_cache: Arc) -> Self { Self { db, comm, @@ -82,7 +82,7 @@ mod tests { use crate::interface::communication::query_types::GetEnforceModeQuery; fn test_handler() -> (Arc, Arc) { - let db = Arc::new(Database::new(":memory:").unwrap()) as Arc; + let db = Arc::new(Database::new(":memory:").unwrap()) as Arc; let cache = Arc::new(AtomicU8::new(0)); let comm = Arc::new(CommunicationManager::new()); comm.register_event_type::(); diff --git a/net-guardia/src/infrastructure/http_server.rs b/net-guardia/src/infrastructure/http_server.rs index 8a3601e..9ac262c 100644 --- a/net-guardia/src/infrastructure/http_server.rs +++ b/net-guardia/src/infrastructure/http_server.rs @@ -8,6 +8,7 @@ use actix_web::web::route; use actix_web::{App, HttpResponse, HttpServer, web}; use macros::log; +use crate::adapter::ebpf::EbpfServices; use crate::adapter::http::{ acl, api_keys, audit as audit_api, auth, default, filter, health as health_api, logs as logs_api, ml, notification as notification_api, rate_limit as rate_limit_api, report as report_api, setup as setup_api, soar, @@ -22,18 +23,17 @@ use crate::core::auth::middleware::AuthMiddleware; use crate::core::auth::setup_guard::{SetupCompleteFlag, SetupGuard}; use crate::core::config_service::ConfigService; use crate::core::dns_filter_service::DnsFilterService; -use crate::core::ebpf::EbpfServices; use crate::core::notification_service::NotificationService; use crate::core::playbook_service::PlaybookService; use crate::core::rate_limit_service::RateLimitService; -use crate::core::system::ShutdownHandle; use crate::infrastructure::app_config::AppConfig; use crate::infrastructure::app_services::AppServices; use crate::infrastructure::communication_manager::CommunicationManager; use crate::infrastructure::secret_store::SecretStore; use crate::infrastructure::suricata_manager::SuricataManager; -use crate::interface::port::api_key::ApiKeyPort; -use crate::interface::port::repository::RepositoryPort; +use crate::infrastructure::system::ShutdownHandle; +use crate::interface::port::api_key::ApiKeyRepo; +use crate::interface::port::app_repo::AppRepo; use crate::model::config::constants::HTTP_FALLBACK_PORT; use crate::model::error::Error; use crate::model::error::http::HttpError; @@ -152,8 +152,8 @@ pub fn start_setup_server( let make_app = move || { App::new() .wrap(cors(vec![])) - .app_data(web::Data::from(db.clone() as Arc)) - .app_data(web::Data::from(db.clone() as Arc)) + .app_data(web::Data::from(db.clone() as Arc)) + .app_data(web::Data::from(db.clone() as Arc)) .app_data(web::Data::from(db.clone())) .app_data(web::Data::from(secret_store.clone())) .app_data(web::Data::from(jwt_service.clone())) @@ -248,8 +248,8 @@ pub async fn run(params: HttpServerParams) -> Result<(), Error> { .app_data(web::Data::from(ml_engine.clone())) .app_data(web::Data::from(flow_statistics.clone())) .app_data(web::Data::from(drop_monitor.clone())) - .app_data(web::Data::from(db.clone() as Arc)) - .app_data(web::Data::from(db.clone() as Arc)) + .app_data(web::Data::from(db.clone() as Arc)) + .app_data(web::Data::from(db.clone() as Arc)) .app_data(web::Data::from(db.clone())) .app_data(web::Data::from(secret_store.clone())) .app_data(web::Data::from(jwt_service.clone())) diff --git a/net-guardia/src/infrastructure/mod.rs b/net-guardia/src/infrastructure/mod.rs index ecc1a96..99f0033 100644 --- a/net-guardia/src/infrastructure/mod.rs +++ b/net-guardia/src/infrastructure/mod.rs @@ -12,3 +12,4 @@ pub mod service_factory; pub mod statistics; pub mod suricata_manager; pub mod suricata_monitor; +pub mod system; diff --git a/net-guardia/src/infrastructure/service_factory.rs b/net-guardia/src/infrastructure/service_factory.rs index 4969e93..19922cf 100644 --- a/net-guardia/src/infrastructure/service_factory.rs +++ b/net-guardia/src/infrastructure/service_factory.rs @@ -16,12 +16,12 @@ use common::define::pipeline::*; use crate::core::auth::jwt::JwtService; use crate::adapter::access_control_adapter::EbpfAccessControlAdapter; +use crate::adapter::ebpf::EbpfServices; use crate::adapter::persistence::Database; use crate::adapter::telegram::TelegramAdapter; use crate::core::acl_service::AclService; use crate::core::config_service::ConfigService; use crate::core::dns_filter_service::DnsFilterService; -use crate::core::ebpf::EbpfServices; use crate::core::email::scheduler::ReportScheduler; use crate::core::ml::drift_detector::DriftDetector; use crate::core::ml::manifest::ModelManifest; @@ -41,10 +41,11 @@ 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::notification::{AlertNotifier, NotificationConfigPort}; -use crate::interface::port::repository::RepositoryPort; +use crate::interface::port::app_repo::AppRepo; +use crate::interface::port::notification::AlertNotifier; use crate::interface::port::secret_store::SecretStorePort; -use crate::interface::port::soar::SoarPort; +use crate::interface::port::setting::SettingRepo; +use crate::interface::port::soar::SoarRepo; use crate::model::access_control::list_type::ListType; use crate::model::detection::drift::FeatureBaselines; use crate::model::error::Error; @@ -202,7 +203,7 @@ impl ServiceFactory { // Create CommunicationManager and register enforce-mode handler let comm = Arc::new(CommunicationManager::new()); let enforce_handler = Arc::new(EnforceModeHandler::new( - db.clone() as Arc, + db.clone() as Arc, comm.clone(), enforce_level_cache.clone(), )); @@ -219,7 +220,7 @@ impl ServiceFactory { comm.register_event_type::(); // Seed default SOAR playbooks if empty - (db.as_ref() as &dyn SoarPort).seed_default_playbooks()?; + (db.as_ref() as &dyn SoarRepo).seed_default_playbooks()?; // Restore persisted state from database Self::restore_dns_blacklist(&db, &ebpf_services); @@ -229,8 +230,8 @@ impl ServiceFactory { // Create TelegramAdapter as alert notifier (may fail if not configured yet) let alert_notifier: Option> = match TelegramAdapter::new( - db.clone() as Arc, - db.clone() as Arc, + db.clone() as Arc, + db.clone() as Arc, Some(secret_store_port.clone()), ) { Ok(adapter) => Some(Arc::new(adapter)), @@ -257,12 +258,14 @@ impl ServiceFactory { Arc::new(EbpfAccessControlAdapter::new(ebpf_services.access_control.clone())); // Create SOAR engine + let rate_limit_port: Arc = + ebpf_services.rate_limit.clone(); let soar_engine = Arc::new(SoarEngine::new( db.clone(), access_control_port.clone(), alert_notifier.clone(), geoip.clone(), - Some(ebpf_services.rate_limit.clone()), + Some(rate_limit_port.clone()), enforce_level_cache, Some(secret_store_port.clone()), )?); @@ -272,33 +275,33 @@ impl ServiceFactory { // Create Report scheduler let report_scheduler = - ReportScheduler::new(db.clone() as Arc, Some(secret_store_port.clone())); + ReportScheduler::new(db.clone() as Arc, Some(secret_store_port.clone())); - // Create domain services (Phase 2B) + // Create domain services (Phase 2B) — upcast concrete eBPF services to + // their port-layer traits so the core services see only abstract ports. + let access_control_admin: Arc = + ebpf_services.access_control.clone(); + let geo_block_port: Arc = + ebpf_services.geo_block.clone(); + let dns_filter_port: Arc = + ebpf_services.dns_filter.clone(); let acl_service = Arc::new(AclService::new( - db.clone() as Arc, - ebpf_services.access_control.clone(), - ebpf_services.geo_block.clone(), - )); - let dns_filter_service = Arc::new(DnsFilterService::new( - db.clone() as Arc, - ebpf_services.dns_filter.clone(), - )); - let rate_limit_service = Arc::new(RateLimitService::new( - db.clone() as Arc, - ebpf_services.rate_limit.clone(), + db.clone() as Arc, + access_control_admin, + geo_block_port, )); + let dns_filter_service = Arc::new(DnsFilterService::new(db.clone() as Arc, dns_filter_port)); + let rate_limit_service = Arc::new(RateLimitService::new(db.clone() as Arc, rate_limit_port)); let playbook_service = Arc::new(PlaybookService::new( db.clone(), soar_engine.clone(), access_control_port, )); - let config_service = Arc::new( - ConfigService::new(db.clone() as Arc).with_secret_store(secret_store_port.clone()), - ); + let config_service = + Arc::new(ConfigService::new(db.clone() as Arc).with_secret_store(secret_store_port.clone())); let notification_service = Arc::new(NotificationService::new( - db.clone() as Arc, - db.clone() as Arc, + db.clone() as Arc, + db.clone() as Arc, secret_store_port, )); diff --git a/net-guardia/src/core/system.rs b/net-guardia/src/infrastructure/system.rs similarity index 97% rename from net-guardia/src/core/system.rs rename to net-guardia/src/infrastructure/system.rs index 0d50908..595a91d 100644 --- a/net-guardia/src/core/system.rs +++ b/net-guardia/src/infrastructure/system.rs @@ -13,6 +13,7 @@ use tokio::sync::mpsc::{self, Sender}; use tokio::sync::oneshot; use tokio::time::{interval, sleep}; +use crate::adapter::ebpf::EbpfServices; use crate::adapter::persistence::Database; use crate::core::acl_service::AclService; use crate::core::auth::jwt::JwtService; @@ -21,7 +22,6 @@ use crate::core::correlation::engine::CorrelationEngine; use crate::core::detection::beaconing::BeaconingDetector; use crate::core::detection::orchestrator::DetectionOrchestrator; use crate::core::dns_filter_service::DnsFilterService; -use crate::core::ebpf::EbpfServices; use crate::core::email::scheduler::ReportScheduler; use crate::core::ml::drift_detector::DriftDetector; use crate::core::ml::model_watcher::ModelWatcher; @@ -41,9 +41,9 @@ use crate::infrastructure::secret_store::SecretStore; use crate::infrastructure::service_factory::ServiceFactory; use crate::infrastructure::suricata_manager::SuricataManager; use crate::infrastructure::suricata_monitor::SuricataMonitor; -use crate::interface::port::audit::AuditPort; -use crate::interface::port::repository::RepositoryPort; -use crate::interface::port::stats::StatsPort; +use crate::interface::port::audit::AuditRepo; +use crate::interface::port::setting::SettingRepo; +use crate::interface::port::stats::StatsRepo; use crate::model::detection::ml_detection::AlertMessage; use crate::model::error::Error; use crate::model::error::system::SystemError; @@ -195,7 +195,9 @@ impl System { // When maps exist but bind fails (e.g. igb on kernel < 6.17), record // the classified reason and continue — the ML engine will see no // packets, same as a network that is simply quiet. - if let Err(e) = ebpf_services.run(app_services.ml_engine.clone()).await { + let sink_factory: Arc = + app_services.ml_engine.clone(); + if let Err(e) = ebpf_services.run(sink_factory).await { use crate::infrastructure::ebpf_preflight; use crate::model::system::health::EbpfFailStage; let iface = self.app_config.network.ingress_ifname.as_str(); @@ -220,13 +222,13 @@ impl System { } // Start audit logger (subscribe to AuditEvent + DriftDetectedEvent, persist to DB) - let audit_logger = Arc::new(AuditLogger::new(self.db.clone() as Arc)); + let audit_logger = Arc::new(AuditLogger::new(self.db.clone() as Arc)); audit_logger.start(&self.comm); // Start stats aggregator (writes weekly_* settings for Report engine) let stats_aggregator = StatsAggregator::new( - self.db.clone() as Arc, - self.db.clone() as Arc, + self.db.clone() as Arc, + self.db.clone() as Arc, ); stats_aggregator.start(); diff --git a/net-guardia/src/interface/port/access_control_admin.rs b/net-guardia/src/interface/port/access_control_admin.rs new file mode 100644 index 0000000..6d6cfba --- /dev/null +++ b/net-guardia/src/interface/port/access_control_admin.rs @@ -0,0 +1,50 @@ +use std::collections::HashMap; +use std::net::{Ipv4Addr, Ipv6Addr, SocketAddrV4, SocketAddrV6}; + +use async_trait::async_trait; +use common::model::ip_address::Port; + +use crate::model::access_control::list_type::ListType; +use crate::model::error::Error; +use crate::model::monitoring::direction::FlowDirection; + +/// Admin-level ACL port — add/remove individual IPv4/IPv6 ACL list entries. +/// +/// Distinct from `AccessControlPort` (which only exposes `block_ip` / +/// `unblock_ip` for SOAR). `AclService` uses this richer API to serve the +/// `/api/acl` HTTP routes. +#[async_trait] +#[allow(dead_code)] +pub trait AccessControlAdminPort: Send + Sync { + async fn add_ipv4_list( + &self, + direction: FlowDirection, + list_type: ListType, + address: SocketAddrV4, + ) -> Result<(), Error>; + + async fn add_ipv6_list( + &self, + direction: FlowDirection, + list_type: ListType, + address: SocketAddrV6, + ) -> Result<(), Error>; + + async fn remove_ipv4_list( + &self, + direction: FlowDirection, + list_type: ListType, + address: SocketAddrV4, + ) -> Result<(), Error>; + + async fn remove_ipv6_list( + &self, + direction: FlowDirection, + list_type: ListType, + address: SocketAddrV6, + ) -> Result<(), Error>; + + async fn get_ipv4_list(&self, direction: FlowDirection, list_type: ListType) -> HashMap>; + + async fn get_ipv6_list(&self, direction: FlowDirection, list_type: ListType) -> HashMap>; +} diff --git a/net-guardia/src/interface/port/acl.rs b/net-guardia/src/interface/port/acl.rs new file mode 100644 index 0000000..b0aefd9 --- /dev/null +++ b/net-guardia/src/interface/port/acl.rs @@ -0,0 +1,41 @@ +use crate::model::error::Error; + +/// Type alias for ACL rule tuples: (ip_version, direction, list_type, ip_address, port) +pub type AclRuleTuple = (u8, String, String, String, u16); + +/// Data Plane BC — ACL aggregate repository. +/// +/// Owns ACL rules (user-managed block/allow lists) and the admin whitelist that +/// SOAR must not block. Kept disjoint from `EnforcementRepo` (rate-limit / DNS / +/// geo) so policy tables can evolve independently of packet-matching tables. +#[allow(dead_code)] +pub trait AclRepo: Send + Sync { + fn insert_acl_rule( + &self, + ip_version: u8, + direction: &str, + list_type: &str, + ip_address: &str, + port: u16, + ) -> Result<(), Error>; + + fn delete_acl_rule( + &self, + ip_version: u8, + direction: &str, + list_type: &str, + ip_address: &str, + port: u16, + ) -> Result<(), Error>; + + fn load_acl_rules(&self) -> Result, Error>; + + /// Returns true if a manual (non-SOAR) ACL rule exists for this IP. + /// Used by the TTL scheduler to avoid removing an eBPF block that the user + /// explicitly installed. + fn has_manual_acl_rule(&self, ip_address: &str) -> Result; + + fn load_admin_whitelist(&self) -> Result, Error>; + fn insert_admin_whitelist(&self, ip: &str) -> Result<(), Error>; + fn delete_admin_whitelist(&self, ip: &str) -> Result<(), Error>; +} diff --git a/net-guardia/src/interface/port/api_key.rs b/net-guardia/src/interface/port/api_key.rs index 9c5caa9..0cdb492 100644 --- a/net-guardia/src/interface/port/api_key.rs +++ b/net-guardia/src/interface/port/api_key.rs @@ -5,8 +5,9 @@ use crate::model::identity::auth::Claims; #[allow(clippy::type_complexity)] pub type ApiKeyListItem = (i64, String, String, String, Option); -/// Port for API key management and validation. -pub trait ApiKeyPort: Send + Sync { +/// Identity BC — API key CRUD + validation (distinct from user login, +/// used by MCP / programmatic clients). +pub trait ApiKeyRepo: Send + Sync { fn validate_api_key(&self, api_key: &str) -> Result, Error>; fn hmac_api_key(&self, raw_key: &str) -> String; fn insert_api_key(&self, key_hash: &str, name: &str, permission_level: &str) -> Result; diff --git a/net-guardia/src/interface/port/app_repo.rs b/net-guardia/src/interface/port/app_repo.rs new file mode 100644 index 0000000..4cd1fd7 --- /dev/null +++ b/net-guardia/src/interface/port/app_repo.rs @@ -0,0 +1,49 @@ +use super::acl::AclRepo; +use super::api_key::ApiKeyRepo; +use super::audit::AuditRepo; +use super::db_admin::DbAdminRepo; +use super::enforcement::EnforcementRepo; +use super::identity::IdentityRepo; +use super::setting::SettingRepo; +use super::soar::SoarRepo; +use super::stats::StatsRepo; + +/// Composition-root supertrait bundling every aggregate Repo trait + +/// `DbAdminRepo`. +/// +/// Services that operate on a single aggregate should take the +/// aggregate-specific trait (`Arc`, `Arc`, …) so +/// their dependency surface matches their responsibility. `AppRepo` exists +/// for composition wiring and for legacy call sites that span many +/// aggregates; it is an implementation convenience, not an aggregate +/// definition. +pub trait AppRepo: + AclRepo + + ApiKeyRepo + + AuditRepo + + DbAdminRepo + + EnforcementRepo + + IdentityRepo + + SettingRepo + + SoarRepo + + StatsRepo + + Send + + Sync +{ +} + +impl AppRepo for T where + T: AclRepo + + ApiKeyRepo + + AuditRepo + + DbAdminRepo + + EnforcementRepo + + IdentityRepo + + SettingRepo + + SoarRepo + + StatsRepo + + Send + + Sync + + ?Sized +{ +} diff --git a/net-guardia/src/interface/port/audit.rs b/net-guardia/src/interface/port/audit.rs index 727c3e5..350c15a 100644 --- a/net-guardia/src/interface/port/audit.rs +++ b/net-guardia/src/interface/port/audit.rs @@ -1,6 +1,30 @@ use crate::model::error::Error; -/// Port for audit trail persistence. -pub trait AuditPort: Send + Sync { - fn insert_audit_log(&self, actor: &str, action: &str, detail: &str) -> Result<(), Error>; +/// Audit log entry returned by `list_audit_logs` and +/// `verify_audit_log_chain` APIs. +#[derive(Debug, Clone)] +pub struct AuditLogEntry { + pub id: i64, + pub actor: String, + pub action: String, + pub detail: String, + pub created_at: String, +} + +/// Audit BC (supporting) — append-only WORM hash-chained audit log. +/// +/// The append-only constraint is enforced by SQLite triggers +/// (`audit_log_no_update` / `audit_log_no_delete`), not by this trait. +#[allow(dead_code)] +pub trait AuditRepo: Send + Sync { + /// Append a new audit entry. `detail` is typically a JSON blob. + fn insert_audit_log(&self, actor: &str, action: &str, detail: &str) -> Result<(), Error>; + + /// Read all audit entries ordered by id ASC. + fn list_audit_logs(&self) -> Result, Error>; + + /// Walk the full chain and verify every `row_hash` matches + /// `H(ts || actor || action || detail || prev_hash)`. Returns the number + /// of entries verified. Errors on the first broken link. + fn verify_audit_log_chain(&self) -> Result; } diff --git a/net-guardia/src/interface/port/db_admin.rs b/net-guardia/src/interface/port/db_admin.rs new file mode 100644 index 0000000..08bc738 --- /dev/null +++ b/net-guardia/src/interface/port/db_admin.rs @@ -0,0 +1,40 @@ +use crate::model::error::Error; + +/// Persistence technical service — cross-aggregate atomic business operations +/// and database-wide administration (migration, encryption, backup). +/// +/// The atomic operations exposed here each span multiple aggregates in one +/// SQLite transaction. Rather than expose a generic `with_transaction` +/// primitive (which cannot be used through a trait object because Rust +/// forbids generic methods on dyn traits), each cross-aggregate use case +/// gets a dedicated business method. +/// +/// The five tx points identified during M2 design (see +/// `docs/strategy/debate/v12-architecture-review/M2_CARVE_PLAN.md` §4b): +/// +/// | # | Use case | Method | +/// |---|---|---| +/// | tx-1 | SOAR block commit (soar_block_rules + acl_rules) | `commit_soar_block_to_db` | +/// | tx-2 | TTL unblock (acl_rules delete + soar row mark) | `commit_soar_unblock_to_db` | +/// | tx-3 | Manual unblock (same as tx-2) | `commit_soar_unblock_to_db` | +/// | tx-4 | Playbook create + conditions + actions | `insert_playbook_atomic` on SoarRepo | +/// | tx-5 | Playbook update + replace conditions/actions | `update_playbook_atomic` on SoarRepo | +pub trait DbAdminRepo: Send + Sync { + /// tx-1 — Atomically record a SOAR-driven IP block to both + /// `soar_block_rules` and `acl_rules`. Returns the new + /// `soar_block_rules.id`. Callers are responsible for eBPF rollback if + /// this fails. + fn commit_soar_block_to_db( + &self, + source_ip: &str, + ip_version: u8, + playbook_id: i64, + expires_at: &str, + ) -> Result; + + /// tx-2 / tx-3 — Atomically clear a SOAR-driven IP block: removes the + /// corresponding `acl_rules` row (if present) and marks the + /// `soar_block_rules` row as unblocked. Callers handle eBPF unblock + /// separately. + fn commit_soar_unblock_to_db(&self, soar_block_id: i64, ip_version: u8, source_ip: &str) -> Result<(), Error>; +} diff --git a/net-guardia/src/interface/port/dns_filter_api.rs b/net-guardia/src/interface/port/dns_filter_api.rs new file mode 100644 index 0000000..3834e45 --- /dev/null +++ b/net-guardia/src/interface/port/dns_filter_api.rs @@ -0,0 +1,12 @@ +use crate::model::error::Error; + +/// Admin-level DNS filter port — add/remove/list domains on the blacklist. +/// Used by `DnsFilterService` (HTTP-driven CRUD). Kept separate from +/// `DnsQueryFilter` (which is the fast-path check) to reflect their distinct +/// call sites and latency profiles. +#[allow(dead_code)] +pub trait DnsFilterPort: Send + Sync { + fn add_domain(&self, domain: &str) -> Result<(), Error>; + fn remove_domain(&self, domain: &str) -> Result<(), Error>; + fn list_domains(&self) -> Vec; +} diff --git a/net-guardia/src/interface/port/dns_query_filter.rs b/net-guardia/src/interface/port/dns_query_filter.rs new file mode 100644 index 0000000..e18a4e3 --- /dev/null +++ b/net-guardia/src/interface/port/dns_query_filter.rs @@ -0,0 +1,12 @@ +/// Data-plane DNS query filter — checks raw UDP-payload bytes against a +/// blacklist. Used by `XskManager` on the fast path to drop malicious DNS +/// queries before they reach the forwarding stage. +/// +/// Keeping this port byte-oriented (instead of exposing parsed wire names) +/// means the implementation owns the parse + lookup together, which matters +/// for hot-path performance. +pub trait DnsQueryFilter: Send + Sync { + /// Returns `true` when `raw` is a DNS query whose QNAME is on the + /// blacklist. Returns `false` for non-DNS traffic and for clean DNS. + fn is_query_blacklisted(&self, raw: &[u8]) -> bool; +} diff --git a/net-guardia/src/interface/port/enforcement.rs b/net-guardia/src/interface/port/enforcement.rs new file mode 100644 index 0000000..a77743a --- /dev/null +++ b/net-guardia/src/interface/port/enforcement.rs @@ -0,0 +1,24 @@ +use crate::model::error::Error; + +/// Data Plane BC — rate-limit, DNS blacklist, geo-block aggregate repository. +/// +/// These tables back three distinct eBPF map populations but share the +/// lifecycle of "data-plane policy that is not per-IP ACL". Kept disjoint from +/// `AclRepo` so the per-packet matching rules evolve independently from the +/// aggregate policy knobs. +#[allow(dead_code)] +pub trait EnforcementRepo: Send + Sync { + // --- Rate Limit --- + fn set_rate_limit(&self, key: &str, value: u64) -> Result<(), Error>; + fn load_rate_limit_config(&self) -> Result, Error>; + + // --- DNS --- + fn insert_dns_domain(&self, domain: &str) -> Result<(), Error>; + fn delete_dns_domain(&self, domain: &str) -> Result<(), Error>; + fn load_dns_domains(&self) -> Result, Error>; + + // --- Geo --- + fn insert_geo_country(&self, code: &str) -> Result<(), Error>; + fn delete_geo_country(&self, code: &str) -> Result<(), Error>; + fn load_geo_countries(&self) -> Result, Error>; +} diff --git a/net-guardia/src/interface/port/geo_block_api.rs b/net-guardia/src/interface/port/geo_block_api.rs new file mode 100644 index 0000000..600ca39 --- /dev/null +++ b/net-guardia/src/interface/port/geo_block_api.rs @@ -0,0 +1,17 @@ +use crate::model::error::Error; + +/// Data-plane geo-block admin port — block / unblock / list country codes. +/// Used by `AclService` for the `/api/acl/geo` HTTP routes. +#[allow(dead_code)] +pub trait GeoBlockPort: Send + Sync { + /// Add every ISO-3166-1 alpha-2 code in `codes` to the block set. + /// Returns the number of /24 ranges actually added (existing codes + /// count as zero). + fn block_countries(&self, codes: &[String]) -> Result; + + /// Remove every code in `codes` from the block set. Returns the number + /// of /24 ranges actually removed. + fn unblock_countries(&self, codes: &[String]) -> Result; + + fn list_blocked(&self) -> Vec; +} diff --git a/net-guardia/src/interface/port/repository.rs b/net-guardia/src/interface/port/identity.rs similarity index 62% rename from net-guardia/src/interface/port/repository.rs rename to net-guardia/src/interface/port/identity.rs index 6c5efe2..1f2dbf7 100644 --- a/net-guardia/src/interface/port/repository.rs +++ b/net-guardia/src/interface/port/identity.rs @@ -1,12 +1,10 @@ use crate::model::error::Error; -/// Type alias for ACL rule tuples: (ip_version, direction, list_type, ip_address, port) -pub type AclRuleTuple = (u8, String, String, String, u16); - /// Type alias for user record tuples: (id, username, password_hash, role, force_password_change) pub type UserTuple = (i64, String, String, String, bool); /// Type alias for user list items: (id, username, role, force_password_change, created_at) +#[allow(dead_code)] pub type UserListItem = (i64, String, String, bool, String); /// Type alias for user-with-groups: (id, username, role, force_password_change, created_at, groups: Vec<(group_id, group_name)>) @@ -15,51 +13,16 @@ pub type UserWithGroups = (i64, String, String, bool, String, Vec<(i64, String)> /// Type alias for user group tuples: (id, name, description, permissions, created_at) pub type UserGroupTuple = (i64, String, String, String, String); -/// Port for persistent storage operations. -/// Adapters: SQLite (current), could be Postgres, etc. -/// All methods are used via the concrete Database adapter; the trait -/// defines the hexagonal-architecture boundary. +/// Identity BC (generic) — users, groups, membership, and login rate-limit counter. +/// +/// Kept as one aggregate because user lifecycle, group membership, permission +/// resolution and login-attempt counters all share the `users` table lifecycle +/// and are enforced together at login time. #[allow(dead_code)] -pub trait RepositoryPort: Send + Sync { - // --- ACL --- - fn insert_acl_rule( - &self, - ip_version: u8, - direction: &str, - list_type: &str, - ip_address: &str, - port: u16, - ) -> Result<(), Error>; - fn delete_acl_rule( - &self, - ip_version: u8, - direction: &str, - list_type: &str, - ip_address: &str, - port: u16, - ) -> Result<(), Error>; - fn load_acl_rules(&self) -> Result, Error>; - - // --- Rate Limit --- - fn set_rate_limit(&self, key: &str, value: u64) -> Result<(), Error>; - fn load_rate_limit_config(&self) -> Result, Error>; - - // --- DNS --- - fn insert_dns_domain(&self, domain: &str) -> Result<(), Error>; - fn delete_dns_domain(&self, domain: &str) -> Result<(), Error>; - fn load_dns_domains(&self) -> Result, Error>; - - // --- Geo --- - fn insert_geo_country(&self, code: &str) -> Result<(), Error>; - fn delete_geo_country(&self, code: &str) -> Result<(), Error>; - fn load_geo_countries(&self) -> Result, Error>; - - // --- Settings --- - fn get_setting(&self, key: &str) -> Result, Error>; - fn set_setting(&self, key: &str, value: &str) -> Result<(), Error>; - +pub trait IdentityRepo: Send + Sync { // --- Users --- fn find_user(&self, username: &str) -> Result, Error>; + fn find_user_by_id(&self, user_id: i64) -> Result, Error>; fn insert_user( &self, username: &str, @@ -69,14 +32,11 @@ pub trait RepositoryPort: Send + Sync { ) -> Result; fn update_user_password(&self, user_id: i64, password_hash: &str) -> Result<(), Error>; fn user_count(&self) -> Result; - - // --- User Management --- fn list_users(&self) -> Result, Error>; fn list_users_with_groups(&self) -> Result, Error>; fn delete_user(&self, user_id: i64) -> Result; fn update_user_role(&self, user_id: i64, role: &str) -> Result<(), Error>; fn reset_user_password(&self, user_id: i64, password_hash: &str) -> Result<(), Error>; - fn find_user_by_id(&self, user_id: i64) -> Result, Error>; // --- User Groups --- fn list_user_groups(&self) -> Result, Error>; @@ -85,7 +45,7 @@ pub trait RepositoryPort: Send + Sync { fn delete_user_group(&self, id: i64) -> Result; fn get_user_group(&self, id: i64) -> Result, Error>; - // --- User Group Membership --- + // --- Membership --- fn get_user_groups(&self, user_id: i64) -> Result, Error>; fn set_user_groups(&self, user_id: i64, group_ids: &[i64]) -> Result<(), Error>; fn get_user_permissions(&self, user_id: i64) -> Result, Error>; diff --git a/net-guardia/src/interface/port/mod.rs b/net-guardia/src/interface/port/mod.rs index e6b104e..9730899 100644 --- a/net-guardia/src/interface/port/mod.rs +++ b/net-guardia/src/interface/port/mod.rs @@ -1,8 +1,19 @@ pub mod access_control; +pub mod access_control_admin; +pub mod acl; pub mod api_key; +pub mod app_repo; pub mod audit; +pub mod db_admin; +pub mod dns_filter_api; +pub mod dns_query_filter; +pub mod enforcement; +pub mod geo_block_api; +pub mod identity; pub mod notification; -pub mod repository; +pub mod packet_sink; +pub mod rate_limit_api; pub mod secret_store; +pub mod setting; pub mod soar; pub mod stats; diff --git a/net-guardia/src/interface/port/notification.rs b/net-guardia/src/interface/port/notification.rs index 3ac2f46..47c58bc 100644 --- a/net-guardia/src/interface/port/notification.rs +++ b/net-guardia/src/interface/port/notification.rs @@ -20,9 +20,3 @@ pub trait AlertNotifier: Send + Sync { async fn send_alert(&self, payload: &AlertPayload) -> Result<(), Error>; async fn send_test_message(&self) -> Result<(), Error>; } - -/// Port for notification channel configuration (Telegram, email, etc.). -pub trait NotificationConfigPort: Send + Sync { - fn get_notification_config(&self, channel: &str) -> Result, Error>; - fn set_notification_config(&self, channel: &str, config_json: &str) -> Result<(), Error>; -} diff --git a/net-guardia/src/interface/port/packet_sink.rs b/net-guardia/src/interface/port/packet_sink.rs new file mode 100644 index 0000000..d1c34ce --- /dev/null +++ b/net-guardia/src/interface/port/packet_sink.rs @@ -0,0 +1,23 @@ +use std::sync::Arc; + +use crate::model::monitoring::user_packet::UserPacket; + +/// Data-plane packet sink — receives parsed packets from the AF_XDP RX path. +/// +/// Implementations wrap a `FlowTracker` (or other per-queue state) and forward +/// each packet into the ML inference pipeline. `XskManager` sees only this +/// trait, never `core::ml`, so the dependency direction stays +/// `adapter/ebpf → interface/port`. +pub trait PacketSink: Send + Sync { + /// Process one parsed packet. The boolean says whether the packet arrived + /// on the ingress interface (`true`) or the egress interface (`false`). + fn process_packet(&self, packet: UserPacket, is_ingress: bool); +} + +/// Factory that hands out a per-queue `PacketSink` for each AF_XDP queue the +/// manager spins up. `XskManager` calls this once per queue during bring-up. +pub trait PacketSinkFactory: Send + Sync { + /// Return a sink bound to `queue_id`, or `None` to skip per-packet + /// tracking on that queue. + fn sink_for_queue(&self, queue_id: u32) -> Option>; +} diff --git a/net-guardia/src/interface/port/rate_limit_api.rs b/net-guardia/src/interface/port/rate_limit_api.rs new file mode 100644 index 0000000..fb8007a --- /dev/null +++ b/net-guardia/src/interface/port/rate_limit_api.rs @@ -0,0 +1,21 @@ +use crate::model::error::Error; + +/// Data-plane rate-limit config port. +/// +/// Split into five per-protocol knobs to match the underlying eBPF per-class +/// counters. `RateLimitService` (HTTP CRUD) and `SoarEngine` (the +/// adjust-rate-limit action) both depend on this port. +#[allow(dead_code)] +pub trait RateLimitPort: Send + Sync { + fn set_packet_rate(&self, rate: u64) -> Result<(), Error>; + fn set_syn_rate(&self, rate: u64) -> Result<(), Error>; + fn set_udp_rate(&self, rate: u64) -> Result<(), Error>; + fn set_dns_rate(&self, rate: u64) -> Result<(), Error>; + fn set_window_ns(&self, ns: u64) -> Result<(), Error>; + + fn get_packet_rate(&self) -> Result; + fn get_syn_rate(&self) -> Result; + fn get_udp_rate(&self) -> Result; + fn get_dns_rate(&self) -> Result; + fn get_window_ns(&self) -> Result; +} diff --git a/net-guardia/src/interface/port/secret_store.rs b/net-guardia/src/interface/port/secret_store.rs index d289050..156b796 100644 --- a/net-guardia/src/interface/port/secret_store.rs +++ b/net-guardia/src/interface/port/secret_store.rs @@ -1,5 +1,9 @@ use crate::model::error::Error; +/// Port for plaintext access to sensitive values (e.g. SMTP password, JWT +/// secret). The adapter (`infrastructure/secret_store.rs`) wraps +/// `SettingRepo::get_app_secret` / `set_app_secret` with AES-256-GCM +/// envelope encryption, so callers of this port never see ciphertext. pub trait SecretStorePort: Send + Sync { fn get_secret(&self, key: &str) -> Result, Error>; fn set_secret(&self, key: &str, plaintext: &str) -> Result<(), Error>; diff --git a/net-guardia/src/interface/port/setting.rs b/net-guardia/src/interface/port/setting.rs new file mode 100644 index 0000000..56e6b0d --- /dev/null +++ b/net-guardia/src/interface/port/setting.rs @@ -0,0 +1,23 @@ +use crate::model::error::Error; + +/// Configuration technical service — key/value settings, encrypted app secrets, +/// and per-channel notification config blobs. +/// +/// Per DOMAIN_MAP §2 this is a Technical Service (no BC), but it has an +/// aggregate-shaped DB footprint (three tables: `settings`, `app_secrets`, +/// `notification_config`) with identical K/V semantics, so it gets a single +/// repo trait rather than three. +#[allow(dead_code)] +pub trait SettingRepo: Send + Sync { + // --- Plain settings (cleartext K/V) --- + fn get_setting(&self, key: &str) -> Result, Error>; + fn set_setting(&self, key: &str, value: &str) -> Result<(), Error>; + + // --- App secrets (encrypted-at-rest in `app_secrets` table) --- + fn get_app_secret(&self, key: &str) -> Result, Error>; + fn set_app_secret(&self, key: &str, plaintext: &str) -> Result<(), Error>; + + // --- Notification channel config blobs (JSON) --- + fn get_notification_config(&self, channel: &str) -> Result, Error>; + fn set_notification_config(&self, channel: &str, config_json: &str) -> Result<(), Error>; +} diff --git a/net-guardia/src/interface/port/soar.rs b/net-guardia/src/interface/port/soar.rs index 0d04dad..63265ff 100644 --- a/net-guardia/src/interface/port/soar.rs +++ b/net-guardia/src/interface/port/soar.rs @@ -24,31 +24,15 @@ pub type PlaybookRow = ( #[allow(clippy::type_complexity)] pub type SoarExecutionRow = (i64, i64, Option, String, String, String); -/// Port for SOAR-related persistence: playbooks, block rules, execution log, admin whitelist, -/// plus the settings and ACL methods that SOAR actions depend on. -pub trait SoarPort: Send + Sync { - // --- Settings (used by rate-limit adjust/restore and email actions) --- - fn get_setting(&self, key: &str) -> Result, Error>; - fn set_setting(&self, key: &str, value: &str) -> Result<(), Error>; - - // --- ACL Rules (used by block_ip action and TTL scheduler cleanup) --- - fn insert_acl_rule( - &self, - ip_version: u8, - direction: &str, - list_type: &str, - ip_address: &str, - port: u16, - ) -> Result<(), Error>; - fn delete_acl_rule( - &self, - ip_version: u8, - direction: &str, - list_type: &str, - ip_address: &str, - port: u16, - ) -> Result<(), Error>; - +/// Threat Response BC — SOAR aggregate repository. +/// +/// Covers playbook CRUD, condition CRUD, block-rule lifecycle, pending-unblock +/// recovery queue, and the execution log. The settings (`soar_*`) and ACL +/// writes that block actions depend on live in `SettingRepo` and `AclRepo` +/// respectively; cross-aggregate atomicity is handled via +/// `DbAdminRepo::with_transaction` + `TxRepos`. +#[allow(dead_code)] +pub trait SoarRepo: Send + Sync { // --- Playbooks --- fn insert_playbook( &self, @@ -94,7 +78,6 @@ pub trait SoarPort: Send + Sync { fn get_soar_block_by_id(&self, id: i64) -> Result, Error>; fn get_expired_soar_blocks(&self) -> Result, Error>; fn mark_soar_block_unblocked(&self, id: i64) -> Result<(), Error>; - fn has_manual_acl_rule(&self, ip_address: &str) -> Result; // --- Pending Unblock Recovery --- fn insert_pending_unblock(&self, source_ip: &str) -> Result; @@ -112,8 +95,35 @@ pub trait SoarPort: Send + Sync { ) -> Result; fn list_soar_executions(&self, limit: i64) -> Result, Error>; - // --- Admin Whitelist --- - fn load_admin_whitelist(&self) -> Result, Error>; - fn insert_admin_whitelist(&self, ip: &str) -> Result<(), Error>; - fn delete_admin_whitelist(&self, ip: &str) -> Result<(), Error>; + // --- Intra-aggregate atomic operations (tx-4 / tx-5 per M2_CARVE_PLAN §4b) --- + + /// tx-4 — Atomically create a playbook with its conditions and actions. + /// All rows (playbook + conditions + actions) commit together; any error + /// rolls back the whole insert. Returns the new playbook id. + /// + /// `actions` tuples: `(action_order, action_type, params_json)`. + /// `conditions` tuples: `(condition_type, operator, value, value2)`. + #[allow(clippy::too_many_arguments)] + fn insert_playbook_atomic( + &self, + name: &str, + trigger_event: &str, + threshold: Option, + count: Option, + window: Option, + cooldown: i64, + actions: &[(i64, String, String)], + conditions: &[(String, String, String, Option)], + ) -> Result; + + /// tx-5 — Atomically update a playbook's metadata and replace its + /// conditions and actions. Returns `Ok(false)` if no playbook with that + /// id exists; otherwise `Ok(true)` after the whole update commits. + fn update_playbook_atomic( + &self, + id: i64, + row: &UpdatePlaybookRow, + actions: &[(i64, String, String)], + conditions: &[(String, String, String, Option)], + ) -> Result; } diff --git a/net-guardia/src/interface/port/stats.rs b/net-guardia/src/interface/port/stats.rs index df56c15..d939e24 100644 --- a/net-guardia/src/interface/port/stats.rs +++ b/net-guardia/src/interface/port/stats.rs @@ -1,7 +1,8 @@ use crate::model::error::Error; -/// Port for statistics aggregation queries. -pub trait StatsPort: Send + Sync { +/// Reporting BC (generic) — weekly aggregation queries used by the report +/// scheduler and dashboard APIs. +pub trait StatsRepo: Send + Sync { fn count_weekly_executions(&self, days: i64) -> Result; fn count_weekly_blocks(&self, days: i64) -> Result; fn count_weekly_unblocks(&self, days: i64) -> Result; diff --git a/net-guardia/src/main.rs b/net-guardia/src/main.rs index 603565a..fbdcc97 100644 --- a/net-guardia/src/main.rs +++ b/net-guardia/src/main.rs @@ -20,9 +20,9 @@ use tokio::{signal, time}; use crate::adapter::persistence::Database; use crate::core::auth::jwt::JwtService; use crate::core::auth::password; -use crate::core::system::{ShutdownMode, System}; use crate::infrastructure::http_server; use crate::infrastructure::secret_store::SecretStore; +use crate::infrastructure::system::{ShutdownMode, System}; use crate::interface::port::secret_store::SecretStorePort; use crate::model::error::Error; use crate::model::error::system::SystemError; diff --git a/net-guardia/src/model/report/data.rs b/net-guardia/src/model/report/data.rs index cd92cc2..5336155 100644 --- a/net-guardia/src/model/report/data.rs +++ b/net-guardia/src/model/report/data.rs @@ -1,7 +1,7 @@ use chrono::{Duration as ChronoDuration, Local}; use serde::{Deserialize, Serialize}; -use crate::interface::port::repository::RepositoryPort; +use crate::interface::port::setting::SettingRepo; use crate::model::error::Error; /// Shared report data structure used by both HTML email and PDF report. @@ -63,7 +63,7 @@ pub struct SystemHealthSummary { impl ReportData { /// Build report data from database settings (aggregated by the ML pipeline). - pub fn from_database(db: &dyn RepositoryPort) -> Result { + pub fn from_database(db: &dyn SettingRepo) -> Result { let now = Local::now(); let period = format!( "{} — {}",