mirror of
https://github.com/DaLaw2/NetGuardia.git
synced 2026-08-24 14:10:28 +09:00
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:
parent
c4ac70fbe1
commit
231fb88efd
@ -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;
|
||||
|
||||
@ -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>>,
|
||||
}
|
||||
@ -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> {
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
@ -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})),
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
pub mod access_control_adapter;
|
||||
pub mod ebpf;
|
||||
pub mod http;
|
||||
pub mod persistence;
|
||||
pub mod telegram;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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());
|
||||
}
|
||||
};
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@ -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 }))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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())
|
||||
}
|
||||
|
||||
509
net-guardia/src/core/soar/actions.rs
Normal file
509
net-guardia/src/core/soar/actions.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
236
net-guardia/src/core/soar/matcher.rs
Normal file
236
net-guardia/src/core/soar/matcher.rs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,3 +1,5 @@
|
||||
pub mod actions;
|
||||
pub mod engine;
|
||||
pub mod frequency;
|
||||
pub mod matcher;
|
||||
pub mod scheduler;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>();
|
||||
|
||||
@ -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()))
|
||||
|
||||
@ -12,3 +12,4 @@ pub mod service_factory;
|
||||
pub mod statistics;
|
||||
pub mod suricata_manager;
|
||||
pub mod suricata_monitor;
|
||||
pub mod system;
|
||||
|
||||
@ -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,
|
||||
));
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
50
net-guardia/src/interface/port/access_control_admin.rs
Normal file
50
net-guardia/src/interface/port/access_control_admin.rs
Normal 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>>;
|
||||
}
|
||||
41
net-guardia/src/interface/port/acl.rs
Normal file
41
net-guardia/src/interface/port/acl.rs
Normal 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>;
|
||||
}
|
||||
@ -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>;
|
||||
|
||||
49
net-guardia/src/interface/port/app_repo.rs
Normal file
49
net-guardia/src/interface/port/app_repo.rs
Normal 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
|
||||
{
|
||||
}
|
||||
@ -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>;
|
||||
}
|
||||
|
||||
40
net-guardia/src/interface/port/db_admin.rs
Normal file
40
net-guardia/src/interface/port/db_admin.rs
Normal 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>;
|
||||
}
|
||||
12
net-guardia/src/interface/port/dns_filter_api.rs
Normal file
12
net-guardia/src/interface/port/dns_filter_api.rs
Normal 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>;
|
||||
}
|
||||
12
net-guardia/src/interface/port/dns_query_filter.rs
Normal file
12
net-guardia/src/interface/port/dns_query_filter.rs
Normal 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;
|
||||
}
|
||||
24
net-guardia/src/interface/port/enforcement.rs
Normal file
24
net-guardia/src/interface/port/enforcement.rs
Normal 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>;
|
||||
}
|
||||
17
net-guardia/src/interface/port/geo_block_api.rs
Normal file
17
net-guardia/src/interface/port/geo_block_api.rs
Normal 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>;
|
||||
}
|
||||
@ -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>;
|
||||
@ -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;
|
||||
|
||||
@ -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>;
|
||||
}
|
||||
|
||||
23
net-guardia/src/interface/port/packet_sink.rs
Normal file
23
net-guardia/src/interface/port/packet_sink.rs
Normal 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>>;
|
||||
}
|
||||
21
net-guardia/src/interface/port/rate_limit_api.rs
Normal file
21
net-guardia/src/interface/port/rate_limit_api.rs
Normal 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>;
|
||||
}
|
||||
@ -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>;
|
||||
|
||||
23
net-guardia/src/interface/port/setting.rs
Normal file
23
net-guardia/src/interface/port/setting.rs
Normal 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>;
|
||||
}
|
||||
@ -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>;
|
||||
}
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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!(
|
||||
"{} — {}",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user