refactor(v12): SYNTHESIS M1–M4 — ports, SOAR split, eBPF inversion, system relocation

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) <noreply@anthropic.com>
This commit is contained in:
DaLaw2 2026-04-17 23:00:34 +08:00
parent c4ac70fbe1
commit 231fb88efd
66 changed files with 1833 additions and 1135 deletions

View File

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

View File

@ -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<Ipv4Addr, Vec<Port>> {
self.get_ipv4_list(direction, list_type).await
}
async fn get_ipv6_list(&self, direction: FlowDirection, list_type: ListType) -> HashMap<Ipv6Addr, Vec<Port>> {
self.get_ipv6_list(direction, list_type).await
}
}
struct MapWrapper<T> {
map: Option<AyaHashMap<MapData, T, PortRule>>,
}

View File

@ -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<String> {
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<DnsName, Error> {

View File

@ -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<u64, Error> {
self.block_countries(codes)
}
fn unblock_countries(&self, codes: &[String]) -> Result<u64, Error> {
self.unblock_countries(codes)
}
fn list_blocked(&self) -> Vec<String> {
self.get_blocked_countries()
}
}

View File

@ -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<Self>, ml_engine: Arc<Engine>) -> Result<(), Error> {
pub async fn run(self: Arc<Self>, sink_factory: Arc<dyn PacketSinkFactory>) -> 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<dyn DnsQueryFilter> = 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 {

View File

@ -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<u64, Error> {
self.get_packet_rate()
}
fn get_syn_rate(&self) -> Result<u64, Error> {
self.get_syn_rate()
}
fn get_udp_rate(&self) -> Result<u64, Error> {
self.get_udp_rate()
}
fn get_dns_rate(&self) -> Result<u64, Error> {
self.get_dns_rate()
}
fn get_window_ns(&self) -> Result<u64, Error> {
self.get_window_ns()
}
}

View File

@ -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<Arc<Engine>>,
dns_filter: Option<Arc<DnsFilter>>,
sinks: Option<Arc<dyn PacketSinkFactory>>,
dns_filter: Option<Arc<dyn DnsQueryFilter>>,
shutdowns: &SegQueue<oneshot::Sender<()>>,
) -> 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<FrameDesc>,
tracker: Option<Arc<Mutex<FlowTracker>>>,
dns_filter: Option<Arc<DnsFilter>>,
sink: Option<Arc<dyn PacketSink>>,
dns_filter: Option<Arc<dyn DnsQueryFilter>>,
packet_buffer_size: usize,
buffer_pool_capacity: usize,
}
@ -184,8 +183,8 @@ impl XskPair {
rx_ifname: &str,
_tx_ifname: &str,
direction: Direction,
tracker: Option<Arc<Mutex<FlowTracker>>>,
dns_filter: Option<Arc<DnsFilter>>,
sink: Option<Arc<dyn PacketSink>>,
dns_filter: Option<Arc<dyn DnsQueryFilter>>,
) -> Result<Self, Error> {
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

View File

@ -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<dyn ApiKeyPort>) -> HttpResponse {
async fn list_keys(_auth: AuthClaims, db: web::Data<dyn ApiKeyRepo>) -> HttpResponse {
match db.list_api_keys() {
Ok(keys) => {
let responses: Vec<serde_json::Value> = keys
@ -40,7 +40,7 @@ struct GenerateKeyRequest {
async fn generate_key(
_auth: AuthClaims,
db: web::Data<dyn ApiKeyPort>,
db: web::Data<dyn ApiKeyRepo>,
body: web::Json<GenerateKeyRequest>,
) -> HttpResponse {
use rand::Rng;
@ -72,7 +72,7 @@ async fn generate_key(
}
}
async fn delete_key(_auth: AuthClaims, db: web::Data<dyn ApiKeyPort>, path: web::Path<i64>) -> HttpResponse {
async fn delete_key(_auth: AuthClaims, db: web::Data<dyn ApiKeyRepo>, path: web::Path<i64>) -> HttpResponse {
let id = path.into_inner();
match db.delete_api_key(id) {
Ok(true) => HttpResponse::Ok().json(serde_json::json!({"deleted": true})),

View File

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

View File

@ -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<T, E: fmt::Display>(result: Result<T, E>) -> HttpResponse {

View File

@ -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<Database>) -> 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<Database>) -> HttpResp
async fn report_data(_auth: AuthClaims, db: web::Data<Database>) -> HttpResponse {
let db_ref = db.get_ref();
match engine::generate_report_json(db_ref as &dyn 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<Database>) -> HttpResponse
/// Manually trigger: generate the weekly report and send it via SMTP now.
async fn send_report(_auth: AuthClaims, db: web::Data<Database>, secrets: web::Data<SecretStore>) -> HttpResponse {
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)) {

View File

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

View File

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

View File

@ -1,4 +1,5 @@
pub mod access_control_adapter;
pub mod ebpf;
pub mod http;
pub mod persistence;
pub mod telegram;

View File

@ -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<rusqlite::Connection, rusqlite::Error> 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<SqliteConnectionManager>,
/// 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<dyn XxxRepo>` instead of `Arc<Database>` 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<Vec<AclRuleTuple>, Error> {
self.load_acl_rules()
}
fn has_manual_acl_rule(&self, ip_address: &str) -> Result<bool, Error> {
self.has_manual_acl_rule(ip_address)
}
fn load_admin_whitelist(&self) -> Result<Vec<String>, 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<Vec<String>, Error> {
self.load_geo_countries()
}
}
impl SettingRepo for Database {
fn get_setting(&self, key: &str) -> Result<Option<String>, 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<Option<String>, 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<Option<String>, 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<Option<UserTuple>, Error> {
self.find_user(username)
}
fn find_user_by_id(&self, user_id: i64) -> Result<Option<UserTuple>, 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<Option<UserTuple>, Error> {
self.find_user_by_id(user_id)
}
fn list_user_groups(&self) -> Result<Vec<UserGroupTuple>, Error> {
self.list_user_groups()
}
@ -1807,33 +1838,7 @@ impl RepositoryPort for Database {
}
}
impl SoarPort for Database {
fn get_setting(&self, key: &str) -> Result<Option<String>, 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<bool, Error> {
self.has_manual_acl_rule(ip_address)
}
fn insert_pending_unblock(&self, source_ip: &str) -> Result<i64, Error> {
self.insert_pending_unblock(source_ip)
}
@ -1933,18 +1935,89 @@ impl SoarPort for Database {
fn list_soar_executions(&self, limit: i64) -> Result<Vec<SoarExecutionRow>, Error> {
self.list_soar_executions(limit)
}
fn load_admin_whitelist(&self) -> Result<Vec<String>, 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<f64>,
count: Option<i64>,
window: Option<i64>,
cooldown: i64,
actions: &[(i64, String, String)],
conditions: &[(String, String, String, Option<String>)],
) -> Result<i64, Error> {
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<String>)],
) -> Result<bool, Error> {
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<u64, Error> {
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<Option<String>, 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<Vec<AuditLogEntry>, Error> {
self.list_audit_logs()
}
fn verify_audit_log_chain(&self) -> Result<usize, Error> {
self.verify_audit_log_chain()
}
}
impl ApiKeyPort for Database {
impl ApiKeyRepo for Database {
fn validate_api_key(&self, api_key: &str) -> Result<Option<Claims>, 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<i64, Error> {
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<dyn XxxRepo>` 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);
}
}

View File

@ -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<dyn NotificationConfigPort>,
repo: Arc<dyn RepositoryPort>,
notif: Arc<dyn SettingRepo>,
repo: Arc<dyn AppRepo>,
secrets: Option<Arc<dyn SecretStorePort>>,
/// Rate limiter: (count, window_start)
rate_state: Mutex<(u32, Instant)>,
@ -27,8 +28,8 @@ pub struct TelegramAdapter {
impl TelegramAdapter {
pub fn new(
notif: Arc<dyn NotificationConfigPort>,
repo: Arc<dyn RepositoryPort>,
notif: Arc<dyn SettingRepo>,
repo: Arc<dyn AppRepo>,
secrets: Option<Arc<dyn SecretStorePort>>,
) -> Result<Self, Error> {
let client = Client::builder()

View File

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

View File

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

View File

@ -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<dyn RepositoryPort>,
access_control: Arc<AccessControl>,
geo_block: Arc<GeoBlock>,
db: Arc<dyn AppRepo>,
access_control: Arc<dyn AccessControlAdminPort>,
geo_block: Arc<dyn GeoBlockPort>,
}
impl AclService {
pub fn new(db: Arc<dyn RepositoryPort>, access_control: Arc<AccessControl>, geo_block: Arc<GeoBlock>) -> Self {
pub fn new(
db: Arc<dyn AppRepo>,
access_control: Arc<dyn AccessControlAdminPort>,
geo_block: Arc<dyn GeoBlockPort>,
) -> Self {
Self {
db,
access_control,
@ -147,11 +151,11 @@ impl AclService {
}
pub fn get_blocked_countries(&self) -> Vec<String> {
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()
}
}

View File

@ -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::<web::Data<dyn ApiKeyPort>>() {
let api_key_port = match req.app_data::<web::Data<dyn ApiKeyRepo>>() {
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::<web::Data<dyn RepositoryPort>>() {
let repo = match req.app_data::<web::Data<dyn AppRepo>>() {
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());
}
};

View File

@ -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<dyn RepositoryPort>,
db: Arc<dyn AppRepo>,
secrets: Option<Arc<dyn SecretStorePort>>,
}
impl ConfigService {
pub fn new(db: Arc<dyn RepositoryPort>) -> Self {
pub fn new(db: Arc<dyn AppRepo>) -> Self {
Self { db, secrets: None }
}

View File

@ -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<dyn RepositoryPort>,
dns_filter: Arc<DnsFilter>,
db: Arc<dyn AppRepo>,
dns_filter: Arc<dyn DnsFilterPort>,
}
impl DnsFilterService {
pub fn new(db: Arc<dyn RepositoryPort>, dns_filter: Arc<DnsFilter>) -> Self {
pub fn new(db: Arc<dyn AppRepo>, dns_filter: Arc<dyn DnsFilterPort>) -> Self {
Self { db, dns_filter }
}

View File

@ -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<String, Error> {
pub fn generate_weekly_report(db: &dyn SettingRepo) -> Result<String, Error> {
let threats_count = db
.get_setting("weekly_threats_count")?
.unwrap_or_else(|| "0".to_string());

View File

@ -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<Option<Self>, Error> {
pub fn from_database(db: &dyn SettingRepo, secrets: Option<&dyn SecretStorePort>) -> Result<Option<Self>, Error> {
let host = match db.get_setting("smtp_host")? {
Some(v) if !v.is_empty() => v,
_ => return Ok(None),
@ -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<Option<Self>, 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<Option<Self>, 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<dyn RepositoryPort>,
db: Arc<dyn SettingRepo>,
secrets: Option<Arc<dyn SecretStorePort>>,
}
impl ReportScheduler {
pub fn new(db: Arc<dyn RepositoryPort>, secrets: Option<Arc<dyn SecretStorePort>>) -> Self {
pub fn new(db: Arc<dyn SettingRepo>, secrets: Option<Arc<dyn SecretStorePort>>) -> Self {
Self { db, secrets }
}

View File

@ -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<dyn PacketSink>` 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<Arc<dyn PacketSink>> {
let tracker = self.tracker(queue_id).clone();
Some(Arc::new(QueueTrackerSink { tracker }))
}
}

View File

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

View File

@ -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<dyn NotificationConfigPort>,
repo: Arc<dyn RepositoryPort>,
notif: Arc<dyn SettingRepo>,
repo: Arc<dyn AppRepo>,
secrets: Arc<dyn SecretStorePort>,
}
impl NotificationService {
pub fn new(
notif: Arc<dyn NotificationConfigPort>,
repo: Arc<dyn RepositoryPort>,
secrets: Arc<dyn SecretStorePort>,
) -> Self {
pub fn new(notif: Arc<dyn SettingRepo>, repo: Arc<dyn AppRepo>, secrets: Arc<dyn SecretStorePort>) -> Self {
Self { notif, repo, secrets }
}

View File

@ -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<dyn SoarPort>,
db: Arc<dyn AppRepo>,
soar_engine: Arc<SoarEngine>,
access_control: Arc<dyn AccessControlPort>,
}
impl PlaybookService {
pub fn new(
db: Arc<dyn SoarPort>,
soar_engine: Arc<SoarEngine>,
access_control: Arc<dyn AccessControlPort>,
) -> Self {
pub fn new(db: Arc<dyn AppRepo>, soar_engine: Arc<SoarEngine>, access_control: Arc<dyn AccessControlPort>) -> Self {
Self {
db,
soar_engine,
@ -126,27 +121,35 @@ impl PlaybookService {
}
pub fn create_playbook(&self, input: &CreatePlaybookInput) -> Result<i64, Error> {
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<String>)> = 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<String>)> = 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();

View File

@ -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<dyn RepositoryPort>,
config: Arc<RateLimitConfig>,
db: Arc<dyn AppRepo>,
config: Arc<dyn RateLimitPort>,
}
impl RateLimitService {
pub fn new(db: Arc<dyn RepositoryPort>, config: Arc<RateLimitConfig>) -> Self {
pub fn new(db: Arc<dyn AppRepo>, config: Arc<dyn RateLimitPort>) -> 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> {

View File

@ -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<PathBuf, Error> {
pub fn generate_html_report(db: &dyn SettingRepo, output_dir: &str) -> Result<PathBuf, Error> {
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<serde_json::Value, Error> {
pub fn generate_report_json(db: &dyn SettingRepo) -> Result<serde_json::Value, Error> {
let data = ReportData::from_database(db)?;
serde_json::to_value(&data).map_err(|e| MiscError::SerializeError(e).into())
}

View File

@ -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<String, Error> {
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<String, Error> {
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<String, Error> {
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<String, Error> {
if let Some(notifier) = &self.alert_notifier {
let country = if let Some(geoip) = &self.geoip {
if let Ok(ip_addr) = event.source_ip.parse::<IpAddr>() {
match geoip.lookup(ip_addr).await {
Ok(Some(loc)) => loc.country,
_ => None,
}
} 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<String, Error> {
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!(
"<h2>Threat Detected</h2>\
<p><b>Source IP:</b> {}</p>\
<p><b>Threat Type:</b> {}</p>\
<p><b>Confidence:</b> {:.1}%</p>\
<p><b>Time:</b> {}</p>",
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<String, Error> {
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<SocketAddr> = 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<String> = 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<String, Error> {
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(())
}
}

View File

@ -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<dyn SoarPort>,
access_control: Arc<dyn AccessControlPort>,
pub(super) db: Arc<dyn AppRepo>,
pub(super) access_control: Arc<dyn AccessControlPort>,
/// In-memory cache of playbooks (loaded at startup, refreshed on change).
playbooks: RwLock<Vec<Playbook>>,
pub(super) playbooks: RwLock<Vec<Playbook>>,
/// In-memory cache of admin whitelist IPs.
admin_whitelist: RwLock<HashSet<String>>,
pub(super) admin_whitelist: RwLock<HashSet<String>>,
/// Cooldown tracker: maps (playbook_id, source_ip) → last execution time.
cooldowns: DashMap<CooldownKey, Instant>,
pub(super) cooldowns: DashMap<CooldownKey, Instant>,
/// 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<Arc<dyn AlertNotifier>>,
pub(super) alert_notifier: Option<Arc<dyn AlertNotifier>>,
/// Optional GeoIP service for country lookups.
geoip: Option<Arc<GeoIpService>>,
pub(super) geoip: Option<Arc<GeoIpService>>,
/// Optional rate limit config for adjust_rate_limit action.
rate_limit: Option<Arc<RateLimitConfig>>,
pub(super) rate_limit: Option<Arc<dyn RateLimitPort>>,
/// 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<AtomicU8>,
pub(super) enforce_level_cache: Arc<AtomicU8>,
/// Secret store for decrypting SMTP passwords etc.
secrets: Option<Arc<dyn SecretStorePort>>,
pub(super) secrets: Option<Arc<dyn SecretStorePort>>,
}
impl SoarEngine {
pub fn new(
db: Arc<dyn SoarPort>,
db: Arc<dyn AppRepo>,
access_control: Arc<dyn AccessControlPort>,
alert_notifier: Option<Arc<dyn AlertNotifier>>,
geoip: Option<Arc<GeoIpService>>,
rate_limit: Option<Arc<RateLimitConfig>>,
rate_limit: Option<Arc<dyn RateLimitPort>>,
enforce_level_cache: Arc<AtomicU8>,
secrets: Option<Arc<dyn SecretStorePort>>,
) -> Result<Self, Error> {
@ -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<Playbook> {
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::<f64>() {
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::<ipnetwork::IpNetwork>() {
Ok(n) => n,
Err(_) => return false,
};
let ip = match event.source_ip.parse::<IpAddr>() {
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::<u64>() {
Ok(v) => v,
Err(_) => return false,
};
let window_secs = cond.value2.as_ref().and_then(|s| s.parse::<u64>().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<String, Error> {
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<String, Error> {
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<String, Error> {
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<String, Error> {
if let Some(notifier) = &self.alert_notifier {
let country = if let Some(geoip) = &self.geoip {
if let Ok(ip_addr) = event.source_ip.parse::<IpAddr>() {
match geoip.lookup(ip_addr).await {
Ok(Some(loc)) => loc.country,
_ => None,
}
} 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<String, Error> {
// 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!(
"<h2>Threat Detected</h2>\
<p><b>Source IP:</b> {}</p>\
<p><b>Threat Type:</b> {}</p>\
<p><b>Confidence:</b> {:.1}%</p>\
<p><b>Time:</b> {}</p>",
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<String, Error> {
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<SocketAddr> = 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<String> = 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<String, Error> {
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<dyn SoarPort> {
fn test_db() -> Arc<dyn AppRepo> {
use crate::adapter::persistence::Database;
Arc::new(Database::new(":memory:").expect("Failed to create test database")) as Arc<dyn SoarPort>
Arc::new(Database::new(":memory:").expect("Failed to create test database")) as Arc<dyn AppRepo>
}
fn test_engine(ac: Arc<dyn AccessControlPort>) -> SoarEngine {

View File

@ -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<Playbook> {
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::<f64>() {
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::<ipnetwork::IpNetwork>() {
Ok(n) => n,
Err(_) => return false,
};
let ip = match event.source_ip.parse::<IpAddr>() {
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::<u64>() {
Ok(v) => v,
Err(_) => return false,
};
let window_secs = cond.value2.as_ref().and_then(|s| s.parse::<u64>().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
}
}
}
}

View File

@ -1,3 +1,5 @@
pub mod actions;
pub mod engine;
pub mod frequency;
pub mod matcher;
pub mod scheduler;

View File

@ -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<dyn SoarPort>,
db: Arc<dyn AppRepo>,
access_control: Arc<dyn AccessControlPort>,
soar_engine: Arc<SoarEngine>,
}
impl TtlScheduler {
pub fn new(
db: Arc<dyn SoarPort>,
access_control: Arc<dyn AccessControlPort>,
soar_engine: Arc<SoarEngine>,
) -> Self {
pub fn new(db: Arc<dyn AppRepo>, access_control: Arc<dyn AccessControlPort>, soar_engine: Arc<SoarEngine>) -> 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;
}

View File

@ -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<dyn StatsPort>,
repo: Arc<dyn RepositoryPort>,
stats: Arc<dyn StatsRepo>,
repo: Arc<dyn SettingRepo>,
}
impl StatsAggregator {
pub fn new(stats: Arc<dyn StatsPort>, repo: Arc<dyn RepositoryPort>) -> Self {
pub fn new(stats: Arc<dyn StatsRepo>, repo: Arc<dyn SettingRepo>) -> 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<dyn StatsPort>, db.clone() as Arc<dyn RepositoryPort>);
let aggregator = StatsAggregator::new(db.clone() as Arc<dyn StatsRepo>, db.clone() as Arc<dyn SettingRepo>);
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<dyn StatsPort>, db.clone() as Arc<dyn RepositoryPort>);
let aggregator = StatsAggregator::new(db.clone() as Arc<dyn StatsRepo>, db.clone() as Arc<dyn SettingRepo>);
aggregator
.aggregate()
.expect("aggregation should succeed with empty data");

View File

@ -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<dyn AuditPort>,
db: Arc<dyn AuditRepo>,
}
impl AuditLogger {
pub fn new(db: Arc<dyn AuditPort>) -> Self {
pub fn new(db: Arc<dyn AuditRepo>) -> Self {
Self { db }
}

View File

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

View File

@ -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<dyn RepositoryPort>,
db: Arc<dyn AppRepo>,
comm: Arc<CommunicationManager>,
/// Shared AtomicU8 cache: Monitor=0, MlOnly=1, Enforce=2.
enforce_cache: Arc<AtomicU8>,
}
impl EnforceModeHandler {
pub fn new(db: Arc<dyn RepositoryPort>, comm: Arc<CommunicationManager>, enforce_cache: Arc<AtomicU8>) -> Self {
pub fn new(db: Arc<dyn AppRepo>, comm: Arc<CommunicationManager>, enforce_cache: Arc<AtomicU8>) -> Self {
Self {
db,
comm,
@ -82,7 +82,7 @@ mod tests {
use crate::interface::communication::query_types::GetEnforceModeQuery;
fn test_handler() -> (Arc<EnforceModeHandler>, Arc<CommunicationManager>) {
let db = Arc::new(Database::new(":memory:").unwrap()) as Arc<dyn RepositoryPort>;
let db = Arc::new(Database::new(":memory:").unwrap()) as Arc<dyn AppRepo>;
let cache = Arc::new(AtomicU8::new(0));
let comm = Arc::new(CommunicationManager::new());
comm.register_event_type::<AuditEvent>();

View File

@ -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<dyn RepositoryPort>))
.app_data(web::Data::from(db.clone() as Arc<dyn ApiKeyPort>))
.app_data(web::Data::from(db.clone() as Arc<dyn AppRepo>))
.app_data(web::Data::from(db.clone() as Arc<dyn ApiKeyRepo>))
.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<dyn RepositoryPort>))
.app_data(web::Data::from(db.clone() as Arc<dyn ApiKeyPort>))
.app_data(web::Data::from(db.clone() as Arc<dyn AppRepo>))
.app_data(web::Data::from(db.clone() as Arc<dyn ApiKeyRepo>))
.app_data(web::Data::from(db.clone()))
.app_data(web::Data::from(secret_store.clone()))
.app_data(web::Data::from(jwt_service.clone()))

View File

@ -12,3 +12,4 @@ pub mod service_factory;
pub mod statistics;
pub mod suricata_manager;
pub mod suricata_monitor;
pub mod system;

View File

@ -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<dyn RepositoryPort>,
db.clone() as Arc<dyn AppRepo>,
comm.clone(),
enforce_level_cache.clone(),
));
@ -219,7 +220,7 @@ impl ServiceFactory {
comm.register_event_type::<AuditEvent>();
// 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<Arc<dyn AlertNotifier>> = match TelegramAdapter::new(
db.clone() as Arc<dyn NotificationConfigPort>,
db.clone() as Arc<dyn RepositoryPort>,
db.clone() as Arc<dyn SettingRepo>,
db.clone() as Arc<dyn AppRepo>,
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<dyn crate::interface::port::rate_limit_api::RateLimitPort> =
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<dyn RepositoryPort>, Some(secret_store_port.clone()));
ReportScheduler::new(db.clone() as Arc<dyn SettingRepo>, 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<dyn crate::interface::port::access_control_admin::AccessControlAdminPort> =
ebpf_services.access_control.clone();
let geo_block_port: Arc<dyn crate::interface::port::geo_block_api::GeoBlockPort> =
ebpf_services.geo_block.clone();
let dns_filter_port: Arc<dyn crate::interface::port::dns_filter_api::DnsFilterPort> =
ebpf_services.dns_filter.clone();
let acl_service = Arc::new(AclService::new(
db.clone() as Arc<dyn RepositoryPort>,
ebpf_services.access_control.clone(),
ebpf_services.geo_block.clone(),
));
let dns_filter_service = Arc::new(DnsFilterService::new(
db.clone() as Arc<dyn RepositoryPort>,
ebpf_services.dns_filter.clone(),
));
let rate_limit_service = Arc::new(RateLimitService::new(
db.clone() as Arc<dyn RepositoryPort>,
ebpf_services.rate_limit.clone(),
db.clone() as Arc<dyn AppRepo>,
access_control_admin,
geo_block_port,
));
let dns_filter_service = Arc::new(DnsFilterService::new(db.clone() as Arc<dyn AppRepo>, dns_filter_port));
let rate_limit_service = Arc::new(RateLimitService::new(db.clone() as Arc<dyn AppRepo>, rate_limit_port));
let playbook_service = Arc::new(PlaybookService::new(
db.clone(),
soar_engine.clone(),
access_control_port,
));
let config_service = Arc::new(
ConfigService::new(db.clone() as Arc<dyn RepositoryPort>).with_secret_store(secret_store_port.clone()),
);
let config_service =
Arc::new(ConfigService::new(db.clone() as Arc<dyn AppRepo>).with_secret_store(secret_store_port.clone()));
let notification_service = Arc::new(NotificationService::new(
db.clone() as Arc<dyn NotificationConfigPort>,
db.clone() as Arc<dyn RepositoryPort>,
db.clone() as Arc<dyn SettingRepo>,
db.clone() as Arc<dyn AppRepo>,
secret_store_port,
));

View File

@ -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<dyn crate::interface::port::packet_sink::PacketSinkFactory> =
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<dyn AuditPort>));
let audit_logger = Arc::new(AuditLogger::new(self.db.clone() as Arc<dyn AuditRepo>));
audit_logger.start(&self.comm);
// Start stats aggregator (writes weekly_* settings for Report engine)
let stats_aggregator = StatsAggregator::new(
self.db.clone() as Arc<dyn StatsPort>,
self.db.clone() as Arc<dyn RepositoryPort>,
self.db.clone() as Arc<dyn StatsRepo>,
self.db.clone() as Arc<dyn SettingRepo>,
);
stats_aggregator.start();

View File

@ -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<Ipv4Addr, Vec<Port>>;
async fn get_ipv6_list(&self, direction: FlowDirection, list_type: ListType) -> HashMap<Ipv6Addr, Vec<Port>>;
}

View File

@ -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<Vec<AclRuleTuple>, 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<bool, Error>;
fn load_admin_whitelist(&self) -> Result<Vec<String>, Error>;
fn insert_admin_whitelist(&self, ip: &str) -> Result<(), Error>;
fn delete_admin_whitelist(&self, ip: &str) -> Result<(), Error>;
}

View File

@ -5,8 +5,9 @@ use crate::model::identity::auth::Claims;
#[allow(clippy::type_complexity)]
pub type ApiKeyListItem = (i64, String, String, String, Option<String>);
/// 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<Option<Claims>, Error>;
fn hmac_api_key(&self, raw_key: &str) -> String;
fn insert_api_key(&self, key_hash: &str, name: &str, permission_level: &str) -> Result<i64, Error>;

View File

@ -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<dyn AclRepo>`, `Arc<dyn SoarRepo>`, …) 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<T> AppRepo for T where
T: AclRepo
+ ApiKeyRepo
+ AuditRepo
+ DbAdminRepo
+ EnforcementRepo
+ IdentityRepo
+ SettingRepo
+ SoarRepo
+ StatsRepo
+ Send
+ Sync
+ ?Sized
{
}

View File

@ -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<Vec<AuditLogEntry>, 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<usize, Error>;
}

View File

@ -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<i64, Error>;
/// 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>;
}

View File

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

View File

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

View File

@ -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<Vec<(String, u64)>, 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<Vec<String>, 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<Vec<String>, Error>;
}

View File

@ -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<u64, Error>;
/// Remove every code in `codes` from the block set. Returns the number
/// of /24 ranges actually removed.
fn unblock_countries(&self, codes: &[String]) -> Result<u64, Error>;
fn list_blocked(&self) -> Vec<String>;
}

View File

@ -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<Vec<AclRuleTuple>, Error>;
// --- Rate Limit ---
fn set_rate_limit(&self, key: &str, value: u64) -> Result<(), Error>;
fn load_rate_limit_config(&self) -> Result<Vec<(String, u64)>, 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<Vec<String>, 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<Vec<String>, Error>;
// --- Settings ---
fn get_setting(&self, key: &str) -> Result<Option<String>, Error>;
fn set_setting(&self, key: &str, value: &str) -> Result<(), Error>;
pub trait IdentityRepo: Send + Sync {
// --- Users ---
fn find_user(&self, username: &str) -> Result<Option<UserTuple>, Error>;
fn find_user_by_id(&self, user_id: i64) -> Result<Option<UserTuple>, Error>;
fn insert_user(
&self,
username: &str,
@ -69,14 +32,11 @@ pub trait RepositoryPort: Send + Sync {
) -> Result<i64, Error>;
fn update_user_password(&self, user_id: i64, password_hash: &str) -> Result<(), Error>;
fn user_count(&self) -> Result<i64, Error>;
// --- User Management ---
fn list_users(&self) -> Result<Vec<UserListItem>, Error>;
fn list_users_with_groups(&self) -> Result<Vec<UserWithGroups>, Error>;
fn delete_user(&self, user_id: i64) -> Result<bool, Error>;
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<Option<UserTuple>, Error>;
// --- User Groups ---
fn list_user_groups(&self) -> Result<Vec<UserGroupTuple>, Error>;
@ -85,7 +45,7 @@ pub trait RepositoryPort: Send + Sync {
fn delete_user_group(&self, id: i64) -> Result<bool, Error>;
fn get_user_group(&self, id: i64) -> Result<Option<UserGroupTuple>, Error>;
// --- User Group Membership ---
// --- Membership ---
fn get_user_groups(&self, user_id: i64) -> Result<Vec<(i64, String, String, String)>, Error>;
fn set_user_groups(&self, user_id: i64, group_ids: &[i64]) -> Result<(), Error>;
fn get_user_permissions(&self, user_id: i64) -> Result<Vec<String>, Error>;

View File

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

View File

@ -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<Option<String>, Error>;
fn set_notification_config(&self, channel: &str, config_json: &str) -> Result<(), Error>;
}

View File

@ -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<Arc<dyn PacketSink>>;
}

View File

@ -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<u64, Error>;
fn get_syn_rate(&self) -> Result<u64, Error>;
fn get_udp_rate(&self) -> Result<u64, Error>;
fn get_dns_rate(&self) -> Result<u64, Error>;
fn get_window_ns(&self) -> Result<u64, Error>;
}

View File

@ -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<Option<String>, Error>;
fn set_secret(&self, key: &str, plaintext: &str) -> Result<(), Error>;

View File

@ -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<Option<String>, Error>;
fn set_setting(&self, key: &str, value: &str) -> Result<(), Error>;
// --- App secrets (encrypted-at-rest in `app_secrets` table) ---
fn get_app_secret(&self, key: &str) -> Result<Option<String>, Error>;
fn set_app_secret(&self, key: &str, plaintext: &str) -> Result<(), Error>;
// --- Notification channel config blobs (JSON) ---
fn get_notification_config(&self, channel: &str) -> Result<Option<String>, Error>;
fn set_notification_config(&self, channel: &str, config_json: &str) -> Result<(), Error>;
}

View File

@ -24,31 +24,15 @@ pub type PlaybookRow = (
#[allow(clippy::type_complexity)]
pub type SoarExecutionRow = (i64, i64, Option<String>, 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<Option<String>, 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<Option<(i64, String, i64, String)>, Error>;
fn get_expired_soar_blocks(&self) -> Result<Vec<(i64, String, i64)>, Error>;
fn mark_soar_block_unblocked(&self, id: i64) -> Result<(), Error>;
fn has_manual_acl_rule(&self, ip_address: &str) -> Result<bool, Error>;
// --- Pending Unblock Recovery ---
fn insert_pending_unblock(&self, source_ip: &str) -> Result<i64, Error>;
@ -112,8 +95,35 @@ pub trait SoarPort: Send + Sync {
) -> Result<i64, Error>;
fn list_soar_executions(&self, limit: i64) -> Result<Vec<SoarExecutionRow>, Error>;
// --- Admin Whitelist ---
fn load_admin_whitelist(&self) -> Result<Vec<String>, Error>;
fn insert_admin_whitelist(&self, ip: &str) -> Result<(), Error>;
fn delete_admin_whitelist(&self, ip: &str) -> Result<(), Error>;
// --- 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<f64>,
count: Option<i64>,
window: Option<i64>,
cooldown: i64,
actions: &[(i64, String, String)],
conditions: &[(String, String, String, Option<String>)],
) -> Result<i64, Error>;
/// 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<String>)],
) -> Result<bool, Error>;
}

View File

@ -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<u64, Error>;
fn count_weekly_blocks(&self, days: i64) -> Result<u64, Error>;
fn count_weekly_unblocks(&self, days: i64) -> Result<u64, Error>;

View File

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

View File

@ -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<Self, Error> {
pub fn from_database(db: &dyn SettingRepo) -> Result<Self, Error> {
let now = Local::now();
let period = format!(
"{} — {}",