Compare commits

...

2 Commits

Author SHA1 Message Date
b7ee352ca5 fix: CI Failed 2026-04-26 21:38:07 +08:00
f623eeba31 wip: Architecture adjustment 2026-04-26 19:34:44 +08:00
190 changed files with 4044 additions and 3455 deletions

12
Cargo.lock generated
View File

@ -1249,7 +1249,7 @@ checksum = "15401da73a9ed8c80e3b2d4dc05fe10e7b72d7243b9f614e516a44fa99986e88"
[[package]]
name = "egress-ebpf"
version = "0.1.0"
version = "1.0.0"
dependencies = [
"aya-ebpf",
"aya-log-ebpf",
@ -1906,7 +1906,7 @@ dependencies = [
[[package]]
name = "ingress-ebpf"
version = "0.1.0"
version = "1.0.0"
dependencies = [
"aya-ebpf",
"aya-log-ebpf",
@ -2318,7 +2318,7 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "macros"
version = "0.1.0"
version = "1.0.0"
dependencies = [
"proc-macro2",
"quote",
@ -2365,7 +2365,7 @@ dependencies = [
[[package]]
name = "mcp-server"
version = "0.1.0"
version = "1.0.0"
dependencies = [
"clap",
"reqwest",
@ -2478,7 +2478,7 @@ dependencies = [
[[package]]
name = "net-guardia"
version = "0.1.0"
version = "1.0.0"
dependencies = [
"actix",
"actix-cors",
@ -2553,7 +2553,7 @@ dependencies = [
[[package]]
name = "ng-cli"
version = "0.1.0"
version = "1.0.0"
dependencies = [
"clap",
"libc",

View File

@ -1,6 +1,6 @@
[package]
name = "ng-cli"
version = "0.1.0"
version = "1.0.0"
edition = "2024"
[dependencies]

View File

@ -1,6 +1,6 @@
[package]
name = "egress-ebpf"
version = "0.1.0"
version = "1.0.0"
edition = "2024"
[dependencies]

View File

@ -1,6 +1,6 @@
[package]
name = "ingress-ebpf"
version = "0.1.0"
version = "1.0.0"
edition = "2024"
[dependencies]

View File

@ -1,6 +1,6 @@
[package]
name = "macros"
version = "0.1.0"
version = "1.0.0"
edition = "2024"
[lib]

View File

@ -497,7 +497,7 @@ pub fn config_settings_impl(attr: TokenStream, item: TokenStream) -> TokenStream
}
pub fn from_settings(
repo: &dyn crate::interface::port::setting::SettingRepo,
repo: &dyn crate::interface::setting::SettingRepo,
) -> Result<Self, crate::domain::common::error::Error> {
let mut cfg = Self::defaults();
#(#override_calls)*
@ -505,7 +505,7 @@ pub fn config_settings_impl(attr: TokenStream, item: TokenStream) -> TokenStream
}
pub fn seed_defaults(
repo: &dyn crate::interface::port::setting::SettingRepo,
repo: &dyn crate::interface::setting::SettingRepo,
) -> Result<(), crate::domain::common::error::Error> {
#(#seed_calls)*
Ok(())

View File

@ -1,6 +1,6 @@
[package]
name = "mcp-server"
version = "0.1.0"
version = "1.0.0"
edition = "2024"
[dependencies]

View File

@ -1,6 +1,6 @@
[package]
name = "net-guardia"
version = "0.1.0"
version = "1.0.0"
edition = "2024"
[dependencies]

View File

@ -6,7 +6,7 @@ use crate::domain::common::error::Error;
use crate::domain::data_plane::direction::FlowDirection;
use crate::domain::data_plane::error::EbpfError;
use crate::domain::data_plane::list_type::ListType;
use crate::interface::port::access_control::AccessControlPort;
use crate::interface::access_control::AccessControlPort;
/// Adapter that implements AccessControlPort by delegating to the eBPF AccessControl.
pub struct AccessControlAdapter {

View File

@ -12,7 +12,7 @@ use crate::domain::data_plane::direction::FlowDirection;
use crate::domain::data_plane::error::EbpfError;
use crate::domain::data_plane::ip_address::NativeConvert;
use crate::domain::data_plane::list_type::ListType;
use crate::interface::port::access_control_admin::AccessControlAdminPort;
use crate::interface::access_control_admin::AccessControlAdminPort;
pub struct AccessControl {
ipv4_src_whitelist: RwLock<MapWrapper<IPv4>>,

View File

@ -1,7 +1,7 @@
use std::mem;
use std::net::Ipv6Addr;
use std::sync::Arc;
use std::sync::atomic::Ordering;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use aya::maps::{MapData, RingBuf};
@ -10,8 +10,37 @@ use common::model::drop_event::DropEvent as RawDropEvent;
use tokio::sync::{broadcast, oneshot};
use tokio::time::interval;
use crate::domain::data_plane::drop_event::{DropCounters, DropCountersAtomic, DropEventMessage};
use crate::interface::port::drop_stats::DropStatsPort;
use crate::domain::data_plane::drop_event::{DropCounters, DropEventMessage};
use crate::interface::drop_stats::DropStatsPort;
#[derive(Default)]
pub struct DropCountersAtomic {
acl_blacklist: AtomicU64,
rate_limit_pkt: AtomicU64,
rate_limit_syn: AtomicU64,
rate_limit_udp: AtomicU64,
rate_limit_dns: AtomicU64,
protocol_filter: AtomicU64,
dns_blacklist: AtomicU64,
geo_block: AtomicU64,
total: AtomicU64,
}
impl DropCountersAtomic {
pub fn snapshot(&self) -> DropCounters {
DropCounters {
acl_blacklist: self.acl_blacklist.load(Ordering::Relaxed),
rate_limit_pkt: self.rate_limit_pkt.load(Ordering::Relaxed),
rate_limit_syn: self.rate_limit_syn.load(Ordering::Relaxed),
rate_limit_udp: self.rate_limit_udp.load(Ordering::Relaxed),
rate_limit_dns: self.rate_limit_dns.load(Ordering::Relaxed),
protocol_filter: self.protocol_filter.load(Ordering::Relaxed),
dns_blacklist: self.dns_blacklist.load(Ordering::Relaxed),
geo_block: self.geo_block.load(Ordering::Relaxed),
total: self.total.load(Ordering::Relaxed),
}
}
}
pub struct DropMonitor {
broadcast_tx: broadcast::Sender<DropEventMessage>,
@ -31,12 +60,7 @@ impl DropMonitor {
self.broadcast_tx.subscribe()
}
/// Record a userspace drop decision (XSK worker's DNS filter) by the
/// per-reason counter. Callers at this layer haven't parsed src/dst yet,
/// so no broadcast event is emitted — `/api/stats/drops` stays correct,
/// `/ws/drops` simply does not surface the individual packet. Parse the
/// packet upstream if you need a structured event.
fn bucket_for(&self, reason: u8) -> Option<&std::sync::atomic::AtomicU64> {
fn bucket_for(&self, reason: u8) -> Option<&AtomicU64> {
match reason {
DROP_REASON_ACL_BLACKLIST => Some(&self.counters.acl_blacklist),
DROP_REASON_RATE_LIMIT_PKT => Some(&self.counters.rate_limit_pkt),
@ -66,7 +90,6 @@ impl DropMonitor {
let reason_str = reason_to_str(raw.reason);
// Format IPs based on version
let (src_ip, dst_ip) = format_ips(raw);
let msg = DropEventMessage {
@ -104,7 +127,6 @@ fn format_ips(raw: &RawDropEvent) -> (String, String) {
(src, dst)
}
_ => {
// IPv6 - format as hex
let src = format_ipv6(&raw.src_ip);
let dst = format_ipv6(&raw.dst_ip);
(src, dst)

View File

@ -13,7 +13,7 @@ use crate::domain::common::config::AppConfig;
use crate::domain::common::error::Error;
use crate::domain::common::error::misc::MiscError;
use crate::domain::data_plane::error::EbpfError;
use crate::interface::port::geo_block_api::GeoBlockPort;
use crate::interface::geo_block_api::GeoBlockPort;
/// Pre-indexed GeoIP prefix table, built once at startup.
struct GeoIndex {

View File

@ -1,5 +1,4 @@
pub mod access_control;
pub mod dns_filter;
pub mod drop_monitor;
pub mod geo_block;
pub mod protocol_filter;
@ -17,7 +16,6 @@ use parking_lot::Mutex;
use tokio::sync::oneshot;
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;
@ -27,14 +25,14 @@ use crate::domain::common::config::AppConfig;
use crate::domain::common::error::Error;
use crate::domain::common::error::system::SystemError;
use crate::domain::data_plane::error::EbpfError;
use crate::interface::port::dns_query_filter::DnsQueryFilter;
use crate::interface::port::packet_sink::PacketSinkFactory;
use crate::interface::dns_query_filter::DnsQueryFilter;
use crate::interface::packet_sink::PacketSinkFactory;
pub struct EbpfServices {
pub xsk_manager: Arc<XskManager>,
pub access_control: Arc<AccessControl>,
pub protocol_filter: Arc<ProtocolFilter>,
pub dns_filter: Arc<DnsFilter>,
pub dns_query_filter: Arc<dyn DnsQueryFilter>,
pub geo_block: Arc<GeoBlock>,
pub rate_limit: Arc<RateLimitConfig>,
pub drop_monitor: Arc<DropMonitor>,
@ -47,11 +45,11 @@ impl EbpfServices {
app_config: Arc<ArcSwap<AppConfig>>,
ingress_ebpf: &mut Ebpf,
egress_ebpf: &mut Ebpf,
dns_query_filter: Arc<dyn DnsQueryFilter>,
) -> Result<Self, Error> {
let xsk_manager = XskManager::new(app_config.clone(), ingress_ebpf, egress_ebpf)?;
let access_control = AccessControl::new(ingress_ebpf)?;
let protocol_filter = ProtocolFilter::new(ingress_ebpf)?;
let dns_filter = DnsFilter::new();
let geo_block = GeoBlock::new(ingress_ebpf, app_config.clone())?;
let rate_limit = RateLimitConfig::new(ingress_ebpf)?;
let drop_monitor = Arc::new(DropMonitor::new(app_config.load().observability.drop_channel_capacity));
@ -63,7 +61,7 @@ impl EbpfServices {
xsk_manager: Arc::new(xsk_manager),
access_control: Arc::new(access_control),
protocol_filter: Arc::new(protocol_filter),
dns_filter: Arc::new(dns_filter),
dns_query_filter,
geo_block: Arc::new(geo_block),
rate_limit: Arc::new(rate_limit),
drop_monitor,
@ -72,15 +70,12 @@ impl EbpfServices {
})
}
/// Build an EbpfServices with every eBPF-backed subservice in the
/// "unavailable" state. Used when eBPF failed to load at startup.
/// Queries return empty results; mutating calls return `EbpfError::NotLoaded`.
pub fn unavailable(app_config: Arc<ArcSwap<AppConfig>>) -> Self {
pub fn unavailable(app_config: Arc<ArcSwap<AppConfig>>, dns_query_filter: Arc<dyn DnsQueryFilter>) -> Self {
Self {
xsk_manager: Arc::new(XskManager::unavailable(app_config.clone())),
access_control: Arc::new(AccessControl::unavailable()),
protocol_filter: Arc::new(ProtocolFilter::unavailable()),
dns_filter: Arc::new(DnsFilter::new()),
dns_query_filter,
geo_block: Arc::new(GeoBlock::unavailable(app_config.clone())),
rate_limit: Arc::new(RateLimitConfig::unavailable()),
drop_monitor: Arc::new(DropMonitor::new(app_config.load().observability.drop_channel_capacity)),
@ -91,7 +86,7 @@ impl EbpfServices {
pub async fn run(self: Arc<Self>, sink_factory: Arc<dyn PacketSinkFactory>) -> Result<(), Error> {
let xsk_manager = self.xsk_manager.clone();
let dns: Arc<dyn DnsQueryFilter> = self.dns_filter.clone();
let dns = self.dns_query_filter.clone();
xsk_manager.run(
Some(sink_factory),
Some(dns),

View File

@ -1,5 +1,5 @@
use std::collections::HashMap;
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddrV4, SocketAddrV6};
use std::net::{IpAddr, SocketAddr};
use aya::maps::{Array as AyaArray, HashMap as AyaHashMap, MapData};
use aya::{Ebpf, Pod};
@ -11,7 +11,7 @@ use parking_lot::RwLock;
use crate::domain::common::error::Error;
use crate::domain::data_plane::error::EbpfError;
use crate::domain::data_plane::ip_address::NativeConvert;
use crate::interface::port::protocol_filter::ProtocolFilterPort;
use crate::interface::protocol_filter::{IpVersion, ProtocolFilterPort};
pub struct ProtocolFilter {
ipv4_http_service: RwLock<HttpServiceWrapper<AddrPortV4>>,
@ -41,7 +41,6 @@ impl ProtocolFilter {
Ok(service)
}
/// Construct a ProtocolFilter backed by no eBPF maps.
pub fn unavailable() -> Self {
Self {
ipv4_http_service: RwLock::new(HttpServiceWrapper::unavailable()),
@ -55,235 +54,196 @@ impl ProtocolFilter {
ipv6_ssh_black_list: RwLock::new(EntryMap::unavailable()),
}
}
}
pub fn get_ipv4_http_service(&self) -> HashMap<SocketAddrV4, Vec<HttpMethod>> {
self.ipv4_http_service.read().get_http_method()
fn require_v4_socket(addr: SocketAddr) -> Result<std::net::SocketAddrV4, Error> {
match addr {
SocketAddr::V4(a) => Ok(a),
SocketAddr::V6(_) => Err(EbpfError::IpVersionMismatch("IPv4".to_string()))?,
}
}
pub fn get_ipv6_http_service(&self) -> HashMap<SocketAddrV6, Vec<HttpMethod>> {
self.ipv6_http_service.read().get_http_method()
fn require_v6_socket(addr: SocketAddr) -> Result<std::net::SocketAddrV6, Error> {
match addr {
SocketAddr::V6(a) => Ok(a),
SocketAddr::V4(_) => Err(EbpfError::IpVersionMismatch("IPv6".to_string()))?,
}
}
pub fn add_ipv4_http_service(&self, address: SocketAddrV4, http_method: Vec<HttpMethod>) -> Result<(), Error> {
self.ipv4_http_service.write().add_http_service(address, http_method)
fn require_v4_ip(ip: IpAddr) -> Result<std::net::Ipv4Addr, Error> {
match ip {
IpAddr::V4(a) => Ok(a),
IpAddr::V6(_) => Err(EbpfError::IpVersionMismatch("IPv4".to_string()))?,
}
}
pub fn add_ipv6_http_service(&self, address: SocketAddrV6, http_method: Vec<HttpMethod>) -> Result<(), Error> {
self.ipv6_http_service.write().add_http_service(address, http_method)
}
pub fn remove_ipv4_http_service(
&self,
address: SocketAddrV4,
removed_http_method: Vec<HttpMethod>,
) -> Result<(), Error> {
self.ipv4_http_service
.write()
.remove_http_service(address, removed_http_method)
}
pub fn remove_ipv6_http_service(
&self,
address: SocketAddrV6,
removed_http_method: Vec<HttpMethod>,
) -> Result<(), Error> {
self.ipv6_http_service
.write()
.remove_http_service(address, removed_http_method)
}
pub fn is_ssh_white_list_enable(&self) -> bool {
self.ssh_white_list_enable.read().is_white_list_enable()
}
pub fn enable_ssh_white_list(&self) -> Result<(), Error> {
self.ssh_white_list_enable.write().enable_white_list()
}
pub fn disable_ssh_white_list(&self) -> Result<(), Error> {
self.ssh_white_list_enable.write().disable_white_list()
}
pub fn get_ipv4_ssh_service(&self) -> Vec<SocketAddrV4> {
self.ipv4_ssh_service.read().get_all()
}
pub fn get_ipv6_ssh_service(&self) -> Vec<SocketAddrV6> {
self.ipv6_ssh_service.read().get_all()
}
pub fn add_ipv4_ssh_service(&self, address: SocketAddrV4) -> Result<(), Error> {
self.ipv4_ssh_service.write().add(address)
}
pub fn add_ipv6_ssh_service(&self, address: SocketAddrV6) -> Result<(), Error> {
self.ipv6_ssh_service.write().add(address)
}
pub fn remove_ipv4_ssh_service(&self, address: SocketAddrV4) -> Result<(), Error> {
self.ipv4_ssh_service.write().remove(address)
}
pub fn remove_ipv6_ssh_service(&self, address: SocketAddrV6) -> Result<(), Error> {
self.ipv6_ssh_service.write().remove(address)
}
pub fn get_ipv4_ssh_white_list(&self) -> Vec<Ipv4Addr> {
self.ipv4_ssh_white_list.read().get_all()
}
pub fn get_ipv6_ssh_white_list(&self) -> Vec<Ipv6Addr> {
self.ipv6_ssh_white_list.read().get_all()
}
pub fn add_ipv4_ssh_white_list(&self, ip: Ipv4Addr) -> Result<(), Error> {
self.ipv4_ssh_white_list.write().add(ip)
}
pub fn add_ipv6_ssh_white_list(&self, ip: Ipv6Addr) -> Result<(), Error> {
self.ipv6_ssh_white_list.write().add(ip)
}
pub fn remove_ipv4_ssh_white_list(&self, ip: Ipv4Addr) -> Result<(), Error> {
self.ipv4_ssh_white_list.write().remove(ip)
}
pub fn remove_ipv6_ssh_white_list(&self, ip: Ipv6Addr) -> Result<(), Error> {
self.ipv6_ssh_white_list.write().remove(ip)
}
pub fn get_ipv4_ssh_black_list(&self) -> Vec<Ipv4Addr> {
self.ipv4_ssh_black_list.read().get_all()
}
pub fn get_ipv6_ssh_black_list(&self) -> Vec<Ipv6Addr> {
self.ipv6_ssh_black_list.read().get_all()
}
pub fn add_ipv4_ssh_black_list(&self, ip: Ipv4Addr) -> Result<(), Error> {
self.ipv4_ssh_black_list.write().add(ip)
}
pub fn add_ipv6_ssh_black_list(&self, ip: Ipv6Addr) -> Result<(), Error> {
self.ipv6_ssh_black_list.write().add(ip)
}
pub fn remove_ipv4_ssh_black_list(&self, ip: Ipv4Addr) -> Result<(), Error> {
self.ipv4_ssh_black_list.write().remove(ip)
}
pub fn remove_ipv6_ssh_black_list(&self, ip: Ipv6Addr) -> Result<(), Error> {
self.ipv6_ssh_black_list.write().remove(ip)
fn require_v6_ip(ip: IpAddr) -> Result<std::net::Ipv6Addr, Error> {
match ip {
IpAddr::V6(a) => Ok(a),
IpAddr::V4(_) => Err(EbpfError::IpVersionMismatch("IPv6".to_string()))?,
}
}
impl ProtocolFilterPort for ProtocolFilter {
fn get_ipv4_http_service(&self) -> HashMap<SocketAddrV4, Vec<HttpMethod>> {
self.get_ipv4_http_service()
fn get_http_service(&self, version: IpVersion) -> HashMap<SocketAddr, Vec<HttpMethod>> {
match version {
IpVersion::V4 => self
.ipv4_http_service
.read()
.get_http_method()
.into_iter()
.map(|(k, v)| (SocketAddr::V4(k), v))
.collect(),
IpVersion::V6 => self
.ipv6_http_service
.read()
.get_http_method()
.into_iter()
.map(|(k, v)| (SocketAddr::V6(k), v))
.collect(),
}
}
fn get_ipv6_http_service(&self) -> HashMap<SocketAddrV6, Vec<HttpMethod>> {
self.get_ipv6_http_service()
fn add_http_service(&self, version: IpVersion, address: SocketAddr, methods: Vec<HttpMethod>) -> Result<(), Error> {
match version {
IpVersion::V4 => self
.ipv4_http_service
.write()
.add_http_service(require_v4_socket(address)?, methods),
IpVersion::V6 => self
.ipv6_http_service
.write()
.add_http_service(require_v6_socket(address)?, methods),
}
}
fn add_ipv4_http_service(&self, addr: SocketAddrV4, m: Vec<HttpMethod>) -> Result<(), Error> {
self.add_ipv4_http_service(addr, m)
}
fn add_ipv6_http_service(&self, addr: SocketAddrV6, m: Vec<HttpMethod>) -> Result<(), Error> {
self.add_ipv6_http_service(addr, m)
}
fn remove_ipv4_http_service(&self, addr: SocketAddrV4, m: Vec<HttpMethod>) -> Result<(), Error> {
self.remove_ipv4_http_service(addr, m)
}
fn remove_ipv6_http_service(&self, addr: SocketAddrV6, m: Vec<HttpMethod>) -> Result<(), Error> {
self.remove_ipv6_http_service(addr, m)
fn remove_http_service(
&self,
version: IpVersion,
address: SocketAddr,
methods: Vec<HttpMethod>,
) -> Result<(), Error> {
match version {
IpVersion::V4 => self
.ipv4_http_service
.write()
.remove_http_service(require_v4_socket(address)?, methods),
IpVersion::V6 => self
.ipv6_http_service
.write()
.remove_http_service(require_v6_socket(address)?, methods),
}
}
fn is_ssh_white_list_enable(&self) -> bool {
self.is_ssh_white_list_enable()
self.ssh_white_list_enable.read().is_white_list_enable()
}
fn enable_ssh_white_list(&self) -> Result<(), Error> {
self.enable_ssh_white_list()
self.ssh_white_list_enable.write().enable_white_list()
}
fn disable_ssh_white_list(&self) -> Result<(), Error> {
self.disable_ssh_white_list()
self.ssh_white_list_enable.write().disable_white_list()
}
fn get_ipv4_ssh_service(&self) -> Vec<SocketAddrV4> {
self.get_ipv4_ssh_service()
fn get_ssh_service(&self, version: IpVersion) -> Vec<SocketAddr> {
match version {
IpVersion::V4 => self
.ipv4_ssh_service
.read()
.get_all()
.into_iter()
.map(SocketAddr::V4)
.collect(),
IpVersion::V6 => self
.ipv6_ssh_service
.read()
.get_all()
.into_iter()
.map(SocketAddr::V6)
.collect(),
}
}
fn get_ipv6_ssh_service(&self) -> Vec<SocketAddrV6> {
self.get_ipv6_ssh_service()
fn add_ssh_service(&self, version: IpVersion, address: SocketAddr) -> Result<(), Error> {
match version {
IpVersion::V4 => self.ipv4_ssh_service.write().add(require_v4_socket(address)?),
IpVersion::V6 => self.ipv6_ssh_service.write().add(require_v6_socket(address)?),
}
}
fn add_ipv4_ssh_service(&self, addr: SocketAddrV4) -> Result<(), Error> {
self.add_ipv4_ssh_service(addr)
fn remove_ssh_service(&self, version: IpVersion, address: SocketAddr) -> Result<(), Error> {
match version {
IpVersion::V4 => self.ipv4_ssh_service.write().remove(require_v4_socket(address)?),
IpVersion::V6 => self.ipv6_ssh_service.write().remove(require_v6_socket(address)?),
}
}
fn add_ipv6_ssh_service(&self, addr: SocketAddrV6) -> Result<(), Error> {
self.add_ipv6_ssh_service(addr)
fn get_ssh_white_list(&self, version: IpVersion) -> Vec<IpAddr> {
match version {
IpVersion::V4 => self
.ipv4_ssh_white_list
.read()
.get_all()
.into_iter()
.map(IpAddr::V4)
.collect(),
IpVersion::V6 => self
.ipv6_ssh_white_list
.read()
.get_all()
.into_iter()
.map(IpAddr::V6)
.collect(),
}
}
fn remove_ipv4_ssh_service(&self, addr: SocketAddrV4) -> Result<(), Error> {
self.remove_ipv4_ssh_service(addr)
fn add_ssh_white_list(&self, version: IpVersion, ip: IpAddr) -> Result<(), Error> {
match version {
IpVersion::V4 => self.ipv4_ssh_white_list.write().add(require_v4_ip(ip)?),
IpVersion::V6 => self.ipv6_ssh_white_list.write().add(require_v6_ip(ip)?),
}
}
fn remove_ipv6_ssh_service(&self, addr: SocketAddrV6) -> Result<(), Error> {
self.remove_ipv6_ssh_service(addr)
fn remove_ssh_white_list(&self, version: IpVersion, ip: IpAddr) -> Result<(), Error> {
match version {
IpVersion::V4 => self.ipv4_ssh_white_list.write().remove(require_v4_ip(ip)?),
IpVersion::V6 => self.ipv6_ssh_white_list.write().remove(require_v6_ip(ip)?),
}
}
fn get_ipv4_ssh_white_list(&self) -> Vec<Ipv4Addr> {
self.get_ipv4_ssh_white_list()
fn get_ssh_black_list(&self, version: IpVersion) -> Vec<IpAddr> {
match version {
IpVersion::V4 => self
.ipv4_ssh_black_list
.read()
.get_all()
.into_iter()
.map(IpAddr::V4)
.collect(),
IpVersion::V6 => self
.ipv6_ssh_black_list
.read()
.get_all()
.into_iter()
.map(IpAddr::V6)
.collect(),
}
}
fn get_ipv6_ssh_white_list(&self) -> Vec<Ipv6Addr> {
self.get_ipv6_ssh_white_list()
fn add_ssh_black_list(&self, version: IpVersion, ip: IpAddr) -> Result<(), Error> {
match version {
IpVersion::V4 => self.ipv4_ssh_black_list.write().add(require_v4_ip(ip)?),
IpVersion::V6 => self.ipv6_ssh_black_list.write().add(require_v6_ip(ip)?),
}
}
fn add_ipv4_ssh_white_list(&self, ip: Ipv4Addr) -> Result<(), Error> {
self.add_ipv4_ssh_white_list(ip)
}
fn add_ipv6_ssh_white_list(&self, ip: Ipv6Addr) -> Result<(), Error> {
self.add_ipv6_ssh_white_list(ip)
}
fn remove_ipv4_ssh_white_list(&self, ip: Ipv4Addr) -> Result<(), Error> {
self.remove_ipv4_ssh_white_list(ip)
}
fn remove_ipv6_ssh_white_list(&self, ip: Ipv6Addr) -> Result<(), Error> {
self.remove_ipv6_ssh_white_list(ip)
}
fn get_ipv4_ssh_black_list(&self) -> Vec<Ipv4Addr> {
self.get_ipv4_ssh_black_list()
}
fn get_ipv6_ssh_black_list(&self) -> Vec<Ipv6Addr> {
self.get_ipv6_ssh_black_list()
}
fn add_ipv4_ssh_black_list(&self, ip: Ipv4Addr) -> Result<(), Error> {
self.add_ipv4_ssh_black_list(ip)
}
fn add_ipv6_ssh_black_list(&self, ip: Ipv6Addr) -> Result<(), Error> {
self.add_ipv6_ssh_black_list(ip)
}
fn remove_ipv4_ssh_black_list(&self, ip: Ipv4Addr) -> Result<(), Error> {
self.remove_ipv4_ssh_black_list(ip)
}
fn remove_ipv6_ssh_black_list(&self, ip: Ipv6Addr) -> Result<(), Error> {
self.remove_ipv6_ssh_black_list(ip)
fn remove_ssh_black_list(&self, version: IpVersion, ip: IpAddr) -> Result<(), Error> {
match version {
IpVersion::V4 => self.ipv4_ssh_black_list.write().remove(require_v4_ip(ip)?),
IpVersion::V6 => self.ipv6_ssh_black_list.write().remove(require_v6_ip(ip)?),
}
}
}

View File

@ -4,7 +4,7 @@ use parking_lot::Mutex;
use crate::domain::common::error::Error;
use crate::domain::data_plane::error::EbpfError;
use crate::interface::port::rate_limit_api::RateLimitPort;
use crate::interface::rate_limit_api::RateLimitPort;
pub struct RateLimitConfig {
config_map: Mutex<Option<Array<MapData, u64>>>,

View File

@ -26,8 +26,8 @@ use crate::domain::common::error::system::SystemError;
use crate::domain::data_plane::direction::Direction;
use crate::domain::data_plane::error::EbpfError;
use crate::domain::data_plane::log::EbpfLog;
use crate::interface::port::dns_query_filter::DnsQueryFilter;
use crate::interface::port::packet_sink::{PacketSink, PacketSinkFactory};
use crate::interface::dns_query_filter::DnsQueryFilter;
use crate::interface::packet_sink::{PacketSink, PacketSinkFactory};
use crate::utils::packet_parser::parse_packet;
/// Pre-allocated buffer pool to avoid per-packet malloc.

View File

@ -1,8 +1,9 @@
use actix_web::{HttpResponse, Scope, web};
use serde::Deserialize;
use crate::core::identity::extractor::AuthClaims;
use crate::interface::port::api_key::ApiKeyRepo;
use crate::adapter::http::middleware::extractor::AuthClaims;
use crate::domain::identity::auth::PermissionLevel;
use crate::interface::api_key::ApiKeyRepo;
pub fn initialize() -> Scope {
web::scope("/api-keys")
@ -16,13 +17,13 @@ async fn list_keys(_auth: AuthClaims, db: web::Data<dyn ApiKeyRepo>) -> HttpResp
Ok(keys) => {
let responses: Vec<serde_json::Value> = keys
.into_iter()
.map(|(id, name, level, created, last_used)| {
.map(|k| {
serde_json::json!({
"id": id,
"name": name,
"permission_level": level,
"created_at": created,
"last_used_at": last_used,
"id": k.id,
"name": k.name,
"permission_level": k.permission_level,
"created_at": k.created_at,
"last_used_at": k.last_used_at,
})
})
.collect();
@ -54,19 +55,19 @@ async fn generate_key(
let key_hash = db.hmac_api_key(&raw_key);
let level = body.level.as_deref().unwrap_or("read_only");
if !matches!(level, "read_only" | "read_write" | "full_access") {
let raw_level = body.level.as_deref().unwrap_or("read_only");
let Some(level) = PermissionLevel::from_str(raw_level) else {
return HttpResponse::BadRequest().json(serde_json::json!({
"error": "Invalid permission level. Must be: read_only, read_write, or full_access"
}));
}
};
match db.insert_api_key(&key_hash, &body.name, level) {
match db.insert_api_key(&key_hash, &body.name, level.as_str()) {
Ok(id) => HttpResponse::Created().json(serde_json::json!({
"id": id,
"key": raw_key,
"name": body.name,
"permission_level": level,
"permission_level": level.as_str(),
})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}

View File

@ -1,10 +1,10 @@
use actix_web::{HttpResponse, Scope, web};
use crate::adapter::http::middleware::extractor::AuthClaims;
use crate::adapter::persistence::Database;
use crate::core::identity::extractor::AuthClaims;
use crate::domain::common::error::Error;
use crate::domain::common::error::database::DatabaseError;
use crate::interface::port::audit::AuditRepo;
use crate::interface::audit::AuditRepo;
pub fn initialize() -> Scope {
web::scope("/audit")
@ -40,8 +40,8 @@ async fn list_audit_logs(_auth: AuthClaims, db: web::Data<Database>) -> HttpResp
/// integrity without shell access. Any mismatch returns the offending
/// row id inside `error` so the dashboard can link straight to it.
async fn verify_chain(_auth: AuthClaims, audit: web::Data<dyn AuditRepo>) -> HttpResponse {
match audit.verify_audit_log_chain() {
Ok(count) => HttpResponse::Ok().json(serde_json::json!({
match audit.verify_audit_log_chain(0) {
Ok((count, _last_id)) => HttpResponse::Ok().json(serde_json::json!({
"chain_intact": true,
"verified": count,
})),

View File

@ -1,17 +1,20 @@
use actix_web::{HttpResponse, Responder, Scope, web};
use macros::log;
use serde::Deserialize;
use crate::adapter::http::middleware::extractor::AuthClaims;
use crate::adapter::http::response::ok_or_error;
use crate::core::identity::extractor::AuthClaims;
use crate::core::identity::jwt::JwtService;
use crate::core::identity::auth_service::{AuthService, LoginError, RegisterError};
use crate::domain::identity::auth::{DEFAULT_ADMIN_USERNAME, GROUP_ADMIN, GROUP_VIEWER, ROLE_ADMIN, ROLE_VIEWER};
use crate::domain::identity::error::AuthError;
use crate::domain::identity::password;
use crate::interface::port::app_repo::AppRepo;
use crate::domain::identity::validation::validate_password;
use crate::interface::app_repo::AppRepo;
type Repo = dyn AppRepo;
fn parse_permissions(raw: &str) -> serde_json::Value {
serde_json::from_str(raw).unwrap_or(serde_json::json!([]))
}
#[derive(Deserialize)]
struct LoginRequest {
username: String,
@ -49,161 +52,47 @@ pub fn initialize() -> Scope {
.route("/groups/{id}", web::delete().to(delete_group))
}
fn validate_username(username: &str) -> Result<(), &'static str> {
if username.is_empty() || username.len() > 32 {
return Err("Username must be 1-32 characters");
}
if !username.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
return Err("Username must contain only alphanumeric characters and underscores");
}
Ok(())
}
fn validate_password(password: &str) -> Result<(), &'static str> {
if password.len() < 8 {
return Err("Password must be at least 8 characters");
}
Ok(())
}
/// Dummy Argon2 hash used to prevent timing-based username enumeration.
/// When a user doesn't exist, we still run verify_password against this
/// so the response time is indistinguishable from a real user lookup.
const DUMMY_HASH: &str = "$argon2id$v=19$m=19456,t=2,p=1$dW5rbm93bg$QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUE";
async fn login(body: web::Json<LoginRequest>, db: web::Data<Repo>, jwt: web::Data<JwtService>) -> impl Responder {
async fn login(body: web::Json<LoginRequest>, auth_svc: web::Data<AuthService>) -> impl Responder {
let req = body.into_inner();
// Check login lockout
match db.check_login_locked(&req.username) {
Ok(Some(remaining_secs)) => {
return HttpResponse::TooManyRequests().json(serde_json::json!({
"error": "Account temporarily locked due to too many failed login attempts",
"retry_after_secs": remaining_secs,
}));
}
Err(_) => {}
Ok(None) => {}
}
let user = match db.find_user(&req.username) {
Ok(Some(u)) => u,
_ => {
// Run dummy hash verification to prevent timing-based username enumeration
let _ = password::verify_password(&req.password, DUMMY_HASH);
if let Err(e) = db.record_login_failure(&req.username) {
log!(AuthError::LoginFailureTrackingError(e));
}
return HttpResponse::Unauthorized().json(serde_json::json!({"error": "Invalid credentials"}));
}
};
let (id, username, hash, _db_role, force_password_change) = user;
match password::verify_password(&req.password, &hash) {
Ok(true) => {}
_ => {
if let Err(e) = db.record_login_failure(&req.username) {
log!(AuthError::LoginFailureTrackingError(e));
}
return HttpResponse::Unauthorized().json(serde_json::json!({"error": "Invalid credentials"}));
}
}
// Clear login failures on success
if let Err(e) = db.clear_login_failures(&req.username) {
log!(AuthError::LoginClearError(e));
}
// Permissions come exclusively from groups — no role-based fallback
let permissions = db.list_user_permissions(id).unwrap_or_default();
let groups = db.list_groups_for_user(id).unwrap_or_default();
let role = if groups.iter().any(|(_id, name, _desc, _perms)| name == GROUP_ADMIN) {
ROLE_ADMIN.to_string()
} else {
ROLE_VIEWER.to_string()
};
match jwt.create_token(id, &username, &role, permissions) {
Ok(token) => HttpResponse::Ok().json(serde_json::json!({
"token": token,
"role": role,
"force_password_change": force_password_change,
match auth_svc.login(&req.username, &req.password) {
Ok(result) => HttpResponse::Ok().json(result),
Err(LoginError::Locked { retry_after_secs }) => HttpResponse::TooManyRequests().json(serde_json::json!({
"error": "Account temporarily locked due to too many failed login attempts",
"retry_after_secs": retry_after_secs,
})),
Err(_) => HttpResponse::InternalServerError().json(serde_json::json!({"error": "Failed to create token"})),
Err(LoginError::InvalidCredentials) => {
HttpResponse::Unauthorized().json(serde_json::json!({"error": "Invalid credentials"}))
}
Err(LoginError::InternalError) => {
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Failed to create token"}))
}
}
}
async fn register(auth: AuthClaims, body: web::Json<RegisterRequest>, db: web::Data<Repo>) -> impl Responder {
async fn register(
auth: AuthClaims,
body: web::Json<RegisterRequest>,
auth_svc: web::Data<AuthService>,
) -> impl Responder {
let reg = body.into_inner();
// Validate input
if let Err(msg) = validate_username(&reg.username) {
return HttpResponse::BadRequest().json(serde_json::json!({"error": msg}));
}
if let Err(msg) = validate_password(&reg.password) {
return HttpResponse::BadRequest().json(serde_json::json!({"error": msg}));
}
// Validate role
if reg.role != ROLE_ADMIN && reg.role != ROLE_VIEWER {
return HttpResponse::BadRequest().json(serde_json::json!({"error": "Role must be 'admin' or 'viewer'"}));
}
// Only admins can create admin accounts
if reg.role == ROLE_ADMIN && auth.role != ROLE_ADMIN {
return HttpResponse::Forbidden()
.json(serde_json::json!({"error": "Only administrators can create admin accounts"}));
}
let hash = match password::hash_password(&reg.password) {
Ok(h) => h,
Err(_) => {
return HttpResponse::InternalServerError().json(serde_json::json!({"error": "Failed to hash password"}));
match auth_svc.register(&reg.username, &reg.password, &reg.role, &auth.role) {
Ok(_) => HttpResponse::Created().json(serde_json::json!({"username": reg.username, "role": reg.role})),
Err(RegisterError::Validation(msg)) => HttpResponse::BadRequest().json(serde_json::json!({"error": msg})),
Err(RegisterError::InvalidRole) => {
HttpResponse::BadRequest().json(serde_json::json!({"error": "Role must be 'admin' or 'viewer'"}))
}
};
match db.insert_user(&reg.username, &hash, &reg.role, false) {
Ok(new_user_id) => {
// Auto-assign to default group based on role
let default_group_name = if reg.role == ROLE_ADMIN {
GROUP_ADMIN
} else {
GROUP_VIEWER
};
if let Ok(groups) = db.list_user_groups()
&& let Some((group_id, _, _, _, _)) =
groups.into_iter().find(|(_, name, _, _, _)| name == default_group_name)
&& let Err(e) = db.set_user_groups(new_user_id, &[group_id])
{
log!(AuthError::GroupAssignmentFailed(e));
}
HttpResponse::Created().json(serde_json::json!({"username": reg.username, "role": reg.role}))
Err(RegisterError::Forbidden) => HttpResponse::Forbidden()
.json(serde_json::json!({"error": "Only administrators can create admin accounts"})),
Err(RegisterError::HashFailed) => {
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Failed to hash password"}))
}
Err(e) => HttpResponse::Conflict().json(serde_json::json!({"error": e.to_string()})),
Err(RegisterError::Conflict(e)) => HttpResponse::Conflict().json(serde_json::json!({"error": e.to_string()})),
}
}
async fn me(auth: AuthClaims, db: web::Data<Repo>) -> impl Responder {
let user_groups = db.list_groups_for_user(auth.sub).unwrap_or_default();
let group_names: Vec<String> = user_groups
.iter()
.map(|(_id, name, _desc, _perms)| name.clone())
.collect();
let role = if group_names.iter().any(|n| n == GROUP_ADMIN) {
ROLE_ADMIN
} else {
ROLE_VIEWER
};
let permissions = db.list_user_permissions(auth.sub).unwrap_or_default();
HttpResponse::Ok().json(serde_json::json!({
"id": auth.sub,
"username": auth.username,
"role": role,
"permissions": permissions,
"groups": group_names,
}))
async fn me(auth: AuthClaims, auth_svc: web::Data<AuthService>) -> impl Responder {
let profile = auth_svc.user_profile(auth.sub, &auth.username);
HttpResponse::Ok().json(profile)
}
async fn change_password(
@ -211,32 +100,26 @@ async fn change_password(
body: web::Json<ChangePasswordRequest>,
db: web::Data<Repo>,
) -> impl Responder {
let claims = &*auth;
let change_req = body.into_inner();
// Validate new password
if let Err(msg) = validate_password(&change_req.new_password) {
return HttpResponse::BadRequest().json(serde_json::json!({"error": msg}));
}
// Verify current password
let user = match db.find_user(&claims.username) {
let user = match db.find_user(&auth.username) {
Ok(Some(u)) => u,
_ => {
return HttpResponse::InternalServerError().json(serde_json::json!({"error": "User not found"}));
}
};
let (_id, _username, hash, _role, _force) = user;
match password::verify_password(&change_req.current_password, &hash) {
match password::verify_password(&change_req.current_password, &user.password_hash) {
Ok(true) => {}
_ => {
return HttpResponse::Unauthorized().json(serde_json::json!({"error": "Current password is incorrect"}));
}
}
// Hash and update
let new_hash = match password::hash_password(&change_req.new_password) {
Ok(h) => h,
Err(_) => {
@ -244,35 +127,34 @@ async fn change_password(
}
};
match db.update_user_password(claims.sub, &new_hash) {
match db.update_user_password(auth.sub, &new_hash) {
Ok(_) => HttpResponse::Ok().json(serde_json::json!({"message": "Password changed successfully"})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
}
// --- User Management (admin only) ---
async fn list_users(_auth: AuthClaims, db: web::Data<Repo>) -> impl Responder {
match db.list_users_with_groups() {
Ok(users) => {
let result: Vec<serde_json::Value> = users
.into_iter()
.map(|(id, username, _role, force_pw, created_at, user_groups)| {
let groups: Vec<serde_json::Value> = user_groups
.map(|u| {
let groups: Vec<serde_json::Value> = u
.groups
.iter()
.map(|(gid, name)| serde_json::json!({"id": gid, "name": name}))
.map(|g| serde_json::json!({"id": g.group_id, "name": g.group_name}))
.collect();
let role = if user_groups.iter().any(|(_id, name)| name == GROUP_ADMIN) {
let role = if u.groups.iter().any(|g| g.group_name == GROUP_ADMIN) {
ROLE_ADMIN
} else {
ROLE_VIEWER
};
serde_json::json!({
"id": id,
"username": username,
"id": u.id,
"username": u.username,
"role": role,
"force_password_change": force_pw,
"created_at": created_at,
"force_password_change": u.force_password_change,
"created_at": u.created_at,
"groups": groups,
})
})
@ -286,14 +168,12 @@ async fn list_users(_auth: AuthClaims, db: web::Data<Repo>) -> impl Responder {
async fn delete_user(_auth: AuthClaims, path: web::Path<i64>, db: web::Data<Repo>) -> impl Responder {
let user_id = path.into_inner();
// Can't delete self
if _auth.sub == user_id {
return HttpResponse::BadRequest().json(serde_json::json!({"error": "Cannot delete your own account"}));
}
// Protect the built-in admin account
match db.find_user_by_id(user_id) {
Ok(Some((_, ref username, _, _, _))) if username == DEFAULT_ADMIN_USERNAME => {
Ok(Some(ref u)) if u.username == DEFAULT_ADMIN_USERNAME => {
return HttpResponse::Forbidden()
.json(serde_json::json!({"error": "Cannot delete the built-in admin account"}));
}
@ -315,7 +195,6 @@ async fn update_role(
) -> impl Responder {
let user_id = path.into_inner();
// Can't change own role
if _auth.sub == user_id {
return HttpResponse::BadRequest().json(serde_json::json!({"error": "Cannot change your own role"}));
}
@ -327,7 +206,6 @@ async fn update_role(
}
};
// Check target user exists
match db.find_user_by_id(user_id) {
Ok(Some(_)) => {}
Ok(None) => {
@ -367,7 +245,6 @@ async fn reset_password(
return HttpResponse::BadRequest().json(serde_json::json!({"error": msg}));
}
// Check target user exists
match db.find_user_by_id(user_id) {
Ok(Some(_)) => {}
Ok(None) => {
@ -388,27 +265,25 @@ async fn reset_password(
ok_or_error(db.reset_user_password(user_id, &hash))
}
// --- User Group Management (users:admin required) ---
async fn list_groups(_auth: AuthClaims, db: web::Data<Repo>) -> impl Responder {
match db.list_user_groups() {
Ok(groups) => {
let result: Vec<serde_json::Value> = groups
.into_iter()
.map(|(id, name, description, permissions, created_at)| {
let perms: serde_json::Value = serde_json::from_str(&permissions).unwrap_or(serde_json::json!([]));
.map(|g| {
let perms: serde_json::Value = parse_permissions(&g.permissions);
let members: Vec<serde_json::Value> = db
.list_group_members(id)
.list_group_members(g.id)
.unwrap_or_default()
.into_iter()
.map(|(uid, username)| serde_json::json!({"id": uid, "username": username}))
.map(|m| serde_json::json!({"id": m.id, "username": m.username}))
.collect();
serde_json::json!({
"id": id,
"name": name,
"description": description,
"id": g.id,
"name": g.name,
"description": g.description,
"permissions": perms,
"created_at": created_at,
"created_at": g.created_at,
"members": members,
})
})
@ -438,7 +313,7 @@ async fn create_group(_auth: AuthClaims, body: web::Json<serde_json::Value>, db:
"id": id,
"name": name,
"description": description,
"permissions": serde_json::from_str::<serde_json::Value>(&permissions).unwrap_or(serde_json::json!([])),
"permissions": parse_permissions(&permissions),
})),
Err(e) => HttpResponse::Conflict().json(serde_json::json!({"error": e.to_string()})),
}
@ -448,15 +323,15 @@ async fn get_group(_auth: AuthClaims, path: web::Path<i64>, db: web::Data<Repo>)
let group_id = path.into_inner();
match db.get_user_group(group_id) {
Ok(Some((id, name, description, permissions, created_at))) => {
let perms: serde_json::Value = serde_json::from_str(&permissions).unwrap_or(serde_json::json!([]));
Ok(Some(g)) => {
let perms: serde_json::Value = parse_permissions(&g.permissions);
let members = db.list_group_member_ids(group_id).unwrap_or_default();
HttpResponse::Ok().json(serde_json::json!({
"id": id,
"name": name,
"description": description,
"id": g.id,
"name": g.name,
"description": g.description,
"permissions": perms,
"created_at": created_at,
"created_at": g.created_at,
"members": members,
}))
}
@ -473,11 +348,9 @@ async fn update_group(
) -> impl Responder {
let group_id = path.into_inner();
// Check group exists
let existing = match db.get_user_group(group_id) {
Ok(Some(g)) => {
// Protect built-in groups
if g.1 == GROUP_ADMIN || g.1 == GROUP_VIEWER {
if g.name == GROUP_ADMIN || g.name == GROUP_VIEWER {
return HttpResponse::Forbidden().json(serde_json::json!({"error": "Cannot modify built-in groups"}));
}
g
@ -490,11 +363,14 @@ async fn update_group(
}
};
let name = body.get("name").and_then(|v| v.as_str()).unwrap_or(&existing.1);
let description = body.get("description").and_then(|v| v.as_str()).unwrap_or(&existing.2);
let name = body.get("name").and_then(|v| v.as_str()).unwrap_or(&existing.name);
let description = body
.get("description")
.and_then(|v| v.as_str())
.unwrap_or(&existing.description);
let permissions = match body.get("permissions") {
Some(p) if p.is_array() => p.to_string(),
_ => existing.3.clone(),
_ => existing.permissions.clone(),
};
match db.update_user_group(group_id, name, description, &permissions) {
@ -502,7 +378,7 @@ async fn update_group(
"id": group_id,
"name": name,
"description": description,
"permissions": serde_json::from_str::<serde_json::Value>(&permissions).unwrap_or(serde_json::json!([])),
"permissions": parse_permissions(&permissions),
})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
@ -511,9 +387,8 @@ async fn update_group(
async fn delete_group(_auth: AuthClaims, path: web::Path<i64>, db: web::Data<Repo>) -> impl Responder {
let group_id = path.into_inner();
// Protect built-in groups
match db.get_user_group(group_id) {
Ok(Some(g)) if g.1 == GROUP_ADMIN || g.1 == GROUP_VIEWER => {
Ok(Some(ref g)) if g.name == GROUP_ADMIN || g.name == GROUP_VIEWER => {
return HttpResponse::Forbidden().json(serde_json::json!({"error": "Cannot delete built-in groups"}));
}
_ => {}
@ -534,9 +409,8 @@ async fn set_user_groups(
) -> impl Responder {
let user_id = path.into_inner();
// Protect the default admin account
match db.find_user_by_id(user_id) {
Ok(Some((_, ref username, _, _, _))) if username == DEFAULT_ADMIN_USERNAME => {
Ok(Some(ref u)) if u.username == DEFAULT_ADMIN_USERNAME => {
return HttpResponse::Forbidden()
.json(serde_json::json!({"error": "Cannot modify groups for the built-in admin account"}));
}
@ -565,52 +439,13 @@ async fn set_user_groups(
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_username_valid() {
assert!(validate_username("admin").is_ok());
assert!(validate_username("user_123").is_ok());
assert!(validate_username("a").is_ok());
}
#[test]
fn test_validate_username_empty() {
assert!(validate_username("").is_err());
}
#[test]
fn test_validate_username_too_long() {
let long = "a".repeat(33);
assert!(validate_username(&long).is_err());
}
#[test]
fn test_validate_username_special_chars() {
assert!(validate_username("admin@host").is_err());
assert!(validate_username("user name").is_err());
assert!(validate_username("user-name").is_err());
assert!(validate_username("用戶").is_err());
}
#[test]
fn test_validate_password_valid() {
assert!(validate_password("12345678").is_ok());
assert!(validate_password("a very long password").is_ok());
}
#[test]
fn test_validate_password_too_short() {
assert!(validate_password("").is_err());
assert!(validate_password("1234567").is_err());
assert!(validate_password("a").is_err());
}
use crate::domain::identity::validation::{validate_password, validate_username};
#[test]
fn test_dummy_hash_is_valid_argon2() {
use argon2::password_hash::PasswordHash;
// DUMMY_HASH must be parseable as a valid Argon2 hash structure
// so that timing-based username enumeration is prevented
use crate::core::identity::auth_service::DUMMY_HASH;
let parsed = PasswordHash::new(DUMMY_HASH);
assert!(
parsed.is_ok(),
@ -618,4 +453,15 @@ mod tests {
parsed.err()
);
}
#[test]
fn test_validate_username_valid() {
assert!(validate_username("admin").is_ok());
assert!(validate_username("user_123").is_ok());
}
#[test]
fn test_validate_password_valid() {
assert!(validate_password("12345678").is_ok());
}
}

View File

@ -6,7 +6,7 @@
use actix_web::{HttpResponse, Scope, web};
use crate::core::identity::extractor::AuthClaims;
use crate::adapter::http::middleware::extractor::AuthClaims;
use crate::domain::detection::feature_extractor::feature_registry_names;
pub fn initialize() -> Scope {

View File

@ -1,4 +1,4 @@
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddrV4, SocketAddrV6};
use std::net::{IpAddr, SocketAddr};
use actix_web::{HttpResponse, Responder, Scope, web};
use common::model::http_method::HttpMethod;
@ -6,7 +6,7 @@ use serde::Deserialize;
use crate::adapter::http::response::ok_or_error;
use crate::core::data_plane::dns_filter_service::DnsFilterService;
use crate::interface::port::protocol_filter::ProtocolFilterPort;
use crate::interface::protocol_filter::{IpVersion, ProtocolFilterPort};
pub fn initialize() -> Scope {
web::scope("/filter")
@ -20,6 +20,14 @@ struct DnsDomainsRequest {
domains: Vec<String>,
}
fn parse_ip_version(path: &str) -> Option<IpVersion> {
match path {
"ipv4" => Some(IpVersion::V4),
"ipv6" => Some(IpVersion::V6),
_ => None,
}
}
fn dns_scope() -> Scope {
web::scope("/dns").service(
web::scope("/blacklist")
@ -57,22 +65,16 @@ async fn remove_dns_blacklist(
fn http_scope() -> Scope {
web::scope("/http")
.route("/ipv4", web::get().to(get_ipv4_http_service))
.route("/ipv6", web::get().to(get_ipv6_http_service))
.route("/ipv4", web::put().to(add_ipv4_http_service))
.route("/ipv6", web::put().to(add_ipv6_http_service))
.route("/ipv4", web::delete().to(remove_ipv4_http_service))
.route("/ipv6", web::delete().to(remove_ipv6_http_service))
.route("/{version}", web::get().to(get_http_service))
.route("/{version}", web::put().to(add_http_service))
.route("/{version}", web::delete().to(remove_http_service))
}
fn ssh_scope() -> Scope {
web::scope("/ssh")
.route("/ipv4", web::get().to(get_ipv4_ssh_service))
.route("/ipv6", web::get().to(get_ipv6_ssh_service))
.route("/ipv4", web::put().to(add_ipv4_ssh_service))
.route("/ipv6", web::put().to(add_ipv6_ssh_service))
.route("/ipv4", web::delete().to(remove_ipv4_ssh_service))
.route("/ipv6", web::delete().to(remove_ipv6_ssh_service))
.route("/{version}", web::get().to(get_ssh_service))
.route("/{version}", web::put().to(add_ssh_service))
.route("/{version}", web::delete().to(remove_ssh_service))
.service(ssh_whitelist_scope())
.service(ssh_blacklist_scope())
}
@ -82,106 +84,78 @@ fn ssh_whitelist_scope() -> Scope {
.route("/status", web::get().to(is_ssh_white_list_enable))
.route("/enable", web::post().to(enable_ssh_white_list))
.route("/disable", web::post().to(disable_ssh_white_list))
.route("/ipv4", web::get().to(get_ipv4_ssh_white_list))
.route("/ipv6", web::get().to(get_ipv6_ssh_white_list))
.route("/ipv4", web::put().to(add_ipv4_ssh_white_list))
.route("/ipv6", web::put().to(add_ipv6_ssh_white_list))
.route("/ipv4", web::delete().to(remove_ipv4_ssh_white_list))
.route("/ipv6", web::delete().to(remove_ipv6_ssh_white_list))
.route("/{version}", web::get().to(get_ssh_white_list))
.route("/{version}", web::put().to(add_ssh_white_list))
.route("/{version}", web::delete().to(remove_ssh_white_list))
}
fn ssh_blacklist_scope() -> Scope {
web::scope("/blacklist")
.route("/ipv4", web::get().to(get_ipv4_ssh_black_list))
.route("/ipv6", web::get().to(get_ipv6_ssh_black_list))
.route("/ipv4", web::put().to(add_ipv4_ssh_black_list))
.route("/ipv6", web::put().to(add_ipv6_ssh_black_list))
.route("/ipv4", web::delete().to(remove_ipv4_ssh_black_list))
.route("/ipv6", web::delete().to(remove_ipv6_ssh_black_list))
.route("/{version}", web::get().to(get_ssh_black_list))
.route("/{version}", web::put().to(add_ssh_black_list))
.route("/{version}", web::delete().to(remove_ssh_black_list))
}
// --- HTTP service handlers ---
async fn get_ipv4_http_service(service: web::Data<dyn ProtocolFilterPort>) -> impl Responder {
HttpResponse::Ok().json(service.get_ipv4_http_service())
async fn get_http_service(path: web::Path<String>, service: web::Data<dyn ProtocolFilterPort>) -> impl Responder {
let Some(version) = parse_ip_version(&path) else {
return HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid IP version"}));
};
HttpResponse::Ok().json(service.get_http_service(version))
}
async fn get_ipv6_http_service(service: web::Data<dyn ProtocolFilterPort>) -> impl Responder {
HttpResponse::Ok().json(service.get_ipv6_http_service())
}
async fn add_ipv4_http_service(
payload: web::Json<(SocketAddrV4, Vec<HttpMethod>)>,
async fn add_http_service(
path: web::Path<String>,
payload: web::Json<(SocketAddr, Vec<HttpMethod>)>,
service: web::Data<dyn ProtocolFilterPort>,
) -> impl Responder {
let Some(version) = parse_ip_version(&path) else {
return HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid IP version"}));
};
let (addr, methods) = payload.into_inner();
ok_or_error(service.add_ipv4_http_service(addr, methods))
ok_or_error(service.add_http_service(version, addr, methods))
}
async fn add_ipv6_http_service(
payload: web::Json<(SocketAddrV6, Vec<HttpMethod>)>,
async fn remove_http_service(
path: web::Path<String>,
payload: web::Json<(SocketAddr, Vec<HttpMethod>)>,
service: web::Data<dyn ProtocolFilterPort>,
) -> impl Responder {
let Some(version) = parse_ip_version(&path) else {
return HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid IP version"}));
};
let (addr, methods) = payload.into_inner();
ok_or_error(service.add_ipv6_http_service(addr, methods))
ok_or_error(service.remove_http_service(version, addr, methods))
}
async fn remove_ipv4_http_service(
payload: web::Json<(SocketAddrV4, Vec<HttpMethod>)>,
async fn get_ssh_service(path: web::Path<String>, service: web::Data<dyn ProtocolFilterPort>) -> impl Responder {
let Some(version) = parse_ip_version(&path) else {
return HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid IP version"}));
};
HttpResponse::Ok().json(service.get_ssh_service(version))
}
async fn add_ssh_service(
path: web::Path<String>,
payload: web::Json<SocketAddr>,
service: web::Data<dyn ProtocolFilterPort>,
) -> impl Responder {
let (addr, methods) = payload.into_inner();
ok_or_error(service.remove_ipv4_http_service(addr, methods))
let Some(version) = parse_ip_version(&path) else {
return HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid IP version"}));
};
ok_or_error(service.add_ssh_service(version, payload.into_inner()))
}
async fn remove_ipv6_http_service(
payload: web::Json<(SocketAddrV6, Vec<HttpMethod>)>,
async fn remove_ssh_service(
path: web::Path<String>,
payload: web::Json<SocketAddr>,
service: web::Data<dyn ProtocolFilterPort>,
) -> impl Responder {
let (addr, methods) = payload.into_inner();
ok_or_error(service.remove_ipv6_http_service(addr, methods))
let Some(version) = parse_ip_version(&path) else {
return HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid IP version"}));
};
ok_or_error(service.remove_ssh_service(version, payload.into_inner()))
}
// --- SSH service handlers ---
async fn get_ipv4_ssh_service(service: web::Data<dyn ProtocolFilterPort>) -> impl Responder {
HttpResponse::Ok().json(service.get_ipv4_ssh_service())
}
async fn get_ipv6_ssh_service(service: web::Data<dyn ProtocolFilterPort>) -> impl Responder {
HttpResponse::Ok().json(service.get_ipv6_ssh_service())
}
async fn add_ipv4_ssh_service(
ip_addr: web::Json<SocketAddrV4>,
service: web::Data<dyn ProtocolFilterPort>,
) -> impl Responder {
ok_or_error(service.add_ipv4_ssh_service(ip_addr.into_inner()))
}
async fn add_ipv6_ssh_service(
ip_addr: web::Json<SocketAddrV6>,
service: web::Data<dyn ProtocolFilterPort>,
) -> impl Responder {
ok_or_error(service.add_ipv6_ssh_service(ip_addr.into_inner()))
}
async fn remove_ipv4_ssh_service(
ip_addr: web::Json<SocketAddrV4>,
service: web::Data<dyn ProtocolFilterPort>,
) -> impl Responder {
ok_or_error(service.remove_ipv4_ssh_service(ip_addr.into_inner()))
}
async fn remove_ipv6_ssh_service(
ip_addr: web::Json<SocketAddrV6>,
service: web::Data<dyn ProtocolFilterPort>,
) -> impl Responder {
ok_or_error(service.remove_ipv6_ssh_service(ip_addr.into_inner()))
}
// --- SSH whitelist handlers ---
async fn is_ssh_white_list_enable(service: web::Data<dyn ProtocolFilterPort>) -> impl Responder {
HttpResponse::Ok().json(service.is_ssh_white_list_enable())
}
@ -194,76 +168,60 @@ async fn disable_ssh_white_list(service: web::Data<dyn ProtocolFilterPort>) -> i
ok_or_error(service.disable_ssh_white_list())
}
async fn get_ipv4_ssh_white_list(service: web::Data<dyn ProtocolFilterPort>) -> impl Responder {
HttpResponse::Ok().json(service.get_ipv4_ssh_white_list())
async fn get_ssh_white_list(path: web::Path<String>, service: web::Data<dyn ProtocolFilterPort>) -> impl Responder {
let Some(version) = parse_ip_version(&path) else {
return HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid IP version"}));
};
HttpResponse::Ok().json(service.get_ssh_white_list(version))
}
async fn get_ipv6_ssh_white_list(service: web::Data<dyn ProtocolFilterPort>) -> impl Responder {
HttpResponse::Ok().json(service.get_ipv6_ssh_white_list())
}
async fn add_ipv4_ssh_white_list(
ip_addr: web::Json<Ipv4Addr>,
async fn add_ssh_white_list(
path: web::Path<String>,
payload: web::Json<IpAddr>,
service: web::Data<dyn ProtocolFilterPort>,
) -> impl Responder {
ok_or_error(service.add_ipv4_ssh_white_list(ip_addr.into_inner()))
let Some(version) = parse_ip_version(&path) else {
return HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid IP version"}));
};
ok_or_error(service.add_ssh_white_list(version, payload.into_inner()))
}
async fn add_ipv6_ssh_white_list(
ip_addr: web::Json<Ipv6Addr>,
async fn remove_ssh_white_list(
path: web::Path<String>,
payload: web::Json<IpAddr>,
service: web::Data<dyn ProtocolFilterPort>,
) -> impl Responder {
ok_or_error(service.add_ipv6_ssh_white_list(ip_addr.into_inner()))
let Some(version) = parse_ip_version(&path) else {
return HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid IP version"}));
};
ok_or_error(service.remove_ssh_white_list(version, payload.into_inner()))
}
async fn remove_ipv4_ssh_white_list(
ip_addr: web::Json<Ipv4Addr>,
async fn get_ssh_black_list(path: web::Path<String>, service: web::Data<dyn ProtocolFilterPort>) -> impl Responder {
let Some(version) = parse_ip_version(&path) else {
return HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid IP version"}));
};
HttpResponse::Ok().json(service.get_ssh_black_list(version))
}
async fn add_ssh_black_list(
path: web::Path<String>,
payload: web::Json<IpAddr>,
service: web::Data<dyn ProtocolFilterPort>,
) -> impl Responder {
ok_or_error(service.remove_ipv4_ssh_white_list(ip_addr.into_inner()))
let Some(version) = parse_ip_version(&path) else {
return HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid IP version"}));
};
ok_or_error(service.add_ssh_black_list(version, payload.into_inner()))
}
async fn remove_ipv6_ssh_white_list(
ip_addr: web::Json<Ipv6Addr>,
async fn remove_ssh_black_list(
path: web::Path<String>,
payload: web::Json<IpAddr>,
service: web::Data<dyn ProtocolFilterPort>,
) -> impl Responder {
ok_or_error(service.remove_ipv6_ssh_white_list(ip_addr.into_inner()))
}
// --- SSH blacklist handlers ---
async fn get_ipv4_ssh_black_list(service: web::Data<dyn ProtocolFilterPort>) -> impl Responder {
HttpResponse::Ok().json(service.get_ipv4_ssh_black_list())
}
async fn get_ipv6_ssh_black_list(service: web::Data<dyn ProtocolFilterPort>) -> impl Responder {
HttpResponse::Ok().json(service.get_ipv6_ssh_black_list())
}
async fn add_ipv4_ssh_black_list(
ip_addr: web::Json<Ipv4Addr>,
service: web::Data<dyn ProtocolFilterPort>,
) -> impl Responder {
ok_or_error(service.add_ipv4_ssh_black_list(ip_addr.into_inner()))
}
async fn add_ipv6_ssh_black_list(
ip_addr: web::Json<Ipv6Addr>,
service: web::Data<dyn ProtocolFilterPort>,
) -> impl Responder {
ok_or_error(service.add_ipv6_ssh_black_list(ip_addr.into_inner()))
}
async fn remove_ipv4_ssh_black_list(
ip_addr: web::Json<Ipv4Addr>,
service: web::Data<dyn ProtocolFilterPort>,
) -> impl Responder {
ok_or_error(service.remove_ipv4_ssh_black_list(ip_addr.into_inner()))
}
async fn remove_ipv6_ssh_black_list(
ip_addr: web::Json<Ipv6Addr>,
service: web::Data<dyn ProtocolFilterPort>,
) -> impl Responder {
ok_or_error(service.remove_ipv6_ssh_black_list(ip_addr.into_inner()))
let Some(version) = parse_ip_version(&path) else {
return HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid IP version"}));
};
ok_or_error(service.remove_ssh_black_list(version, payload.into_inner()))
}

View File

@ -7,15 +7,14 @@
//! analysts can answer "why was this IP blocked?" without parsing
//! logs by hand.
use std::sync::Arc;
use actix_web::{HttpRequest, HttpResponse, Responder, Scope, web};
use arc_swap::ArcSwap;
use crate::core::detection::metrics::FusionMetrics;
use crate::domain::common::audit::AuditLogEntry;
use crate::domain::common::config::AppConfig;
use crate::domain::common::config::constants::FUSION_AUDIT_ACTION;
use crate::domain::detection::metrics::FusionMetrics;
use crate::interface::port::audit::{AuditLogEntry, AuditRepo};
use crate::interface::audit::AuditRepo;
pub fn initialize() -> Scope {
web::scope("/fusion")
@ -37,7 +36,7 @@ async fn get_metrics(metrics: web::Data<FusionMetrics>) -> impl Responder {
async fn explain_ip(
req: HttpRequest,
audit: web::Data<dyn AuditRepo>,
app_config: web::Data<Arc<ArcSwap<AppConfig>>>,
app_config: web::Data<ArcSwap<AppConfig>>,
) -> impl Responder {
let src_ip = match req.match_info().get("src_ip") {
Some(ip) => ip.to_string(),

View File

@ -6,7 +6,8 @@ use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, deco
use crate::domain::common::error::Error;
use crate::domain::identity::auth::Claims;
use crate::domain::identity::error::AuthError;
use crate::interface::port::secret_store::SecretStorePort;
use crate::interface::secret_store::SecretStorePort;
use crate::interface::token_minter::TokenMinter;
pub struct JwtService {
encoding_key: EncodingKey,
@ -63,6 +64,18 @@ impl JwtService {
}
}
impl TokenMinter for JwtService {
fn create_token(
&self,
user_id: i64,
username: &str,
role: &str,
permissions: Vec<String>,
) -> Result<String, Error> {
self.create_token(user_id, username, role, permissions)
}
}
#[cfg(test)]
mod tests {
use super::*;

View File

@ -1,15 +1,14 @@
use std::fs;
use std::io::ErrorKind;
use std::path::Path;
use std::sync::Arc;
use std::time::UNIX_EPOCH;
use actix_web::{HttpResponse, Scope, web};
use arc_swap::ArcSwap;
use serde::{Deserialize, Serialize};
use crate::core::common::log_buffer::{self, LogBuffer, LogEntry};
use crate::domain::common::config::AppConfig;
use crate::infrastructure::log_buffer::{self, LogBuffer, LogEntry};
/// Hardcoded log directory — not configurable via API to prevent directory traversal.
const LOG_DIR: &str = "logs";
@ -51,7 +50,7 @@ struct LiveResponse {
async fn live_logs(
query: web::Query<LiveQuery>,
app_config: web::Data<Arc<ArcSwap<AppConfig>>>,
app_config: web::Data<ArcSwap<AppConfig>>,
buf: web::Data<LogBuffer>,
) -> HttpResponse {
let since_id = query.since_id.unwrap_or(0);
@ -117,7 +116,7 @@ async fn list_logs() -> HttpResponse {
HttpResponse::Ok().json(serde_json::json!({ "files": entries }))
}
async fn download_log(path: web::Path<String>, app_config: web::Data<Arc<ArcSwap<AppConfig>>>) -> HttpResponse {
async fn download_log(path: web::Path<String>, app_config: web::Data<ArcSwap<AppConfig>>) -> HttpResponse {
let max_download_size = app_config.load().observability.log_max_download_size;
let filename = path.into_inner();

View File

@ -0,0 +1,218 @@
use std::future::{Future, Ready, ready};
use std::pin::Pin;
use std::rc::Rc;
use std::task::{Context, Poll};
use actix_web::body::EitherBody;
use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform};
use actix_web::http::Method;
use actix_web::{Error as ActixError, HttpMessage, HttpResponse, web};
use macros::log;
use crate::adapter::http::jwt::JwtService;
use crate::domain::identity::error::AuthError;
use crate::interface::api_key::ApiKeyRepo;
use crate::interface::app_repo::AppRepo;
pub struct AuthMiddleware;
impl<S, B> Transform<S, ServiceRequest> for AuthMiddleware
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
B: 'static,
{
type Response = ServiceResponse<EitherBody<B>>;
type Error = ActixError;
type Transform = AuthMiddlewareService<S>;
type InitError = ();
type Future = Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future {
ready(Ok(AuthMiddlewareService {
service: Rc::new(service),
}))
}
}
pub struct AuthMiddlewareService<S> {
service: Rc<S>,
}
fn required_permission(path: &str, method: &Method) -> Option<String> {
let resource = if path == "/api/auth/login" || path == "/api/auth/me" || path == "/api/auth/change-password" {
return None;
} else if path.starts_with("/api/auth/") {
return Some("users:admin".to_string());
} else if path.starts_with("/api/health/") || path.starts_with("/api/stats/") {
"dashboard"
} else if path.starts_with("/api/ml/") || path.starts_with("/api/byo/") {
"ai_detection"
} else if path.starts_with("/api/fusion/") {
"fusion"
} else if path.starts_with("/api/flow-trace/") {
"flow_trace"
} else if path.starts_with("/api/acl/geo/") {
"geo_block"
} else if path.starts_with("/api/acl/") {
"access_control"
} else if path.starts_with("/api/filter/dns/") {
"dns_filter"
} else if path.starts_with("/api/filter/http/") || path.starts_with("/api/filter/ssh/") {
"protocol_filter"
} else if path.starts_with("/api/rate-limit/") {
"rate_limit"
} else if path.starts_with("/api/system/") {
"system"
} else if path.contains("/soar/blocks/") && path.ends_with("/unblock") {
return Some("access_control:write".to_string());
} else if path.starts_with("/api/soar/")
|| path.starts_with("/api/notifications/")
|| path.starts_with("/api/report/")
|| path.starts_with("/api/api-keys/")
|| path.starts_with("/api/logs/")
|| path.starts_with("/api/audit/")
{
"system"
} else {
return None;
};
let action = match *method {
Method::GET => "read",
_ => "write",
};
Some(format!("{}:{}", resource, action))
}
impl<S, B> Service<ServiceRequest> for AuthMiddlewareService<S>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
B: 'static,
{
type Response = ServiceResponse<EitherBody<B>>;
type Error = ActixError;
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>>>>;
fn poll_ready(&self, ctx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.service.poll_ready(ctx)
}
fn call(&self, req: ServiceRequest) -> Self::Future {
let service = Rc::clone(&self.service);
Box::pin(async move {
let path = req.path().to_string();
// Skip auth for public endpoints
if path == "/api/auth/login" || path.starts_with("/api/setup/") || !path.starts_with("/api/") {
let res = service.call(req).await?.map_into_left_body();
return Ok(res);
}
// Extract JWT service from app data
let jwt_service = match req.app_data::<web::Data<JwtService>>() {
Some(s) => s.clone(),
None => {
let resp =
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Auth not configured"}));
return Ok(req.into_response(resp).map_into_right_body());
}
};
// Try JWT first, then fall back to API key
let claims = if let Some(auth_header) = req.headers().get("Authorization") {
// JWT Bearer token auth
let val_str = auth_header.to_str().unwrap_or("");
let token = match val_str.strip_prefix("Bearer ") {
Some(t) => t,
None => {
let resp = HttpResponse::Unauthorized()
.json(serde_json::json!({"error": "Invalid authorization header"}));
return Ok(req.into_response(resp).map_into_right_body());
}
};
match jwt_service.validate_token(token) {
Ok(c) => c,
Err(_) => {
let resp =
HttpResponse::Unauthorized().json(serde_json::json!({"error": "Invalid or expired token"}));
return Ok(req.into_response(resp).map_into_right_body());
}
}
} 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 ApiKeyRepo>>() {
Some(d) => d.clone(),
None => {
let resp = HttpResponse::InternalServerError()
.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 AppRepo>>() {
Some(d) => d.clone(),
None => {
let resp = HttpResponse::InternalServerError()
.json(serde_json::json!({"error": "AppRepo not configured"}));
return Ok(req.into_response(resp).map_into_right_body());
}
};
// Rate limit check for API key attempts (reuse login failure tracking)
let rate_key = format!(
"apikey:{}",
req.peer_addr().map(|a| a.ip().to_string()).unwrap_or_default()
);
if let Ok(Some(remaining)) = repo.check_login_locked(&rate_key) {
let resp = HttpResponse::TooManyRequests().json(serde_json::json!({
"error": "Too many failed API key attempts",
"retry_after_secs": remaining,
}));
return Ok(req.into_response(resp).map_into_right_body());
}
match api_key_port.validate_api_key(api_key) {
Ok(Some(key_claims)) => {
if let Err(e) = repo.clear_login_failures(&rate_key) {
log!(AuthError::LoginClearError(e));
}
key_claims
}
Ok(None) => {
if let Err(e) = repo.record_login_failure(&rate_key) {
log!(AuthError::LoginFailureTrackingError(e));
}
let resp = HttpResponse::Unauthorized()
.json(serde_json::json!({"error": "Invalid or revoked API key"}));
return Ok(req.into_response(resp).map_into_right_body());
}
Err(_) => {
let resp = HttpResponse::InternalServerError()
.json(serde_json::json!({"error": "API key validation failed"}));
return Ok(req.into_response(resp).map_into_right_body());
}
}
} else {
let resp =
HttpResponse::Unauthorized().json(serde_json::json!({"error": "Missing authorization header"}));
return Ok(req.into_response(resp).map_into_right_body());
};
// Permission-based RBAC check
if let Some(required) = required_permission(&path, req.method())
&& !claims.permissions.contains(&required)
{
let resp = HttpResponse::Forbidden().json(serde_json::json!({"error": "Insufficient permissions"}));
return Ok(req.into_response(resp).map_into_right_body());
}
// Store claims in request extensions
req.extensions_mut().insert(claims);
let res = service.call(req).await?.map_into_left_body();
Ok(res)
})
}
}

View File

@ -2,8 +2,7 @@ use std::future::{Future, Ready, ready};
use std::net::IpAddr;
use std::pin::Pin;
use std::rc::Rc;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::atomic::Ordering;
use std::task::{Context, Poll};
use actix_web::body::EitherBody;
@ -11,8 +10,7 @@ use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform};
use actix_web::http::header;
use actix_web::{Error as ActixError, HttpResponse, web};
/// Shared flag: when true, non-HTTPS requests are redirected.
pub type ForceHttpsFlag = Arc<AtomicBool>;
use crate::infrastructure::http_server::ForceHttpsFlag;
/// Validate that the host is safe to use in a redirect Location header.
/// Only allows: private IPs (RFC 1918), loopback, .local hostnames, and bare hostnames
@ -100,7 +98,7 @@ where
// Check if force_https is enabled
let force = req
.app_data::<web::Data<ForceHttpsFlag>>()
.map(|flag| flag.load(Ordering::Relaxed))
.map(|flag| flag.0.load(Ordering::Relaxed))
.unwrap_or(false);
if !force {

View File

@ -9,7 +9,7 @@ use actix_web::http::Method;
use actix_web::{Error as ActixError, HttpMessage, HttpResponse, web};
use macros::log;
use crate::core::identity::jwt::JwtService;
use crate::adapter::http::middleware::jwt::JwtService;
use crate::domain::identity::error::AuthError;
use crate::interface::port::api_key::ApiKeyRepo;
use crate::interface::port::app_repo::AppRepo;
@ -40,9 +40,8 @@ pub struct AuthMiddlewareService<S> {
fn required_permission(path: &str, method: &Method) -> Option<String> {
let resource = if path == "/api/auth/login" || path == "/api/auth/me" || path == "/api/auth/change-password" {
return None; // Public auth endpoints: login (no auth), me/change-password (auth-only, no RBAC)
return None;
} else if path.starts_with("/api/auth/") {
// User/group management requires users:admin
return Some("users:admin".to_string());
} else if path.starts_with("/api/health/") || path.starts_with("/api/stats/") {
"dashboard"
@ -65,7 +64,6 @@ fn required_permission(path: &str, method: &Method) -> Option<String> {
} else if path.starts_with("/api/system/") {
"system"
} else if path.contains("/soar/blocks/") && path.ends_with("/unblock") {
// manual_unblock needs access_control:write (always POST)
return Some("access_control:write".to_string());
} else if path.starts_with("/api/soar/")
|| path.starts_with("/api/notifications/")

View File

@ -0,0 +1,5 @@
pub mod auth;
pub mod csrf;
pub mod extractor;
pub mod https_redirect;
pub mod setup_guard;

View File

@ -1,17 +1,14 @@
use std::future::{Future, Ready, ready};
use std::pin::Pin;
use std::rc::Rc;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::atomic::Ordering;
use std::task::{Context, Poll};
use actix_web::body::EitherBody;
use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform};
use actix_web::{Error as ActixError, HttpResponse, web};
/// Shared flag indicating whether setup has completed.
/// When false, only setup wizard routes are allowed; all others get 503.
pub type SetupCompleteFlag = Arc<AtomicBool>;
use crate::infrastructure::http_server::SetupCompleteFlag;
pub struct SetupGuard;
@ -59,8 +56,8 @@ where
// Check setup_complete flag from app data
let setup_complete = req
.app_data::<web::Data<SetupCompleteFlag>>()
.map(|flag| flag.load(Ordering::SeqCst))
.unwrap_or(true); // Default to true if flag not found
.map(|flag| flag.0.load(Ordering::SeqCst))
.unwrap_or(true);
if setup_complete {
// Normal mode: pass through, but block setup mutation endpoints.

View File

@ -1,12 +1,12 @@
use actix_web::{HttpResponse, Responder, Scope, web};
use tokio::sync::broadcast;
use crate::core::identity::extractor::AuthClaims;
use crate::adapter::http::middleware::extractor::AuthClaims;
use crate::core::inference::engine::Engine;
use crate::core::inference::model_adapter::ModelSourceState;
use crate::core::inference::runner::Inference;
use crate::domain::common::config::constants::AUDIT_ACTOR_SECURITY_ADMIN_PREFIX;
use crate::domain::common::event::AuditEvent;
use crate::domain::detection::model_adapter::ModelSourceState;
/// Permission required to forcibly revert the active ML source to dormant.
/// Mirrors the upload handler's gate so swap-out and revert are symmetric:
@ -44,7 +44,7 @@ async fn get_status(engine: web::Data<Engine>) -> impl Responder {
/// `GET /api/ml/models/current` — wire-format snapshot of the ML source
/// state the dashboard's ML Status panel renders.
async fn get_current_model(inference: web::Data<Inference>) -> impl Responder {
let status = inference.current_status();
let status = inference.model_source_status();
let label = if status.is_active() {
"active"
} else if status.is_dormant() {
@ -74,7 +74,7 @@ async fn delete_current_model(
}));
}
let before_status = inference.current_status();
let before_status = inference.model_source_status();
if before_status.is_dormant() {
return HttpResponse::Ok().json(serde_json::json!({
"already_dormant": true,

View File

@ -8,11 +8,14 @@ pub mod filter;
pub mod flow_trace;
pub mod fusion;
pub mod health;
pub mod jwt;
pub mod logs;
pub mod middleware;
pub mod ml;
pub mod model_upload;
pub mod notification;
pub mod rate_limit;
pub mod ready;
pub mod report;
pub mod response;
pub mod setup;

View File

@ -15,13 +15,12 @@
//! down on any error path so failed uploads don't pile up in
//! `models/.staging/`.
use std::fs as std_fs;
use std::fs::File as StdFile;
use std::io;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, SystemTime};
use std::time::Duration;
use actix_multipart::Multipart;
use actix_web::{HttpResponse, Responder, Scope, web};
@ -35,7 +34,7 @@ use tokio::sync::broadcast;
use tokio::task;
use uuid::Uuid;
use crate::core::identity::extractor::AuthClaims;
use crate::adapter::http::middleware::extractor::AuthClaims;
use crate::core::inference::model_loader::build_adapter;
use crate::core::inference::runner::Inference;
use crate::domain::common::config::AppConfig;
@ -583,7 +582,7 @@ async fn validate_and_promote(ctx: &PromoteContext<'_>) -> Result<PromoteReport,
.await
.map_err(|e| PromoteError::StagingIo(format!("sha256 onnx: {e}")))?;
let before_status = inference.current_status();
let before_status = inference.model_source_status();
let _guard = promote_lock.try_acquire().ok_or(PromoteError::ConcurrentPromote)?;
let models_dir = PathBuf::from(MODELS_DIR);
@ -705,36 +704,12 @@ async fn sha256_file(path: &Path) -> io::Result<String> {
.unwrap_or_else(|e| Err(io::Error::other(format!("sha256 join: {e}"))))
}
/// Remove staging subdirectories older than `max_age`. Runs on startup
/// and on a periodic timer so failed uploads don't accumulate.
pub fn clean_staging_orphans(staging_root: &Path, max_age: Duration) -> io::Result<usize> {
if !staging_root.exists() {
return Ok(0);
}
let now = SystemTime::now();
let mut cleaned = 0usize;
for entry in std_fs::read_dir(staging_root)? {
let entry = entry?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let metadata = entry.metadata()?;
let mtime = metadata.modified()?;
let age = now.duration_since(mtime).unwrap_or_default();
if age >= max_age {
std_fs::remove_dir_all(&path)?;
cleaned += 1;
}
}
Ok(cleaned)
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::*;
use crate::utils::staging::clean_staging_orphans;
#[test]
fn onnx_sniff_rejects_empty() {

View File

@ -1,9 +1,9 @@
use actix_web::{HttpResponse, Scope, web};
use serde::Deserialize;
use crate::adapter::http::middleware::extractor::AuthClaims;
use crate::adapter::http::response::{ok_json_or_error, ok_or_error};
use crate::core::common::notification_service::NotificationService;
use crate::core::identity::extractor::AuthClaims;
pub fn initialize() -> Scope {
web::scope("/notifications")

View File

@ -0,0 +1,23 @@
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering::SeqCst;
use actix_web::{HttpResponse, web};
use crate::infrastructure::readiness::ReadinessState;
pub async fn health_ready(ready: web::Data<Arc<AtomicBool>>, state: web::Data<ReadinessState>) -> HttpResponse {
let is_ready = ready.load(SeqCst);
let uptime_secs = state.started_at.elapsed().as_secs();
HttpResponse::Ok().json(serde_json::json!({
"ready": is_ready,
"subsystems": {
"db_connected": state.db_connected.load(SeqCst),
"ml_model_loaded": state.ml_model_loaded.load(SeqCst),
"soar_engine_running": state.soar_engine_running.load(SeqCst),
"ebpf_attached": state.ebpf_attached.load(SeqCst),
},
"uptime_secs": uptime_secs,
}))
}

View File

@ -5,17 +5,17 @@ use arc_swap::ArcSwap;
use chrono::Local;
use tokio::task::spawn_blocking;
use crate::adapter::http::middleware::extractor::AuthClaims;
use crate::adapter::http::response::ok_json_or_error;
use crate::adapter::notification::smtp::SmtpClient;
use crate::adapter::persistence::Database;
use crate::core::identity::extractor::AuthClaims;
use crate::core::reporting::email_report::generate_weekly_report;
use crate::core::reporting::email_scheduler::SmtpClient;
use crate::core::reporting::report_engine;
use crate::domain::common::config::AppConfig;
use crate::domain::common::error::misc::MiscError;
use crate::infrastructure::secret_store::SecretStore;
use crate::interface::port::secret_store::SecretStorePort;
use crate::interface::port::setting::SettingRepo;
use crate::interface::secret_store::SecretStorePort;
use crate::interface::setting::SettingRepo;
pub fn initialize() -> Scope {
web::scope("/report")

View File

@ -8,13 +8,13 @@ use serde::Deserialize;
use serde_json::Value;
use crate::adapter::persistence::Database;
use crate::core::identity::setup_guard::SetupCompleteFlag;
use crate::domain::common::error::Error;
use crate::domain::common::error::system::SystemError;
use crate::domain::identity::auth::DEFAULT_ADMIN_USERNAME;
use crate::domain::identity::password;
use crate::infrastructure::http_server::SetupCompleteFlag;
use crate::infrastructure::secret_store::SecretStore;
use crate::interface::port::secret_store::SecretStorePort;
use crate::interface::secret_store::SecretStorePort;
pub fn initialize() -> Scope {
web::scope("/setup")
@ -24,7 +24,7 @@ pub fn initialize() -> Scope {
}
async fn setup_status(setup_flag: web::Data<SetupCompleteFlag>) -> HttpResponse {
let complete = setup_flag.load(Ordering::SeqCst);
let complete = setup_flag.0.load(Ordering::SeqCst);
HttpResponse::Ok().json(serde_json::json!({
"setup_complete": complete,
}))
@ -88,8 +88,7 @@ async fn complete_setup(
setup_flag: web::Data<SetupCompleteFlag>,
body: web::Json<SetupRequest>,
) -> HttpResponse {
// Check if already completed (concurrent access protection)
if setup_flag.load(Ordering::SeqCst) {
if setup_flag.0.load(Ordering::SeqCst) {
return HttpResponse::Conflict().json(serde_json::json!({
"error": "Setup already completed"
}));
@ -145,11 +144,11 @@ async fn complete_setup(
Ok(hash) => {
// Find admin user and update password
if let Ok(Some(user)) = db.find_user(DEFAULT_ADMIN_USERNAME) {
if let Err(e) = db.update_user_password(user.0, &hash) {
if let Err(e) = db.update_user_password(user.id, &hash) {
log!(SystemError::SetupPasswordUpdateFailed(e));
}
// Clear force_password_change since setup wizard set the password
if let Err(e) = db.reset_user_password(user.0, &hash) {
if let Err(e) = db.reset_user_password(user.id, &hash) {
log!(SystemError::SetupPasswordUpdateFailed(e));
}
}
@ -165,7 +164,7 @@ async fn complete_setup(
if let Err(e) = db.set_setting("setup_complete", "true") {
log!(SystemError::SetupCompleteFlagFailed(e));
}
setup_flag.store(true, Ordering::SeqCst);
setup_flag.0.store(true, Ordering::SeqCst);
// System::run() polls the setup_complete flag and will automatically
// start eBPF, ML, and SOAR services once this flag becomes true.

View File

@ -4,8 +4,8 @@ use actix_web::{HttpResponse, Scope, web};
use arc_swap::ArcSwap;
use serde::Deserialize;
use crate::adapter::http::middleware::extractor::AuthClaims;
use crate::adapter::http::response::{ok_json_or_error, ok_or_error};
use crate::core::identity::extractor::AuthClaims;
use crate::core::response::engine::SoarEngine;
use crate::core::response::playbook_service::PlaybookService;
use crate::domain::common::config::AppConfig;

View File

@ -1,7 +1,7 @@
use actix_web::{HttpResponse, Responder, Scope, web};
use crate::infrastructure::statistics::FlowStatistics;
use crate::interface::port::drop_stats::DropStatsPort;
use crate::core::common::statistics::FlowStatistics;
use crate::interface::drop_stats::DropStatsPort;
pub fn initialize() -> Scope {
web::scope("/stats")

View File

@ -1,13 +1,15 @@
use actix_web::{HttpResponse, Responder, Scope, web};
use serde::Deserialize;
use crate::adapter::http::middleware::extractor::AuthClaims;
use crate::core::common::config_service::ConfigService;
use crate::core::identity::extractor::AuthClaims;
use crate::domain::common::config::constants::PERMISSION_SYSTEM_ADMIN;
use crate::infrastructure::enforce_mode_handler::EnforceModeHandler;
use crate::core::common::enforce_mode_handler::EnforceModeHandler;
use crate::domain::common::config::constants::{
ENFORCE_MODE_ENFORCE, ENFORCE_MODE_ML_ONLY, ENFORCE_MODE_MONITOR, PERMISSION_SYSTEM_ADMIN,
};
use crate::infrastructure::logger::Logger;
use crate::infrastructure::system::{ShutdownHandle, ShutdownMode};
use crate::interface::port::app_repo::AppRepo;
use crate::interface::app_repo::AppRepo;
use crate::utils::boot_time;
type Repo = dyn AppRepo;
@ -47,7 +49,7 @@ async fn set_enforce_mode(
handler: web::Data<EnforceModeHandler>,
) -> impl Responder {
let mode = &body.mode;
if mode != "monitor" && mode != "ml_only" && mode != "enforce" {
if mode != ENFORCE_MODE_MONITOR && mode != ENFORCE_MODE_ML_ONLY && mode != ENFORCE_MODE_ENFORCE {
return HttpResponse::BadRequest()
.json(serde_json::json!({"error": "Mode must be 'monitor', 'ml_only', or 'enforce'"}));
}

View File

@ -1,6 +1,8 @@
pub mod access_control;
pub mod ebpf;
pub mod http;
pub mod model_loading;
pub mod notification;
pub mod persistence;
pub mod telegram;
pub mod websocket;

View File

@ -0,0 +1,2 @@
pub mod config_loader;
pub mod manifest;

View File

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

View File

@ -0,0 +1,105 @@
use lettre::message::header::ContentType;
use lettre::transport::smtp::authentication::Credentials;
use lettre::{Message, SmtpTransport, Transport};
use crate::domain::common::config::notification::SmtpConfig;
use crate::domain::common::error::Error;
use crate::domain::common::error::notification::NotificationError;
use crate::interface::email_sender::{EmailSender, EmailSenderFactory};
use crate::interface::secret_store::SecretStorePort;
pub struct SmtpClient {
host: String,
port: u16,
username: String,
password: String,
sender: String,
}
impl SmtpClient {
pub fn from_config(cfg: &SmtpConfig, secrets: Option<&dyn SecretStorePort>) -> Result<Option<Self>, Error> {
if cfg.host.is_empty() || cfg.username.is_empty() {
return Ok(None);
}
let password = match secrets.and_then(|ss| ss.get_secret("smtp_password").ok().flatten()) {
Some(pw) if !pw.is_empty() => pw,
_ => return Ok(None),
};
let sender = if cfg.sender.is_empty() {
cfg.username.clone()
} else {
cfg.sender.clone()
};
if !sender.contains('@') {
return Ok(None);
}
Ok(Some(Self {
host: cfg.host.clone(),
port: cfg.port,
username: cfg.username.clone(),
password,
sender,
}))
}
pub fn send(&self, to: &str, subject: &str, html_body: &str) -> Result<(), Error> {
let from_addr = self
.sender
.parse()
.map_err(|e| NotificationError::InvalidAddress("from", e))?;
let to_addr = to.parse().map_err(|e| NotificationError::InvalidAddress("to", e))?;
let email = Message::builder()
.from(from_addr)
.to(to_addr)
.subject(subject)
.header(ContentType::TEXT_HTML)
.body(html_body.to_string())
.map_err(NotificationError::MessageBuildFailed)?;
let creds = Credentials::new(self.username.clone(), self.password.clone());
let mailer = match self.port {
465 => SmtpTransport::relay(&self.host)
.map_err(NotificationError::SmtpConnectionFailed)?
.port(self.port)
.credentials(creds)
.build(),
25 | 587 => SmtpTransport::starttls_relay(&self.host)
.map_err(NotificationError::SmtpConnectionFailed)?
.port(self.port)
.credentials(creds)
.build(),
_ => SmtpTransport::builder_dangerous(&self.host)
.port(self.port)
.credentials(creds)
.build(),
};
mailer.send(&email).map_err(NotificationError::SmtpSendFailed)?;
Ok(())
}
}
impl EmailSender for SmtpClient {
fn send(&self, to: &str, subject: &str, html_body: &str) -> Result<(), Error> {
self.send(to, subject, html_body)
}
}
pub struct SmtpClientFactory;
impl EmailSenderFactory for SmtpClientFactory {
fn build_smtp_sender(
&self,
cfg: &SmtpConfig,
secrets: Option<&dyn SecretStorePort>,
) -> Result<Option<Box<dyn EmailSender>>, Error> {
SmtpClient::from_config(cfg, secrets).map(|opt| opt.map(|c| Box::new(c) as Box<dyn EmailSender>))
}
}

View File

@ -2,7 +2,8 @@ use rusqlite::params;
use super::Database;
use crate::domain::common::error::Error;
use crate::interface::port::acl::{AclRepo, AclRuleTuple};
use crate::domain::data_plane::acl_rule::AclRuleView;
use crate::interface::acl::AclRepo;
impl Database {
pub fn insert_acl_rule(
@ -37,17 +38,17 @@ impl Database {
Ok(())
}
pub fn list_acl_rules(&self) -> Result<Vec<AclRuleTuple>, Error> {
pub fn list_acl_rules(&self) -> Result<Vec<AclRuleView>, Error> {
let conn = self.conn()?;
let mut stmt = conn.prepare("SELECT ip_version, direction, list_type, ip_address, port FROM acl_rules")?;
let rows = stmt.query_map([], |row| {
Ok((
row.get::<_, u8>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
row.get::<_, i64>(4)? as u16,
))
Ok(AclRuleView {
ip_version: row.get(0)?,
direction: row.get(1)?,
list_type: row.get(2)?,
ip_address: row.get(3)?,
port: row.get::<_, i64>(4)? as u16,
})
})?;
let mut results = Vec::new();
for row in rows {
@ -146,16 +147,11 @@ mod tests {
db.insert_acl_rule(4, "source", "blacklist", "192.168.1.1", 80).unwrap();
let rules = db.list_acl_rules().unwrap();
assert_eq!(rules.len(), 1);
assert_eq!(
rules[0],
(
4,
"source".to_string(),
"blacklist".to_string(),
"192.168.1.1".to_string(),
80
)
);
assert_eq!(rules[0].ip_version, 4);
assert_eq!(rules[0].direction, "source");
assert_eq!(rules[0].list_type, "blacklist");
assert_eq!(rules[0].ip_address, "192.168.1.1");
assert_eq!(rules[0].port, 80);
db.delete_acl_rule(4, "source", "blacklist", "192.168.1.1", 80).unwrap();
let rules = db.list_acl_rules().unwrap();

View File

@ -6,8 +6,9 @@ use sha2::Sha256;
use super::Database;
use crate::domain::common::error::Error;
use crate::domain::identity::auth::Claims;
use crate::interface::port::api_key::{ApiKeyListItem, ApiKeyRepo};
use crate::domain::identity::auth::{Claims, PermissionLevel};
use crate::domain::identity::user::ApiKeyView;
use crate::interface::api_key::ApiKeyRepo;
type HmacSha256 = Hmac<Sha256>;
@ -54,35 +55,8 @@ impl Database {
params![id],
);
// Build permissions based on permission level
let permissions = match level.as_str() {
"read_write" | "full_access" => vec![
"dashboard:read".into(),
"statistics:read".into(),
"ai_detection:read".into(),
"ai_detection:write".into(),
"access_control:read".into(),
"access_control:write".into(),
"geo_block:read".into(),
"geo_block:write".into(),
"dns_filter:read".into(),
"dns_filter:write".into(),
"rate_limit:read".into(),
"rate_limit:write".into(),
"system:read".into(),
"system:write".into(),
],
_ => vec![
"dashboard:read".into(),
"statistics:read".into(),
"ai_detection:read".into(),
"access_control:read".into(),
"geo_block:read".into(),
"dns_filter:read".into(),
"rate_limit:read".into(),
"system:read".into(),
],
};
let perm_level = PermissionLevel::from_str(&level).unwrap_or(PermissionLevel::ReadOnly);
let permissions: Vec<String> = perm_level.permissions().iter().map(|s| (*s).to_string()).collect();
Ok(Some(Claims {
sub: -id, // negative ID to distinguish from user IDs
@ -106,17 +80,17 @@ impl Database {
Ok(conn.last_insert_rowid())
}
pub fn list_api_keys(&self) -> Result<Vec<ApiKeyListItem>, Error> {
pub fn list_api_keys(&self) -> Result<Vec<ApiKeyView>, Error> {
let conn = self.conn()?;
let mut stmt = conn.prepare("SELECT id, name, permission_level, created_at, last_used_at FROM api_keys")?;
let rows = stmt.query_map([], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
row.get::<_, Option<String>>(4)?,
))
Ok(ApiKeyView {
id: row.get(0)?,
name: row.get(1)?,
permission_level: row.get(2)?,
created_at: row.get(3)?,
last_used_at: row.get(4)?,
})
})?;
let mut result = Vec::new();
for row in rows {
@ -145,7 +119,7 @@ impl ApiKeyRepo for Database {
self.insert_api_key(key_hash, name, permission_level)
}
fn list_api_keys(&self) -> Result<Vec<ApiKeyListItem>, Error> {
fn list_api_keys(&self) -> Result<Vec<ApiKeyView>, Error> {
self.list_api_keys()
}

View File

@ -5,9 +5,10 @@ use rusqlite::params;
use sha2::{Digest, Sha256};
use super::Database;
use crate::domain::common::audit::AuditLogEntry;
use crate::domain::common::error::Error;
use crate::domain::common::error::database::DatabaseError;
use crate::interface::port::audit::{AuditLogEntry, AuditRepo};
use crate::interface::audit::AuditRepo;
/// Compute the row hash for an audit_log entry.
/// Formula: sha256_hex(ts || 0x00 || actor || 0x00 || action || 0x00 || detail || 0x00 || prev_hash)
@ -90,17 +91,33 @@ impl Database {
Ok(rows)
}
/// Walk the entire audit_log in id order and verify the hash chain.
/// Returns `Ok(count)` on success; returns `Err` at the first mismatch,
/// naming the offending row id and the kind of mismatch.
pub fn verify_audit_log_chain(&self) -> Result<usize, Error> {
/// Walk audit_log rows in id order, verifying the hash chain.
/// When `after_id` is 0 the entire table is checked; otherwise only
/// rows with `id > after_id` are verified (the prev_hash of the first
/// row is validated against the stored row_hash of `after_id`).
/// Returns `Ok((verified_count, last_id))` on success.
pub fn verify_audit_log_chain(&self, after_id: i64) -> Result<(usize, i64), Error> {
let conn = self.conn()?;
let mut stmt =
conn.prepare("SELECT id, ts, actor, action, detail, prev_hash, row_hash FROM audit_log ORDER BY id ASC")?;
let mut rows = stmt.query([])?;
let mut expected_prev = String::new();
let mut expected_prev = if after_id > 0 {
conn.query_row(
"SELECT row_hash FROM audit_log WHERE id = ?1",
params![after_id],
|row| row.get::<_, String>(0),
)
.unwrap_or_default()
} else {
String::new()
};
let mut stmt = conn.prepare(
"SELECT id, ts, actor, action, detail, prev_hash, row_hash \
FROM audit_log WHERE id > ?1 ORDER BY id ASC",
)?;
let mut rows = stmt.query(params![after_id])?;
let mut count = 0usize;
let mut last_id = after_id;
while let Some(row) = rows.next()? {
let id: i64 = row.get(0)?;
let ts: String = row.get(1)?;
@ -118,9 +135,10 @@ impl Database {
return Err(DatabaseError::AuditRowHashMismatch(id, computed, row_hash).into());
}
expected_prev = row_hash;
last_id = id;
count += 1;
}
Ok(count)
Ok((count, last_id))
}
}
@ -133,7 +151,7 @@ impl AuditRepo for Database {
self.list_audit_logs_by_action(action, limit)
}
fn verify_audit_log_chain(&self) -> Result<usize, Error> {
self.verify_audit_log_chain()
fn verify_audit_log_chain(&self, after_id: i64) -> Result<(usize, i64), Error> {
self.verify_audit_log_chain(after_id)
}
}

View File

@ -2,7 +2,7 @@ use rusqlite::params;
use super::Database;
use crate::domain::common::error::Error;
use crate::interface::port::enforcement::EnforcementRepo;
use crate::interface::enforcement::EnforcementRepo;
impl Database {
pub fn set_rate_limit(&self, key: &str, value: u64) -> Result<(), Error> {

View File

@ -18,7 +18,19 @@ use rusqlite::{self, Connection, params};
use crate::domain::common::error::Error;
use crate::domain::common::error::database::DatabaseError;
use crate::domain::common::log::misc::MiscLog;
use crate::domain::identity::auth::{GROUP_ADMIN, GROUP_VIEWER};
impl From<rusqlite::Error> for DatabaseError {
fn from(e: rusqlite::Error) -> Self {
DatabaseError::QueryFailed(e)
}
}
impl From<rusqlite::Error> for Error {
fn from(e: rusqlite::Error) -> Self {
Self::Database(DatabaseError::from(e))
}
}
use crate::domain::identity::auth::{ADMIN_PERMISSIONS, GROUP_ADMIN, GROUP_VIEWER, VIEWER_PERMISSIONS};
/// Reads the SQLCipher encryption key from the environment variable `NETGUARDIA_DB_KEY`.
/// Returns `Some(key)` if set and non-empty, `None` otherwise (dev / unencrypted mode).
@ -317,6 +329,9 @@ impl Database {
row_hash TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_audit_log_action
ON audit_log(action, id DESC);
CREATE TRIGGER IF NOT EXISTS audit_log_no_update
BEFORE UPDATE ON audit_log BEGIN
SELECT RAISE(ABORT, 'audit_log is append-only (WORM)');
@ -334,51 +349,8 @@ impl Database {
// Seed default user groups on first install (empty table)
let group_count: i64 = conn_ref.query_row("SELECT COUNT(*) FROM user_groups", [], |row| row.get(0))?;
if group_count == 0 {
let all_permissions = serde_json::json!([
"dashboard:read",
"statistics:read",
"traffic_map:read",
"drops:read",
"ai_detection:read",
"ai_detection:write",
"access_control:read",
"access_control:write",
"geo_block:read",
"geo_block:write",
"dns_filter:read",
"dns_filter:write",
"rate_limit:read",
"rate_limit:write",
"protocol_filter:read",
"protocol_filter:write",
"system:read",
"system:write",
"system:admin",
"users:read",
"users:write",
"users:admin",
"fusion:read",
"fusion:write",
"flow_trace:read",
"flow_trace:write"
])
.to_string();
let viewer_permissions = serde_json::json!([
"dashboard:read",
"statistics:read",
"traffic_map:read",
"drops:read",
"ai_detection:read",
"access_control:read",
"geo_block:read",
"dns_filter:read",
"rate_limit:read",
"protocol_filter:read",
"system:read",
"fusion:read",
"flow_trace:read"
])
.to_string();
let all_permissions = serde_json::to_string(&ADMIN_PERMISSIONS).unwrap_or_else(|_| "[]".to_string());
let viewer_permissions = serde_json::to_string(&VIEWER_PERMISSIONS).unwrap_or_else(|_| "[]".to_string());
conn_ref.execute(
"INSERT INTO user_groups (name, description, permissions) VALUES (?1, ?2, ?3)",
@ -397,9 +369,9 @@ impl Database {
#[cfg(test)]
mod tests {
use super::*;
use crate::interface::port::acl::AclRepo;
use crate::interface::port::identity::IdentityRepo;
use crate::interface::port::setting::SettingRepo;
use crate::interface::acl::AclRepo;
use crate::interface::identity::UserRepo;
use crate::interface::setting::SettingRepo;
pub(super) fn test_db() -> Database {
Database::new(":memory:").expect("Failed to create test database")
@ -411,7 +383,7 @@ mod tests {
}
/// Verify that Database satisfies each aggregate Repo trait contract
/// (AclRepo / SettingRepo / IdentityRepo). Exercises the trait-object
/// (AclRepo / SettingRepo / UserRepo). Exercises the trait-object
/// path so callers that take `Arc<dyn XxxRepo>` compile end-to-end.
#[test]
fn test_aggregate_repo_trait_objects() {
@ -428,7 +400,7 @@ mod tests {
let rules = db.list_acl_rules().unwrap();
assert_eq!(rules.len(), 1);
let identity: &dyn IdentityRepo = &db;
let identity: &dyn UserRepo = &db;
// user_count is inherent — inserts still go through the trait so
// the vtable has something to exercise.
assert_eq!(db.user_count().unwrap(), 0);

View File

@ -2,7 +2,7 @@ use rusqlite::{Error as RusqliteError, Transaction, params};
use super::Database;
use crate::domain::common::error::Error;
use crate::interface::port::setting::SettingRepo;
use crate::interface::setting::SettingRepo;
impl Database {
pub fn get_setting(&self, key: &str) -> Result<Option<String>, Error> {
@ -191,7 +191,7 @@ mod tests {
use super::super::tests::test_db;
use crate::domain::common::error::Error;
use crate::domain::common::error::misc::MiscError;
use crate::interface::port::setting::SettingRepo;
use crate::interface::setting::SettingRepo;
#[test]
fn test_settings_crud() {

View File

@ -5,11 +5,12 @@ use serde_json::Value;
use super::Database;
use crate::domain::common::error::Error;
use crate::domain::response::defaults::DEFAULT_PLAYBOOKS;
use crate::domain::response::playbook_data::{
ActionView, ActiveBlockView, ConditionView, CreatePlaybookInput, ExecutionView, PendingUnblock, PlaybookView,
UpdatePlaybookInput,
ActionInput, ActionView, ActiveBlockView, ConditionView, CreateConditionInput, CreatePlaybookInput, ExecutionView,
PendingUnblock, PlaybookView, UpdatePlaybookInput,
};
use crate::interface::port::soar::SoarRepo;
use crate::interface::soar::SoarRepo;
/// Intermediate row from the playbooks LEFT JOIN playbook_actions query.
/// Private to this module; consumed only by `SoarRepo::list_playbooks`.
@ -198,40 +199,22 @@ impl Database {
}
drop(conn);
// 1. brute_force_block: brute_force, count 5 in 60s → block_ip(3600s) + send_telegram + log
let pb1 = self.insert_playbook("brute_force_block", "brute_force", None, Some(5), Some(60), 600)?;
self.insert_playbook_action(pb1, 1, "block_ip", r#"{"ttl_secs": 3600}"#)?;
self.insert_playbook_action(pb1, 2, "send_telegram", "{}")?;
self.insert_playbook_action(pb1, 3, "log", r#"{"level": "warn"}"#)?;
self.insert_playbook_condition(pb1, "frequency", ">=", "5", Some("60"))?;
// 2. port_scan_alert: port_scan, threshold 0.7 → send_telegram + log (no block)
let pb2 = self.insert_playbook("port_scan_alert", "port_scan", Some(0.7), None, None, 300)?;
self.insert_playbook_action(pb2, 1, "send_telegram", "{}")?;
self.insert_playbook_action(pb2, 2, "log", r#"{"level": "warn"}"#)?;
self.insert_playbook_condition(pb2, "threshold", ">=", "0.7", None)?;
// 3. fusion_c2_multi_source_block — C2 beacon observed by ≥2 sources
// (e.g. Suricata trojan-activity + Beaconing CV + ML c2 class) is
// the highest-precision fusion signal we ship. Block for 1h and
// notify, no solo-source threshold so single-source C2 hits still
// require the solo playbook below to act.
let pb3 = self.insert_playbook("fusion_c2_multi_source_block", "c2_beacon", None, None, None, 600)?;
self.insert_playbook_action(pb3, 1, "block_ip", r#"{"ttl_secs": 3600}"#)?;
self.insert_playbook_action(pb3, 2, "send_telegram", "{}")?;
self.insert_playbook_action(pb3, 3, "log", r#"{"level": "warn"}"#)?;
self.insert_playbook_condition(pb3, "multi_source_min", ">=", "2", None)?;
// 4. fusion_c2_suricata_solo_high_block — the escape hatch for
// Suricata signature hits with very high confidence (>=0.95).
// Lets known-good rules fire without waiting for agreement from a
// second source, matching how analysts intuitively treat a
// signature "dead-on" match.
let pb4 = self.insert_playbook("fusion_c2_suricata_solo_high_block", "c2_beacon", None, None, None, 600)?;
self.insert_playbook_action(pb4, 1, "block_ip", r#"{"ttl_secs": 3600}"#)?;
self.insert_playbook_action(pb4, 2, "send_telegram", "{}")?;
self.insert_playbook_action(pb4, 3, "log", r#"{"level": "warn"}"#)?;
self.insert_playbook_condition(pb4, "single_source_high", "==", "Suricata", Some("0.95"))?;
for def in DEFAULT_PLAYBOOKS {
let pb_id = self.insert_playbook(
def.name,
def.trigger_event,
def.threshold,
def.count,
def.window,
def.cooldown,
)?;
for action in def.actions {
self.insert_playbook_action(pb_id, action.order, action.action_type, action.params)?;
}
for cond in def.conditions {
self.insert_playbook_condition(pb_id, cond.condition_type, cond.operator, cond.value, cond.value2)?;
}
}
Ok(())
}
@ -371,8 +354,8 @@ impl SoarRepo for Database {
fn insert_playbook_atomic(
&self,
input: &CreatePlaybookInput,
actions: &[(i64, String, String)],
conditions: &[(String, String, String, Option<String>)],
actions: &[ActionInput],
conditions: &[CreateConditionInput],
) -> Result<i64, Error> {
let mut conn = self.conn()?;
let tx = conn.transaction()?;
@ -381,16 +364,16 @@ impl SoarRepo for Database {
params![input.name, input.trigger_event, input.condition_threshold, input.condition_count, input.condition_window_secs, input.cooldown_secs],
)?;
let playbook_id = tx.last_insert_rowid();
for (action_order, action_type, params_json) in actions {
for a 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],
params![playbook_id, a.action_order, a.action_type, a.params_json],
)?;
}
for (condition_type, operator, value, value2) in conditions {
for c 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()],
params![playbook_id, c.condition_type, c.operator, c.value, c.value2.as_deref()],
)?;
}
tx.commit()?;
@ -401,8 +384,8 @@ impl SoarRepo for Database {
&self,
id: i64,
row: &UpdatePlaybookInput,
actions: &[(i64, String, String)],
conditions: &[(String, String, String, Option<String>)],
actions: &[ActionInput],
conditions: &[CreateConditionInput],
) -> Result<bool, Error> {
let mut conn = self.conn()?;
let tx = conn.transaction()?;
@ -425,16 +408,16 @@ impl SoarRepo for Database {
}
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 {
for a 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],
params![id, a.action_order, a.action_type, a.params_json],
)?;
}
for (condition_type, operator, value, value2) in conditions {
for c 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()],
params![id, c.condition_type, c.operator, c.value, c.value2.as_deref()],
)?;
}
tx.commit()?;
@ -450,10 +433,10 @@ mod tests {
/// atomically.
#[test]
fn test_insert_playbook_atomic_writes_all_three_tables() {
use crate::interface::port::soar::SoarRepo;
use crate::interface::soar::SoarRepo;
let db = test_db();
use crate::domain::response::playbook_data::CreatePlaybookInput;
use crate::domain::response::playbook_data::{ActionInput, CreateConditionInput, CreatePlaybookInput};
let input = CreatePlaybookInput {
name: "atom_pb".to_string(),
@ -465,8 +448,17 @@ mod tests {
actions: vec![("block_ip".to_string(), "{}".to_string())],
conditions: vec![],
};
let actions = vec![(1i64, "block_ip".to_string(), "{}".to_string())];
let conditions = vec![("threshold".to_string(), ">=".to_string(), "0.8".to_string(), None)];
let actions = vec![ActionInput {
action_order: 1,
action_type: "block_ip".to_string(),
params_json: "{}".to_string(),
}];
let conditions = vec![CreateConditionInput::new(
"threshold".to_string(),
Some(">=".to_string()),
"0.8".to_string(),
None,
)];
let id = db.insert_playbook_atomic(&input, &actions, &conditions).unwrap();
assert!(id > 0);
let loaded = db.list_playbooks().unwrap();

View File

@ -3,7 +3,7 @@ use rusqlite::params;
use super::Database;
use crate::domain::common::error::Error;
use crate::domain::response::playbook_data::{ActiveBlockView, PendingUnblock};
use crate::interface::port::db_admin::DbAdminRepo;
use crate::interface::db_admin::DbAdminRepo;
impl Database {
/// Test-only helper: direct insert of a SOAR block rule row. Production
@ -188,8 +188,8 @@ impl DbAdminRepo for Database {
#[cfg(test)]
mod tests {
use super::super::tests::test_db;
use crate::interface::port::db_admin::DbAdminRepo;
use crate::interface::port::soar::SoarRepo;
use crate::interface::db_admin::DbAdminRepo;
use crate::interface::soar::SoarRepo;
/// Happy path. Verifies `commit_soar_block_to_db` writes both
/// `soar_block_rules` and `acl_rules` atomically.
@ -215,7 +215,7 @@ mod tests {
// acl_rules has the matching row
let rules = db.list_acl_rules().unwrap();
assert_eq!(rules.len(), 1);
assert_eq!(rules[0].3, "10.0.0.99");
assert_eq!(rules[0].ip_address, "10.0.0.99");
}
/// Verifies `commit_soar_unblock_to_db` removes the ACL row and marks

View File

@ -2,7 +2,8 @@ use rusqlite::params;
use super::Database;
use crate::domain::common::error::Error;
use crate::interface::port::stats::StatsRepo;
use crate::domain::report::data::{ThreatBreakdownEntry, TopIpEntry};
use crate::interface::stats::StatsRepo;
impl Database {
/// Count SOAR executions in the last N days.
@ -39,13 +40,16 @@ impl Database {
}
/// Get threat breakdown by trigger_event in the last N days.
pub fn weekly_threat_breakdown(&self, days: i64) -> Result<Vec<(String, u64)>, Error> {
pub fn weekly_threat_breakdown(&self, days: i64) -> Result<Vec<ThreatBreakdownEntry>, Error> {
let conn = self.conn()?;
let mut stmt = conn.prepare(
"SELECT trigger_event, COUNT(*) FROM soar_executions WHERE executed_at >= datetime('now', ?1) GROUP BY trigger_event ORDER BY COUNT(*) DESC"
)?;
let rows = stmt.query_map(params![format!("-{} days", days)], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as u64))
Ok(ThreatBreakdownEntry {
threat_type: row.get(0)?,
count: row.get::<_, i64>(1)? as u64,
})
})?;
let mut result = Vec::new();
for row in rows {
@ -55,13 +59,16 @@ impl Database {
}
/// Get top blocked IPs in the last N days.
pub fn weekly_top_ips(&self, days: i64, limit: i64) -> Result<Vec<(String, u64)>, Error> {
pub fn weekly_top_ips(&self, days: i64, limit: i64) -> Result<Vec<TopIpEntry>, Error> {
let conn = self.conn()?;
let mut stmt = conn.prepare(
"SELECT source_ip, COUNT(*) as cnt FROM soar_block_rules WHERE created_at >= datetime('now', ?1) GROUP BY source_ip ORDER BY cnt DESC LIMIT ?2"
)?;
let rows = stmt.query_map(params![format!("-{} days", days), limit], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as u64))
Ok(TopIpEntry {
ip: row.get(0)?,
count: row.get::<_, i64>(1)? as u64,
})
})?;
let mut result = Vec::new();
for row in rows {
@ -91,11 +98,11 @@ impl StatsRepo for Database {
self.count_weekly_unblocks(days)
}
fn weekly_threat_breakdown(&self, days: i64) -> Result<Vec<(String, u64)>, Error> {
fn weekly_threat_breakdown(&self, days: i64) -> Result<Vec<ThreatBreakdownEntry>, Error> {
self.weekly_threat_breakdown(days)
}
fn weekly_top_ips(&self, days: i64, limit: i64) -> Result<Vec<(String, u64)>, Error> {
fn weekly_top_ips(&self, days: i64, limit: i64) -> Result<Vec<TopIpEntry>, Error> {
self.weekly_top_ips(days, limit)
}

View File

@ -6,22 +6,25 @@ use rusqlite::{Error as RusqliteError, params};
use super::Database;
use crate::domain::common::error::Error;
use crate::domain::common::error::database::DatabaseError;
use crate::interface::port::identity::{IdentityRepo, UserGroupTuple, UserTuple, UserWithGroups};
use crate::domain::identity::auth::{LOGIN_LOCKOUT_SECS, LOGIN_MAX_FAILURES};
use crate::domain::identity::user::{
GroupMemberView, UserGroupMembership, UserGroupView, UserView, UserWithGroupsView,
};
use crate::interface::identity::{LoginAttemptRepo, UserGroupRepo, UserRepo};
impl Database {
pub fn find_user(&self, username: &str) -> Result<Option<UserTuple>, Error> {
pub fn find_user(&self, username: &str) -> Result<Option<UserView>, Error> {
let conn = self.conn()?;
let result = conn.query_row(
"SELECT id, username, password_hash, role, force_password_change FROM users WHERE username = ?1",
"SELECT id, username, password_hash, force_password_change FROM users WHERE username = ?1",
params![username],
|row| {
Ok((
row.get(0)?,
row.get(1)?,
row.get(2)?,
row.get(3)?,
row.get::<_, i64>(4)? != 0,
))
Ok(UserView {
id: row.get(0)?,
username: row.get(1)?,
password_hash: row.get(2)?,
force_password_change: row.get::<_, i64>(3)? != 0,
})
},
);
match result {
@ -67,10 +70,10 @@ impl Database {
Ok(conn.query_row("SELECT COUNT(*) FROM users", [], |row| row.get(0))?)
}
pub fn list_users_with_groups(&self) -> Result<Vec<UserWithGroups>, Error> {
pub fn list_users_with_groups(&self) -> Result<Vec<UserWithGroupsView>, Error> {
let conn = self.conn()?;
let mut stmt = conn.prepare(
"SELECT u.id, u.username, u.role, u.force_password_change, u.created_at, \
"SELECT u.id, u.username, u.force_password_change, u.created_at, \
g.id, g.name \
FROM users u \
LEFT JOIN user_group_members m ON u.id = m.user_id \
@ -81,25 +84,33 @@ impl Database {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, i64>(3)? != 0,
row.get::<_, String>(4)?,
row.get::<_, Option<i64>>(5)?,
row.get::<_, Option<String>>(6)?,
row.get::<_, i64>(2)? != 0,
row.get::<_, String>(3)?,
row.get::<_, Option<i64>>(4)?,
row.get::<_, Option<String>>(5)?,
))
})?;
let mut user_map: HashMap<i64, UserWithGroups> = HashMap::new();
let mut user_map: HashMap<i64, UserWithGroupsView> = HashMap::new();
let mut order: Vec<i64> = Vec::new();
for row in rows {
let (id, username, role, force_pw, created_at, group_id, group_name) = row?;
let (id, username, force_pw, created_at, group_id, group_name) = row?;
let entry = user_map.entry(id).or_insert_with(|| {
order.push(id);
(id, username, role, force_pw, created_at, Vec::new())
UserWithGroupsView {
id,
username,
force_password_change: force_pw,
created_at,
groups: Vec::new(),
}
});
if let (Some(gid), Some(gname)) = (group_id, group_name) {
entry.5.push((gid, gname));
entry.groups.push(UserGroupMembership {
group_id: gid,
group_name: gname,
});
}
}
@ -128,19 +139,18 @@ impl Database {
Ok(())
}
pub fn find_user_by_id(&self, user_id: i64) -> Result<Option<UserTuple>, Error> {
pub fn find_user_by_id(&self, user_id: i64) -> Result<Option<UserView>, Error> {
let conn = self.conn()?;
let result = conn.query_row(
"SELECT id, username, password_hash, role, force_password_change FROM users WHERE id = ?1",
"SELECT id, username, password_hash, force_password_change FROM users WHERE id = ?1",
params![user_id],
|row| {
Ok((
row.get(0)?,
row.get(1)?,
row.get(2)?,
row.get(3)?,
row.get::<_, i64>(4)? != 0,
))
Ok(UserView {
id: row.get(0)?,
username: row.get(1)?,
password_hash: row.get(2)?,
force_password_change: row.get::<_, i64>(3)? != 0,
})
},
);
match result {
@ -150,18 +160,18 @@ impl Database {
}
}
pub fn list_user_groups(&self) -> Result<Vec<UserGroupTuple>, Error> {
pub fn list_user_groups(&self) -> Result<Vec<UserGroupView>, Error> {
let conn = self.conn()?;
let mut stmt =
conn.prepare("SELECT id, name, description, permissions, created_at FROM user_groups ORDER BY id")?;
let rows = stmt.query_map([], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
row.get::<_, String>(4)?,
))
Ok(UserGroupView {
id: row.get(0)?,
name: row.get(1)?,
description: row.get(2)?,
permissions: row.get(3)?,
created_at: row.get(4)?,
})
})?;
let mut results = Vec::new();
for row in rows {
@ -202,19 +212,19 @@ impl Database {
Ok(affected > 0)
}
pub fn get_user_group(&self, id: i64) -> Result<Option<UserGroupTuple>, Error> {
pub fn get_user_group(&self, id: i64) -> Result<Option<UserGroupView>, Error> {
let conn = self.conn()?;
let result = conn.query_row(
"SELECT id, name, description, permissions, created_at FROM user_groups WHERE id = ?1",
params![id],
|row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
row.get::<_, String>(4)?,
))
Ok(UserGroupView {
id: row.get(0)?,
name: row.get(1)?,
description: row.get(2)?,
permissions: row.get(3)?,
created_at: row.get(4)?,
})
},
);
match result {
@ -224,20 +234,21 @@ impl Database {
}
}
pub fn list_groups_for_user(&self, user_id: i64) -> Result<Vec<(i64, String, String, String)>, Error> {
pub fn list_groups_for_user(&self, user_id: i64) -> Result<Vec<UserGroupView>, Error> {
let conn = self.conn()?;
let mut stmt = conn.prepare(
"SELECT g.id, g.name, g.description, g.permissions FROM user_groups g \
"SELECT g.id, g.name, g.description, g.permissions, g.created_at FROM user_groups g \
INNER JOIN user_group_members m ON g.id = m.group_id \
WHERE m.user_id = ?1 ORDER BY g.id",
)?;
let rows = stmt.query_map(params![user_id], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
))
Ok(UserGroupView {
id: row.get(0)?,
name: row.get(1)?,
description: row.get(2)?,
permissions: row.get(3)?,
created_at: row.get(4)?,
})
})?;
let mut results = Vec::new();
for row in rows {
@ -261,8 +272,8 @@ impl Database {
pub fn list_user_permissions(&self, user_id: i64) -> Result<Vec<String>, Error> {
let groups = self.list_groups_for_user(user_id)?;
let mut all_perms = HashSet::new();
for (_id, _name, _desc, perms_json) in groups {
if let Ok(perms) = serde_json::from_str::<Vec<String>>(&perms_json) {
for g in groups {
if let Ok(perms) = serde_json::from_str::<Vec<String>>(&g.permissions) {
for p in perms {
all_perms.insert(p);
}
@ -290,7 +301,7 @@ impl Database {
Ok(results)
}
pub fn list_group_members(&self, group_id: i64) -> Result<Vec<(i64, String)>, Error> {
pub fn list_group_members(&self, group_id: i64) -> Result<Vec<GroupMemberView>, Error> {
let conn = self.conn()?;
let mut stmt = conn.prepare(
"SELECT u.id, u.username FROM users u \
@ -298,7 +309,10 @@ impl Database {
WHERE m.group_id = ?1 ORDER BY u.username",
)?;
let rows = stmt.query_map(params![group_id], |row| {
Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
Ok(GroupMemberView {
id: row.get(0)?,
username: row.get(1)?,
})
})?;
let mut results = Vec::new();
for row in rows {
@ -315,12 +329,12 @@ impl Database {
self.set_setting(&key_count, &count.to_string())?;
if count >= 5 {
if count >= LOGIN_MAX_FAILURES {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or(Duration::ZERO)
.as_secs();
let locked_until = now + 900; // 15 minutes
let locked_until = now + LOGIN_LOCKOUT_SECS;
self.set_setting(&key_locked, &locked_until.to_string())?;
Ok((count, Some(locked_until)))
} else {
@ -360,12 +374,12 @@ impl Database {
}
}
impl IdentityRepo for Database {
fn find_user(&self, username: &str) -> Result<Option<UserTuple>, Error> {
impl UserRepo for Database {
fn find_user(&self, username: &str) -> Result<Option<UserView>, Error> {
self.find_user(username)
}
fn find_user_by_id(&self, user_id: i64) -> Result<Option<UserTuple>, Error> {
fn find_user_by_id(&self, user_id: i64) -> Result<Option<UserView>, Error> {
self.find_user_by_id(user_id)
}
@ -383,7 +397,7 @@ impl IdentityRepo for Database {
self.update_user_password(user_id, password_hash)
}
fn list_users_with_groups(&self) -> Result<Vec<UserWithGroups>, Error> {
fn list_users_with_groups(&self) -> Result<Vec<UserWithGroupsView>, Error> {
self.list_users_with_groups()
}
@ -398,8 +412,10 @@ impl IdentityRepo for Database {
fn reset_user_password(&self, user_id: i64, password_hash: &str) -> Result<(), Error> {
self.reset_user_password(user_id, password_hash)
}
}
fn list_user_groups(&self) -> Result<Vec<UserGroupTuple>, Error> {
impl UserGroupRepo for Database {
fn list_user_groups(&self) -> Result<Vec<UserGroupView>, Error> {
self.list_user_groups()
}
@ -415,11 +431,11 @@ impl IdentityRepo for Database {
self.delete_user_group(id)
}
fn get_user_group(&self, id: i64) -> Result<Option<UserGroupTuple>, Error> {
fn get_user_group(&self, id: i64) -> Result<Option<UserGroupView>, Error> {
self.get_user_group(id)
}
fn list_groups_for_user(&self, user_id: i64) -> Result<Vec<(i64, String, String, String)>, Error> {
fn list_groups_for_user(&self, user_id: i64) -> Result<Vec<UserGroupView>, Error> {
self.list_groups_for_user(user_id)
}
@ -435,10 +451,12 @@ impl IdentityRepo for Database {
self.list_group_member_ids(group_id)
}
fn list_group_members(&self, group_id: i64) -> Result<Vec<(i64, String)>, Error> {
fn list_group_members(&self, group_id: i64) -> Result<Vec<GroupMemberView>, Error> {
self.list_group_members(group_id)
}
}
impl LoginAttemptRepo for Database {
fn record_login_failure(&self, username: &str) -> Result<(u32, Option<u64>), Error> {
self.record_login_failure(username)
}
@ -465,11 +483,10 @@ mod tests {
assert_eq!(db.user_count().unwrap(), 1);
let user = db.find_user("admin").unwrap().unwrap();
assert_eq!(user.0, 1); // id
assert_eq!(user.1, "admin"); // username
assert_eq!(user.2, "hash123"); // password_hash
assert_eq!(user.3, "admin"); // role
assert!(user.4); // force_password_change
assert_eq!(user.id, 1);
assert_eq!(user.username, "admin");
assert_eq!(user.password_hash, "hash123");
assert!(user.force_password_change);
}
#[test]
@ -486,13 +503,13 @@ mod tests {
db.insert_user("admin", "old_hash", "admin", true).unwrap();
let user = db.find_user("admin").unwrap().unwrap();
assert!(user.4); // force_password_change = true
assert!(user.force_password_change);
db.update_user_password(user.0, "new_hash").unwrap();
db.update_user_password(user.id, "new_hash").unwrap();
let user = db.find_user("admin").unwrap().unwrap();
assert!(!user.4); // force_password_change = false
assert_eq!(user.2, "new_hash");
assert!(!user.force_password_change);
assert_eq!(user.password_hash, "new_hash");
}
#[test]

View File

@ -12,9 +12,10 @@ use crate::domain::common::config::AppConfig;
use crate::domain::common::error::Error;
use crate::domain::common::error::notification::NotificationError;
use crate::domain::common::log::system::SystemLog;
use crate::interface::port::notification::{AlertNotifier, AlertNotifierFactory, AlertPayload};
use crate::interface::port::secret_store::SecretStorePort;
use crate::interface::port::setting::SettingRepo;
use crate::domain::common::notification::AlertPayload;
use crate::interface::notification::{AlertNotifier, AlertNotifierFactory};
use crate::interface::secret_store::SecretStorePort;
use crate::interface::setting::SettingRepo;
/// Telegram Bot API adapter implementing AlertNotifier.
pub struct TelegramAdapter {

View File

@ -6,8 +6,8 @@ use actix_ws::Message;
use futures_util::StreamExt;
use tokio::time::interval;
use crate::core::common::statistics::FlowStatistics;
use crate::domain::data_plane::flow_stats::FlowSubscription;
use crate::infrastructure::statistics::FlowStatistics;
/// Default subscription: all flows, no filter, 5 second interval
fn default_subscription() -> FlowSubscription {

View File

@ -4,11 +4,11 @@ use tokio::sync::broadcast;
use super::{alert_websocket, drop_websocket, flow_websocket, fusion_websocket, health_websocket};
use crate::adapter::ebpf::drop_monitor::DropMonitor;
use crate::core::identity::jwt::JwtService;
use crate::adapter::http::jwt::JwtService;
use crate::core::common::statistics::FlowStatistics;
use crate::core::inference::alert::MLAlert;
use crate::domain::common::event::ThreatDetectedEvent;
use crate::infrastructure::health::SystemHealth;
use crate::infrastructure::statistics::FlowStatistics;
#[derive(Deserialize)]
struct WsQuery {

View File

@ -6,8 +6,8 @@ use crate::domain::common::config::AppConfig;
use crate::domain::common::config::section::ConfigSection;
use crate::domain::common::error::Error;
use crate::domain::common::error::misc::MiscError;
use crate::interface::port::app_repo::AppRepo;
use crate::interface::port::secret_store::SecretStorePort;
use crate::interface::app_repo::AppRepo;
use crate::interface::secret_store::SecretStorePort;
const SECRET_KEYS: &[&str] = &["smtp_password"];

View File

@ -4,19 +4,11 @@ use std::sync::atomic::{AtomicU8, Ordering};
use macros::log;
use tokio::sync::broadcast;
use crate::domain::common::config::constants::{ENFORCE_MODE_MONITOR, enforce_mode_to_u8};
use crate::domain::common::error::Error;
use crate::domain::common::event::AuditEvent;
use crate::domain::common::log::system::SystemLog;
use crate::interface::port::app_repo::AppRepo;
/// Map enforce-mode string to u8: monitor=0, ml_only=1, enforce=2.
pub fn enforce_mode_to_u8(mode: &str) -> u8 {
match mode {
"enforce" => 2,
"ml_only" => 1,
_ => 0, // "monitor" or unknown → safest default
}
}
use crate::interface::app_repo::AppRepo;
/// Handles enforce-mode commands and queries by delegating to the repository.
pub struct EnforceModeHandler {
@ -53,7 +45,7 @@ impl EnforceModeHandler {
pub fn get_mode(&self) -> Result<String, Error> {
match self.db.get_setting("enforce_mode")? {
Some(mode) => Ok(mode),
None => Ok("monitor".to_string()),
None => Ok(ENFORCE_MODE_MONITOR.to_string()),
}
}
}

View File

@ -1,3 +1,4 @@
pub mod config_service;
pub mod log_buffer;
pub mod enforce_mode_handler;
pub mod notification_service;
pub mod statistics;

View File

@ -3,13 +3,13 @@ use std::sync::Arc;
use arc_swap::ArcSwap;
use serde_json::Value;
use crate::core::reporting::email_scheduler::SmtpClient;
use crate::domain::common::config::AppConfig;
use crate::domain::common::error::Error;
use crate::domain::common::error::misc::MiscError;
use crate::interface::port::notification::AlertNotifierFactory;
use crate::interface::port::secret_store::SecretStorePort;
use crate::interface::port::setting::SettingRepo;
use crate::interface::email_sender::EmailSenderFactory;
use crate::interface::notification::AlertNotifierFactory;
use crate::interface::secret_store::SecretStorePort;
use crate::interface::setting::SettingRepo;
/// Domain service for notification config (Telegram, SMTP).
/// Coordinates DB persistence and external service testing.
@ -18,6 +18,7 @@ pub struct NotificationService {
config: Arc<ArcSwap<AppConfig>>,
secrets: Arc<dyn SecretStorePort>,
alert_notifier_factory: Arc<dyn AlertNotifierFactory>,
email_sender_factory: Arc<dyn EmailSenderFactory>,
}
impl NotificationService {
@ -26,12 +27,14 @@ impl NotificationService {
config: Arc<ArcSwap<AppConfig>>,
secrets: Arc<dyn SecretStorePort>,
alert_notifier_factory: Arc<dyn AlertNotifierFactory>,
email_sender_factory: Arc<dyn EmailSenderFactory>,
) -> Self {
Self {
notif,
config,
secrets,
alert_notifier_factory,
email_sender_factory,
}
}
@ -88,12 +91,15 @@ impl NotificationService {
/// Send a test email using current SMTP config.
pub fn test_smtp(&self) -> Result<String, Error> {
let smtp_cfg = self.config.load().notification.smtp.clone();
let smtp = SmtpClient::from_config(&smtp_cfg, Some(self.secrets.as_ref()))?.ok_or_else(|| {
MiscError::ValidationError(
"SMTP not configured. Set smtp_host, smtp_port, smtp_username, smtp_password first. \
If smtp_username is not an email address, also set smtp_sender.",
)
})?;
let smtp = self
.email_sender_factory
.build_smtp_sender(&smtp_cfg, Some(self.secrets.as_ref()))?
.ok_or_else(|| {
MiscError::ValidationError(
"SMTP not configured. Set smtp_host, smtp_port, smtp_username, smtp_password first. \
If smtp_username is not an email address, also set smtp_sender.",
)
})?;
if smtp_cfg.recipient.is_empty() {
Err(MiscError::ValidationError("No smtp_recipient configured."))?;

View File

@ -4,10 +4,10 @@ use std::time::{Duration, Instant};
use dashmap::DashMap;
use macros::log;
use crate::core::correlation::correlation_cleanup::capped_cleanup;
use crate::domain::common::config::correlation::CorrelationDetectorParams;
use crate::domain::common::event::{DetectionEvent, DetectionSource};
use crate::domain::detection::attack_type::CanonicalAttackType;
use crate::domain::detection::correlation_cleanup::capped_cleanup;
use crate::domain::detection::log::DetectionLog;
use crate::domain::detection::ml_detection::AlertMessage;

View File

@ -7,13 +7,13 @@ use tokio::sync::broadcast::error::RecvError;
use tokio::sync::{broadcast, mpsc};
use tokio::time::interval;
use crate::core::correlation::botnet::BotnetDetector;
use crate::core::correlation::lateral::LateralMovementDetector;
use crate::core::correlation::scan::ScanDetector;
use crate::domain::common::config::AppConfig;
use crate::domain::common::event::DetectionEvent;
use crate::domain::detection::botnet::BotnetDetector;
use crate::domain::detection::lateral::LateralMovementDetector;
use crate::domain::detection::log::DetectionLog;
use crate::domain::detection::ml_detection::AlertMessage;
use crate::domain::detection::scan::ScanDetector;
/// Coordinates cross-flow correlation detectors (botnet, scan, lateral movement).
/// Subscribes to ML AlertMessage broadcast and feeds enriched DetectionEvents

View File

@ -1,16 +1,16 @@
use std::collections::HashSet;
use std::net::IpAddr;
use std::time::{Duration, Instant};
use dashmap::DashMap;
use macros::log;
use crate::core::correlation::correlation_cleanup::capped_cleanup;
use crate::domain::common::config::correlation::CorrelationDetectorParams;
use crate::domain::common::event::{DetectionEvent, DetectionSource};
use crate::domain::detection::attack_type::CanonicalAttackType;
use crate::domain::detection::correlation_cleanup::capped_cleanup;
use crate::domain::detection::log::DetectionLog;
use crate::domain::detection::ml_detection::AlertMessage;
use crate::utils::ip_address::is_internal_ip;
struct TimedDestSet {
dests: HashSet<String>,
@ -115,39 +115,10 @@ impl LateralMovementDetector {
}
}
/// Check if an IP address string represents a private/internal address.
/// RFC 1918: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
/// RFC 4193: fc00::/7 (IPv6 unique local)
pub fn is_internal_ip(ip_str: &str) -> bool {
let Ok(ip) = ip_str.parse::<IpAddr>() else {
return false;
};
match ip {
IpAddr::V4(v4) => {
let octets = v4.octets();
// 10.0.0.0/8
octets[0] == 10
// 172.16.0.0/12
|| (octets[0] == 172 && (16..=31).contains(&octets[1]))
// 192.168.0.0/16
|| (octets[0] == 192 && octets[1] == 168)
// 127.0.0.0/8 (loopback)
|| octets[0] == 127
}
IpAddr::V6(v6) => {
let segments = v6.segments();
// fc00::/7
(segments[0] & 0xfe00) == 0xfc00
// ::1 (loopback)
|| v6.is_loopback()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::utils::ip_address::is_internal_ip;
#[test]
fn test_internal_ip_detection() {

View File

@ -1 +1,5 @@
pub mod botnet;
pub mod correlation_cleanup;
pub mod engine;
pub mod lateral;
pub mod scan;

View File

@ -4,9 +4,10 @@ use std::time::{Duration, Instant};
use dashmap::DashMap;
use macros::log;
use crate::core::correlation::correlation_cleanup::capped_cleanup;
use crate::domain::common::config::correlation::CorrelationDetectorParams;
use crate::domain::common::event::{DetectionEvent, DetectionSource};
use crate::domain::detection::correlation_cleanup::capped_cleanup;
use crate::domain::detection::attack_type::CanonicalAttackType;
use crate::domain::detection::log::DetectionLog;
use crate::domain::detection::ml_detection::AlertMessage;
@ -80,7 +81,7 @@ impl ScanDetector {
let event = DetectionEvent {
source: DetectionSource::Correlation,
attack_type: "port_scan".to_string(),
attack_type: CanonicalAttackType::PortScan.as_str().to_string(),
confidence: 0.80,
source_ip: key.clone(),
dest_ip: last_dst_ip,

View File

@ -7,9 +7,9 @@ use crate::domain::common::error::Error;
use crate::domain::data_plane::direction::FlowDirection;
use crate::domain::data_plane::error::EbpfError;
use crate::domain::data_plane::list_type::ListType;
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::interface::access_control_admin::AccessControlAdminPort;
use crate::interface::app_repo::AppRepo;
use crate::interface::geo_block_api::GeoBlockPort;
/// Domain service that coordinates ACL changes between DB persistence and eBPF data plane.
/// Atomic write: eBPF first, then DB. If DB fails, rollback eBPF.
@ -36,8 +36,8 @@ impl AclService {
self.access_control.add_ipv4_list(direction, list_type, address)?;
if let Err(e) = self.db.insert_acl_rule(
4,
direction_str(direction),
list_type_str(list_type),
direction.as_str(),
list_type.as_str(),
&address.ip().to_string(),
address.port(),
) {
@ -53,8 +53,8 @@ impl AclService {
self.access_control.add_ipv6_list(direction, list_type, address)?;
if let Err(e) = self.db.insert_acl_rule(
6,
direction_str(direction),
list_type_str(list_type),
direction.as_str(),
list_type.as_str(),
&address.ip().to_string(),
address.port(),
) {
@ -75,8 +75,8 @@ impl AclService {
self.access_control.remove_ipv4_list(direction, list_type, address)?;
if let Err(e) = self.db.delete_acl_rule(
4,
direction_str(direction),
list_type_str(list_type),
direction.as_str(),
list_type.as_str(),
&address.ip().to_string(),
address.port(),
) {
@ -97,8 +97,8 @@ impl AclService {
self.access_control.remove_ipv6_list(direction, list_type, address)?;
if let Err(e) = self.db.delete_acl_rule(
6,
direction_str(direction),
list_type_str(list_type),
direction.as_str(),
list_type.as_str(),
&address.ip().to_string(),
address.port(),
) {
@ -136,17 +136,3 @@ impl AclService {
self.access_control.as_ref()
}
}
fn direction_str(d: FlowDirection) -> &'static str {
match d {
FlowDirection::Source => "source",
FlowDirection::Destination => "destination",
}
}
fn list_type_str(l: ListType) -> &'static str {
match l {
ListType::White => "whitelist",
ListType::Black => "blacklist",
}
}

View File

@ -5,8 +5,8 @@ use dashmap::DashSet;
use crate::domain::common::error::Error;
use crate::domain::common::error::misc::MiscError;
use crate::interface::port::dns_filter_api::DnsFilterPort;
use crate::interface::port::dns_query_filter::DnsQueryFilter;
use crate::interface::dns_filter_api::DnsFilterPort;
use crate::interface::dns_query_filter::DnsQueryFilter;
pub struct DnsFilter {
blacklist: DashSet<DnsName>,

View File

@ -5,8 +5,8 @@ use arc_swap::ArcSwap;
use crate::domain::common::config::AppConfig;
use crate::domain::common::error::Error;
use crate::domain::common::error::misc::MiscError;
use crate::interface::port::app_repo::AppRepo;
use crate::interface::port::dns_filter_api::DnsFilterPort;
use crate::interface::app_repo::AppRepo;
use crate::interface::dns_filter_api::DnsFilterPort;
/// 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.

View File

@ -1,3 +1,4 @@
pub mod acl_service;
pub mod dns_filter;
pub mod dns_filter_service;
pub mod rate_limit_service;

View File

@ -1,8 +1,9 @@
use std::sync::Arc;
use crate::domain::common::error::Error;
use crate::interface::port::app_repo::AppRepo;
use crate::interface::port::rate_limit_api::RateLimitPort;
use crate::domain::common::system::rate_limit_settings::RateLimitSettings;
use crate::interface::app_repo::AppRepo;
use crate::interface::rate_limit_api::RateLimitPort;
/// Domain service that coordinates rate limit config updates between DB and eBPF.
pub struct RateLimitService {
@ -43,5 +44,3 @@ impl RateLimitService {
Ok(())
}
}
use crate::domain::common::system::rate_limit_settings::RateLimitSettings;

View File

@ -1,15 +1,16 @@
use std::sync::Arc;
use std::time::Duration;
use std::time::{Duration, Instant};
use arc_swap::ArcSwap;
use dashmap::DashMap;
use macros::log;
use tokio::sync::broadcast::error::RecvError;
use tokio::sync::{broadcast, mpsc};
use tokio::time::interval;
use crate::domain::common::config::AppConfig;
use crate::domain::common::event::DetectionEvent;
use crate::domain::detection::beaconing::BeaconingState;
use crate::domain::common::event::{DetectionEvent, DetectionSource};
use crate::domain::detection::attack_type::CanonicalAttackType;
use crate::domain::detection::log::DetectionLog;
use crate::domain::detection::ml_detection::AlertMessage;
@ -74,3 +75,224 @@ impl BeaconingDetector {
}
}
}
type FlowTuple = (String, String, u16);
const MAX_TIMESTAMPS_PER_FLOW: usize = 100;
struct CachedFlow {
timestamps: Vec<Instant>,
last_alerted: Option<Instant>,
}
pub struct BeaconingState {
flow_cache: DashMap<FlowTuple, CachedFlow>,
min_observations: usize,
cv_threshold: f64,
max_cache_entries: usize,
expiry_secs: u64,
alert_cooldown_secs: u64,
}
impl BeaconingState {
pub fn new(
min_observations: usize,
cv_threshold: f64,
max_cache_entries: usize,
expiry_secs: u64,
alert_cooldown_secs: u64,
) -> Self {
Self {
flow_cache: DashMap::new(),
min_observations,
cv_threshold,
max_cache_entries,
expiry_secs,
alert_cooldown_secs,
}
}
pub fn record_flow(&self, alert: &AlertMessage) {
let key = (alert.src_ip.clone(), alert.dst_ip.clone(), alert.dst_port);
let now = Instant::now();
let mut entry = self.flow_cache.entry(key).or_insert_with(|| CachedFlow {
timestamps: Vec::new(),
last_alerted: None,
});
entry.timestamps.push(now);
if entry.timestamps.len() > MAX_TIMESTAMPS_PER_FLOW {
let excess = entry.timestamps.len() - MAX_TIMESTAMPS_PER_FLOW;
entry.timestamps.drain(..excess);
}
}
pub fn analyze(&self) -> Vec<DetectionEvent> {
let now = Instant::now();
let cooldown = Duration::from_secs(self.alert_cooldown_secs);
let mut candidates: Vec<(FlowTuple, f64, usize)> = Vec::new();
for entry in self.flow_cache.iter() {
let flow = entry.value();
if flow.timestamps.len() < self.min_observations {
continue;
}
if let Some(last) = flow.last_alerted
&& now.duration_since(last) < cooldown
{
continue;
}
let cv = compute_cv(&flow.timestamps);
if cv < self.cv_threshold {
candidates.push((entry.key().clone(), cv, flow.timestamps.len()));
}
}
let mut events = Vec::new();
for (key, cv, count) in candidates {
let (src_ip, dst_ip, dst_port) = &key;
log!(DetectionLog::BeaconingDetected(
src_ip.clone(),
dst_ip.clone(),
*dst_port,
cv,
count,
));
events.push(DetectionEvent {
source: DetectionSource::Beaconing,
attack_type: CanonicalAttackType::C2Beacon.as_str().to_string(),
confidence: (1.0 - cv / self.cv_threshold) as f32 * 0.5 + 0.5,
source_ip: src_ip.clone(),
dest_ip: dst_ip.clone(),
protocol: 6,
packet_count: count as u64,
flow_duration_us: 0,
ae_score: 0.0,
anomaly_score: 0.0,
c2_score: 0.0,
});
if let Some(mut entry) = self.flow_cache.get_mut(&key) {
entry.last_alerted = Some(now);
}
}
events
}
pub fn cleanup(&self) {
let now = Instant::now();
let expiry = Duration::from_secs(self.expiry_secs);
self.flow_cache.retain(|_, flow| {
flow.timestamps
.last()
.is_some_and(|last| now.duration_since(*last) < expiry)
});
if self.flow_cache.len() > self.max_cache_entries {
let excess = self.flow_cache.len() - self.max_cache_entries;
let keys_to_remove: Vec<FlowTuple> = self.flow_cache.iter().take(excess).map(|e| e.key().clone()).collect();
for key in keys_to_remove {
self.flow_cache.remove(&key);
}
}
}
}
pub fn compute_cv(timestamps: &[Instant]) -> f64 {
if timestamps.len() < 2 {
return f64::MAX;
}
let intervals: Vec<f64> = timestamps
.windows(2)
.map(|w| w[1].duration_since(w[0]).as_secs_f64())
.collect();
let n = intervals.len() as f64;
let mean = intervals.iter().sum::<f64>() / n;
if mean <= 0.0 {
return f64::MAX;
}
let variance = intervals.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / n;
let std = variance.sqrt();
std / mean
}
#[cfg(test)]
mod tests {
use std::time::{Duration, Instant};
use crate::core::detection::beaconing::{BeaconingState, CachedFlow, compute_cv};
use crate::domain::common::event::DetectionSource;
use crate::domain::detection::attack_type::CanonicalAttackType;
#[test]
fn cv_perfectly_periodic() {
let base = Instant::now();
let timestamps: Vec<Instant> = (0..10).map(|i| base + Duration::from_secs(i * 60)).collect();
let cv = compute_cv(&timestamps);
assert!(cv < 0.01, "Perfectly periodic CV should be ~0, got {cv}");
}
#[test]
fn cv_random_high() {
let base = Instant::now();
let timestamps = vec![
base,
base + Duration::from_secs(1),
base + Duration::from_secs(100),
base + Duration::from_secs(101),
base + Duration::from_secs(500),
base + Duration::from_secs(501),
];
let cv = compute_cv(&timestamps);
assert!(cv > 0.5, "Random intervals CV should be high, got {cv}");
}
#[test]
fn cv_with_slight_jitter() {
let base = Instant::now();
let timestamps = vec![
base,
base + Duration::from_millis(60_000),
base + Duration::from_millis(121_000),
base + Duration::from_millis(179_000),
base + Duration::from_millis(240_000),
base + Duration::from_millis(299_000),
];
let cv = compute_cv(&timestamps);
assert!(cv < 0.3, "Slight jitter CV should be < 0.3, got {cv}");
}
#[test]
fn cv_insufficient_data() {
let base = Instant::now();
assert_eq!(compute_cv(&[base]), f64::MAX);
assert_eq!(compute_cv(&[]), f64::MAX);
}
#[test]
fn beaconing_state_detects_periodic_flows() {
let state = BeaconingState::new(5, 0.3, 50_000, 3600, 120);
let base = Instant::now();
let key = ("10.0.0.1".to_string(), "1.2.3.4".to_string(), 443_u16);
state.flow_cache.insert(
key,
CachedFlow {
timestamps: (0..10).map(|i| base + Duration::from_secs(i * 60)).collect(),
last_alerted: None,
},
);
let events = state.analyze();
assert_eq!(events.len(), 1);
assert_eq!(events[0].source, DetectionSource::Beaconing);
assert_eq!(events[0].attack_type, CanonicalAttackType::C2Beacon.as_str());
}
}

View File

@ -1,2 +1,3 @@
pub mod beaconing;
pub mod metrics;
pub mod orchestrator;

View File

@ -9,14 +9,15 @@ use tokio::sync::broadcast;
use tokio::sync::mpsc;
use tokio::time::interval;
use crate::core::detection::metrics::FusionMetrics;
use crate::domain::common::config::AppConfig;
use crate::domain::common::config::constants::{FUSION_AUDIT_ACTION, FUSION_AUDIT_ACTOR};
use crate::domain::common::event::{AuditEvent, DetectionEvent, DetectionSource, ThreatDetectedEvent};
use crate::domain::detection::attack_type::translate;
use crate::domain::detection::fusion_math::{FusionWindowLengths, fused_confidence};
use crate::domain::detection::log::DetectionLog;
use crate::domain::detection::metrics::FusionMetrics;
use crate::interface::port::geo_lookup::GeoLookup;
use crate::domain::detection::ml_detection::AlertMessage;
use crate::interface::geo_lookup::GeoLookup;
/// Per-source record within an in-flight dedup entry. Keeps the strongest
/// confidence per source so multi-hit from one source doesn't inflate the
@ -354,6 +355,46 @@ impl DetectionOrchestrator {
}
}
pub async fn bridge_ml_to_detection(mut rx: broadcast::Receiver<AlertMessage>, tx: mpsc::Sender<DetectionEvent>) {
log!(DetectionLog::MlBridgeStarted);
loop {
match rx.recv().await {
Ok(alert) => {
let raw_type = alert.attack_type.as_deref().unwrap_or("unknown");
if raw_type.eq_ignore_ascii_case("normal") {
continue;
}
let event = DetectionEvent {
source: DetectionSource::ML,
attack_type: raw_type.to_string(),
confidence: alert.confidence,
source_ip: alert.src_ip,
dest_ip: alert.dst_ip,
protocol: alert.protocol,
packet_count: alert.packet_count,
flow_duration_us: alert.flow_duration_us,
ae_score: alert.ae_score,
anomaly_score: alert.anomaly_score,
c2_score: alert.c2_score,
};
if tx.send(event).await.is_err() {
break;
}
}
Err(broadcast::error::RecvError::Lagged(n)) => {
log!(DetectionLog::MlBridgeLagged(n));
}
Err(broadcast::error::RecvError::Closed) => {
log!(DetectionLog::MlAlertChannelClosed);
break;
}
}
}
}
/// Serialize the WORM audit evidence payload for a fused threat emission.
/// Extracted as a free function so tests can cover schema shape without a
/// live broadcast harness.

View File

@ -0,0 +1,165 @@
use std::sync::Arc;
use macros::log;
use serde::Serialize;
use crate::domain::common::error::Error;
use crate::domain::identity::auth::{GROUP_ADMIN, GROUP_VIEWER, ROLE_ADMIN, ROLE_VIEWER};
use crate::domain::identity::error::AuthError;
use crate::domain::identity::password;
use crate::domain::identity::validation::{validate_password, validate_username};
use crate::interface::app_repo::AppRepo;
use crate::interface::token_minter::TokenMinter;
pub const DUMMY_HASH: &str = "$argon2id$v=19$m=19456,t=2,p=1$dW5rbm93bg$QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUE";
pub struct AuthService {
db: Arc<dyn AppRepo>,
jwt: Arc<dyn TokenMinter>,
}
#[derive(Serialize)]
pub struct LoginResult {
pub token: String,
pub role: String,
pub force_password_change: bool,
}
#[derive(Serialize)]
pub struct UserProfile {
pub id: i64,
pub username: String,
pub role: String,
pub permissions: Vec<String>,
pub groups: Vec<String>,
}
pub enum LoginError {
Locked { retry_after_secs: u64 },
InvalidCredentials,
InternalError,
}
pub enum RegisterError {
Validation(&'static str),
InvalidRole,
Forbidden,
HashFailed,
Conflict(Error),
}
impl AuthService {
pub fn new(db: Arc<dyn AppRepo>, jwt: Arc<dyn TokenMinter>) -> Self {
Self { db, jwt }
}
pub fn login(&self, username: &str, raw_password: &str) -> Result<LoginResult, LoginError> {
if let Ok(Some(remaining)) = self.db.check_login_locked(username) {
return Err(LoginError::Locked {
retry_after_secs: remaining,
});
}
let user = match self.db.find_user(username) {
Ok(Some(u)) => u,
_ => {
let _ = password::verify_password(raw_password, DUMMY_HASH);
if let Err(e) = self.db.record_login_failure(username) {
log!(AuthError::LoginFailureTrackingError(e));
}
return Err(LoginError::InvalidCredentials);
}
};
match password::verify_password(raw_password, &user.password_hash) {
Ok(true) => {}
_ => {
if let Err(e) = self.db.record_login_failure(username) {
log!(AuthError::LoginFailureTrackingError(e));
}
return Err(LoginError::InvalidCredentials);
}
}
if let Err(e) = self.db.clear_login_failures(username) {
log!(AuthError::LoginClearError(e));
}
let permissions = self.db.list_user_permissions(user.id).unwrap_or_default();
let role = self.derive_role(user.id);
let token = self
.jwt
.create_token(user.id, &user.username, &role, permissions)
.map_err(|_| LoginError::InternalError)?;
Ok(LoginResult {
token,
role,
force_password_change: user.force_password_change,
})
}
pub fn register(
&self,
username: &str,
raw_password: &str,
role: &str,
caller_role: &str,
) -> Result<i64, RegisterError> {
validate_username(username).map_err(RegisterError::Validation)?;
validate_password(raw_password).map_err(RegisterError::Validation)?;
if role != ROLE_ADMIN && role != ROLE_VIEWER {
return Err(RegisterError::InvalidRole);
}
if role == ROLE_ADMIN && caller_role != ROLE_ADMIN {
return Err(RegisterError::Forbidden);
}
let hash = password::hash_password(raw_password).map_err(|_| RegisterError::HashFailed)?;
let new_id = self
.db
.insert_user(username, &hash, role, false)
.map_err(RegisterError::Conflict)?;
let default_group = if role == ROLE_ADMIN { GROUP_ADMIN } else { GROUP_VIEWER };
if let Ok(groups) = self.db.list_user_groups()
&& let Some(g) = groups.into_iter().find(|g| g.name == default_group)
&& let Err(e) = self.db.set_user_groups(new_id, &[g.id])
{
log!(AuthError::GroupAssignmentFailed(e));
}
Ok(new_id)
}
pub fn user_profile(&self, user_id: i64, username: &str) -> UserProfile {
let groups_raw = self.db.list_groups_for_user(user_id).unwrap_or_default();
let group_names: Vec<String> = groups_raw.iter().map(|g| g.name.clone()).collect();
let role = if group_names.iter().any(|n| n == GROUP_ADMIN) {
ROLE_ADMIN.to_string()
} else {
ROLE_VIEWER.to_string()
};
let permissions = self.db.list_user_permissions(user_id).unwrap_or_default();
UserProfile {
id: user_id,
username: username.to_string(),
role,
permissions,
groups: group_names,
}
}
pub fn derive_role(&self, user_id: i64) -> String {
let groups = self.db.list_groups_for_user(user_id).unwrap_or_default();
if groups.iter().any(|g| g.name == GROUP_ADMIN) {
ROLE_ADMIN.to_string()
} else {
ROLE_VIEWER.to_string()
}
}
}

View File

@ -1,6 +1 @@
pub mod csrf;
pub mod extractor;
pub mod https_redirect;
pub mod jwt;
pub mod middleware;
pub mod setup_guard;
pub mod auth_service;

View File

@ -40,7 +40,7 @@ impl AttackAggregator {
) -> bool {
let now = Instant::now();
let mut detections = self.detections.entry(flow_key.clone()).or_default();
let mut detections = self.detections.entry(*flow_key).or_default();
detections.retain(|(time, _)| now.duration_since(*time) < self.window_duration);
detections.push((now, score));

View File

@ -1,9 +1,13 @@
use std::time::Duration;
use std::collections::VecDeque;
use std::time::{Duration, Instant};
use tokio::sync::{mpsc, oneshot};
use macros::log;
use tokio::sync::{broadcast, mpsc, oneshot};
use tokio::time::interval;
use crate::domain::common::event::DriftDetectedEvent;
use crate::domain::common::log::system::SystemLog;
use crate::domain::detection::drift::{DriftReport, FeatureBaselines};
use crate::domain::detection::drift_detector::DriftDetector;
enum DriftCmd {
Update(Vec<f64>),
@ -51,3 +55,160 @@ impl DriftDetectorHandle {
reply_rx.await.unwrap_or(None)
}
}
pub struct DriftDetector {
snapshots: VecDeque<(Instant, Vec<f64>)>,
num_features: usize,
baselines: Option<FeatureBaselines>,
drift_window: Duration,
max_snapshots: usize,
}
impl DriftDetector {
pub fn new(baselines: Option<FeatureBaselines>, drift_window: Duration, max_snapshots: usize) -> Self {
let num_features = baselines.as_ref().map_or(0, |b| b.names.len());
Self {
snapshots: VecDeque::new(),
num_features,
baselines,
drift_window,
max_snapshots,
}
}
pub fn update(&mut self, features: &[f64]) {
let now = Instant::now();
self.snapshots.push_back((now, features.to_vec()));
self.evict_stale(now);
while self.snapshots.len() > self.max_snapshots {
self.snapshots.pop_front();
}
}
pub fn check_drift(&self) -> Option<DriftReport> {
let baselines = self.baselines.as_ref()?;
if self.snapshots.is_empty() || self.num_features == 0 {
return None;
}
let n = self.snapshots.len() as f64;
let mut sums = vec![0.0_f64; self.num_features];
for (_, features) in &self.snapshots {
for (i, &val) in features.iter().enumerate().take(self.num_features) {
sums[i] += val;
}
}
let mut drifted_features = Vec::new();
let mut max_deviation = 0.0_f64;
for (i, (sum, (bl_mean, bl_std))) in sums
.iter()
.zip(baselines.means.iter().zip(baselines.stds.iter()))
.enumerate()
.take(self.num_features)
{
let current_mean = sum / n;
if *bl_std < 1e-12 {
continue;
}
let deviation = ((current_mean - bl_mean) / bl_std).abs();
if deviation > 3.0 {
drifted_features.push(baselines.names[i].clone());
if deviation > max_deviation {
max_deviation = deviation;
}
}
}
if drifted_features.is_empty() {
None
} else {
Some(DriftReport {
drifted_features,
max_deviation,
})
}
}
fn evict_stale(&mut self, now: Instant) {
while let Some((ts, _)) = self.snapshots.front() {
if now.duration_since(*ts) > self.drift_window {
self.snapshots.pop_front();
} else {
break;
}
}
}
}
pub async fn run_drift_monitor(drift_detector: DriftDetectorHandle, drift_tx: broadcast::Sender<DriftDetectedEvent>) {
let mut tick = interval(Duration::from_secs(60));
loop {
tick.tick().await;
if let Some(report) = drift_detector.check_drift().await {
log!(SystemLog::DriftDetected(
report.drifted_features.len(),
report.max_deviation
));
let event = DriftDetectedEvent {
drifted_features: report.drifted_features,
max_deviation: report.max_deviation,
};
let _ = drift_tx.send(event);
}
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use crate::core::inference::drift_detector::DriftDetector;
use crate::domain::detection::drift::FeatureBaselines;
fn make_baselines(n: usize) -> FeatureBaselines {
FeatureBaselines {
names: (0..n).map(|i| format!("feature_{i}")).collect(),
means: vec![0.0; n],
stds: vec![1.0; n],
}
}
#[test]
fn no_drift_when_within_threshold() {
let baselines = make_baselines(3);
let mut detector = DriftDetector::new(Some(baselines), Duration::from_secs(3600), 10_000);
detector.update(&[1.0, -1.0, 2.0]);
detector.update(&[0.5, -0.5, 1.5]);
assert!(detector.check_drift().is_none());
}
#[test]
fn drift_detected_when_exceeds_threshold() {
let baselines = make_baselines(3);
let mut detector = DriftDetector::new(Some(baselines), Duration::from_secs(3600), 10_000);
detector.update(&[5.0, 0.0, 0.0]);
detector.update(&[5.0, 0.0, 0.0]);
let report = detector.check_drift().unwrap();
assert!(report.drifted_features.contains(&"feature_0".to_string()));
assert!(report.max_deviation > 3.0);
}
#[test]
fn no_baselines_means_no_drift() {
let mut detector = DriftDetector::new(None, Duration::from_secs(3600), 10_000);
detector.update(&[100.0, 200.0]);
assert!(detector.check_drift().is_none());
}
#[test]
fn empty_snapshots_no_drift() {
let baselines = make_baselines(3);
let detector = DriftDetector::new(Some(baselines), Duration::from_secs(3600), 10_000);
assert!(detector.check_drift().is_none());
}
}

View File

@ -16,22 +16,27 @@ use super::alert::MLAlert;
use super::drift_detector::DriftDetectorHandle;
use super::runner::Inference;
use super::traffic_logger::TrafficLogger;
use crate::core::inference::aggregator::AttackAggregator;
use crate::core::inference::flow_tracker::FlowTracker;
use crate::domain::data_plane::user_packet::UserPacket;
use crate::domain::detection::aggregator::AttackAggregator;
use crate::domain::detection::flow_features::FlowFeatures;
use crate::domain::detection::flow_tracker::{FlowData, FlowLimits, FlowTracker};
use crate::domain::detection::flow_tracker::{FlowData, FlowLimits};
use crate::domain::detection::log::MLLog;
use crate::domain::detection::ml_detection::{EngineConfig, FlowKey, InferenceStats};
use crate::interface::port::packet_sink::{PacketSink, PacketSinkFactory};
use crate::domain::detection::ml_detection::{FlowKey, InferenceStats};
use crate::interface::packet_sink::{PacketSink, PacketSinkFactory};
/// Per-queue tracker. With symmetric hash in eBPF, both directions of a flow
/// land on the same queue, so per-queue trackers correctly see bidirectional flows.
/// `FlowTracker` itself is internally synchronized (DashMap), so the
/// per-queue handle is a plain `Arc`.
pub type ThreadTracker = Arc<FlowTracker>;
pub struct EngineConfig {
pub max_flows: usize,
pub min_packets: usize,
pub min_packets_floor: usize,
pub batch_size: usize,
pub inference_interval_secs: u64,
pub aggregator_window_secs: u64,
pub confirmation_window_fraction: u64,
}
pub struct Engine {
trackers: Vec<ThreadTracker>,
trackers: Vec<Arc<FlowTracker>>,
inference_pipeline: Arc<Inference>,
aggregator: AttackAggregator,
drift_detector: DriftDetectorHandle,
@ -65,7 +70,7 @@ impl Engine {
let aggregator = AttackAggregator::new(engine_config.aggregator_window_secs);
let max_flows_per_thread = engine_config.max_flows / (num_threads as usize).max(1);
let trackers: Vec<ThreadTracker> = (0..num_threads)
let trackers: Vec<Arc<FlowTracker>> = (0..num_threads)
.map(|_| Arc::new(FlowTracker::new(max_flows_per_thread, flow_limits)))
.collect();
@ -85,11 +90,11 @@ impl Engine {
}
/// xsk_manager calls this per queue_id; with symmetric hash each queue has its own tracker.
pub fn tracker(&self, queue_id: u32) -> &ThreadTracker {
pub fn tracker(&self, queue_id: u32) -> &Arc<FlowTracker> {
&self.trackers[queue_id as usize % self.trackers.len()]
}
pub fn trackers(&self) -> &[ThreadTracker] {
pub fn trackers(&self) -> &[Arc<FlowTracker>] {
&self.trackers
}
@ -256,7 +261,7 @@ impl Engine {
fn log_traffic(&self, flows: &[FlowData], logger: &TrafficLogger) {
let feature_names = FlowFeatures::all_feature_names_owned();
for flow in flows {
let features = FlowFeatures::extract(flow, &feature_names);
let features = FlowFeatures::extract(flow, feature_names);
logger.log_row(features.to_csv_record());
}
}
@ -337,9 +342,9 @@ impl Engine {
}
}
/// Adapter that exposes one `ThreadTracker` (per AF_XDP queue) as a `PacketSink`.
/// Adapter that exposes one `Arc<FlowTracker>` (per AF_XDP queue) as a `PacketSink`.
struct QueueTrackerSink {
tracker: ThreadTracker,
tracker: Arc<FlowTracker>,
}
impl PacketSink for QueueTrackerSink {

View File

@ -0,0 +1,138 @@
use std::collections::HashMap;
use crate::domain::detection::ml_detection::ClipParams;
#[derive(Debug, Clone)]
pub struct FlowFeatures {
pub features: Vec<f64>,
pub feature_num: usize,
}
impl FlowFeatures {
pub fn normalize(&mut self, means: &[f64], stds: &[f64]) {
for i in 0..self.feature_num {
if stds[i] > 0.0 {
self.features[i] = (self.features[i] - means[i]) / stds[i];
} else {
self.features[i] = 0.0;
}
}
}
pub fn clip(&mut self, clip_min: f64, clip_max: f64) {
for i in 0..self.feature_num {
self.features[i] = self.features[i].max(clip_min).min(clip_max);
}
}
pub fn winsorize(&mut self, clip_params: &HashMap<String, ClipParams>, feature_names: &[String]) {
for (i, feature_name) in feature_names.iter().enumerate() {
if i < self.feature_num
&& let Some(params) = clip_params.get(feature_name)
{
self.features[i] = self.features[i].clamp(params.lower, params.upper);
}
}
}
pub fn all_feature_names() -> Vec<&'static str> {
vec![
"Destination Port",
"Protocol",
"Flow Duration",
"Total Fwd Packets",
"Total Backward Packets",
"Total Length of Fwd Packets",
"Total Length of Bwd Packets",
"Fwd Packet Length Max",
"Fwd Packet Length Min",
"Fwd Packet Length Mean",
"Fwd Packet Length Std",
"Bwd Packet Length Max",
"Bwd Packet Length Min",
"Bwd Packet Length Mean",
"Bwd Packet Length Std",
"Flow Bytes/s",
"Flow Packets/s",
"Flow IAT Mean",
"Flow IAT Std",
"Flow IAT Max",
"Flow IAT Min",
"Fwd IAT Total",
"Fwd IAT Mean",
"Fwd IAT Std",
"Fwd IAT Max",
"Fwd IAT Min",
"Bwd IAT Total",
"Bwd IAT Mean",
"Bwd IAT Std",
"Bwd IAT Max",
"Bwd IAT Min",
"Fwd PSH Flags",
"Bwd PSH Flags",
"Fwd URG Flags",
"Bwd URG Flags",
"Fwd Header Length",
"Bwd Header Length",
"Fwd Packets/s",
"Bwd Packets/s",
"Min Packet Length",
"Max Packet Length",
"Packet Length Mean",
"Packet Length Std",
"Packet Length Variance",
"FIN Flag Count",
"SYN Flag Count",
"RST Flag Count",
"PSH Flag Count",
"ACK Flag Count",
"URG Flag Count",
"CWE Flag Count",
"ECE Flag Count",
"Down/Up Ratio",
"Average Packet Size",
"Avg Fwd Segment Size",
"Avg Bwd Segment Size",
"Fwd Header Length.1",
"Fwd Avg Bytes/Bulk",
"Fwd Avg Packets/Bulk",
"Fwd Avg Bulk Rate",
"Bwd Avg Bytes/Bulk",
"Bwd Avg Packets/Bulk",
"Bwd Avg Bulk Rate",
"Subflow Fwd Packets",
"Subflow Fwd Bytes",
"Subflow Bwd Packets",
"Subflow Bwd Bytes",
"Init_Win_bytes_forward",
"Init_Win_bytes_backward",
"act_data_pkt_fwd",
"min_seg_size_forward",
"Active Mean",
"Active Std",
"Active Max",
"Active Min",
"Idle Mean",
"Idle Std",
"Idle Max",
"Idle Min",
// Phase 2: new features
"fwd_iat_std",
"bwd_iat_std",
"flow_iat_std",
"fwd_bwd_bytes_ratio",
"pkt_len_variance",
"fwd_iat_skewness",
]
}
pub fn all_feature_names_owned() -> Vec<String> {
Self::all_feature_names().iter().map(|s| s.to_string()).collect()
}
pub fn to_csv_record(&self) -> Vec<String> {
let mut record: Vec<String> = self.features.iter().map(|f| f.to_string()).collect();
record.push("BENIGN".to_string());
record
}
}

View File

@ -0,0 +1,244 @@
use std::sync::Arc;
use common::define::tcp_flags::*;
use moka::sync::Cache;
use parking_lot::Mutex;
use crate::domain::data_plane::direction::Direction;
use crate::domain::data_plane::user_packet::UserPacket;
use crate::domain::detection::flow_tracker::{FlowData, FlowLimits};
use crate::domain::detection::ml_detection::FlowKey;
/// Per-flow handle: an `Arc` so map operations stay copy-cheap, with an inner
/// `Mutex` because `add_packet` is a read-modify-write that needs exclusive
/// access. Same-flow packets land on the same XSK queue (symmetric eBPF
/// hash), so this mutex is effectively single-writer; the inference tick
/// briefly contends only when it clones the entry for a snapshot.
type FlowEntry = Arc<Mutex<FlowData>>;
/// Per-queue flow tracker backed by a sharded W-TinyLFU cache (`moka`).
///
/// The hot path (`process_packet`) acquires only the per-shard moka lock
/// and the per-flow entry mutex — never a global tracker lock — so the
/// inference loop's snapshot pass (`get_uninferred_flows`,
/// `cleanup_stale_flows`) can run in parallel without stalling AF_XDP rx.
/// W-TinyLFU's frequency sketch keeps high-rate attack flows resident
/// even when burst noise floods the cache, which a strict-LRU eviction
/// policy would mishandle.
pub struct FlowTracker {
active: Cache<FlowKey, FlowEntry>,
limits: FlowLimits,
}
impl FlowTracker {
pub fn new(max_flows: usize, limits: FlowLimits) -> Self {
let cap = max_flows.max(1) as u64;
Self {
active: Cache::builder().max_capacity(cap).build(),
limits,
}
}
pub fn process_packet(&self, mut packet: UserPacket, is_ingress: bool) {
let packet_key = FlowKey::from_packet(&packet);
let reversed_key = packet_key.reverse();
let (actual_key, is_forward) = if self.active.contains_key(&packet_key) {
(packet_key, true)
} else if self.active.contains_key(&reversed_key) {
(reversed_key, false)
} else {
let syn = packet.tcp_flags & TCP_SYN != 0;
let ack = packet.tcp_flags & TCP_ACK != 0;
if syn && ack {
if is_ingress {
(reversed_key, false)
} else {
(packet_key, true)
}
} else if syn {
(packet_key, true)
} else if is_ingress {
(reversed_key, false)
} else {
(packet_key, true)
}
};
packet.is_forward = is_forward;
let initiator_direction = if is_forward {
if is_ingress {
Direction::Ingress
} else {
Direction::Egress
}
} else if is_ingress {
Direction::Egress
} else {
Direction::Ingress
};
let key_for_init = actual_key;
let entry = self.active.get_with(actual_key, || {
Arc::new(Mutex::new(FlowData::new(key_for_init, &packet, initiator_direction)))
});
entry.lock().add_packet(&packet, &self.limits);
}
pub fn get_flow_stats<T>(&self, convert: impl Fn(&FlowData) -> T) -> Vec<T> {
self.active.iter().map(|(_, entry)| convert(&entry.lock())).collect()
}
pub fn get_uninferred_flows(&self, limit: usize) -> Vec<FlowData> {
let mut result = Vec::new();
for (_, entry) in self.active.iter() {
if result.len() >= limit {
break;
}
let mut flow = entry.lock();
if flow.last_time_us > flow.last_inferred_us {
let snapshot = FlowData {
fwd_packets: std::mem::take(&mut flow.fwd_packets),
bwd_packets: std::mem::take(&mut flow.bwd_packets),
active_periods: std::mem::take(&mut flow.active_periods),
idle_periods: std::mem::take(&mut flow.idle_periods),
..flow.clone()
};
flow.last_inferred_us = flow.last_time_us;
result.push(snapshot);
}
}
result
}
pub fn flow_count(&self) -> usize {
self.active.entry_count() as usize
}
pub fn cleanup_stale_flows(&self, now_us: u64) -> usize {
let mut keys_to_remove = Vec::new();
for (key, entry) in self.active.iter() {
let flow = entry.lock();
let idle = now_us.saturating_sub(flow.last_time_us);
let is_terminated = flow.fin_count > 0 || flow.rst_count > 0;
let stale = if is_terminated {
idle >= self.limits.terminated_timeout_us
} else {
idle >= self.limits.idle_timeout_us
};
if stale {
keys_to_remove.push(*key);
}
}
let mut removed = 0;
for key in keys_to_remove {
self.active.invalidate(&key);
removed += 1;
}
removed
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_limits() -> FlowLimits {
FlowLimits {
max_packets_per_direction: 1000,
max_periods: 1000,
idle_threshold_us: 1_000_000,
bulk_min_packets: 4,
bulk_min_bytes: 1000,
idle_timeout_us: 120_000_000,
terminated_timeout_us: 5_000_000,
}
}
fn make_packet(timestamp_us: u64, tcp_flags: u8) -> UserPacket {
UserPacket {
ip_version: 4,
protocol: 6,
tcp_flags,
src_ip: [10, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
dst_ip: [10, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
src_port: 12345,
dst_port: 80,
packet_length: 100,
payload_length: 60,
header_length: 40,
tcp_window_size: 65535,
timestamp_us,
is_forward: true,
}
}
fn sync_count(tracker: &FlowTracker) -> usize {
tracker.active.run_pending_tasks();
tracker.flow_count()
}
#[test]
fn cleanup_removes_idle_flows() {
let tracker = FlowTracker::new(10000, test_limits());
let base_ts = 1_000_000_000u64;
let pkt = make_packet(base_ts, 0x02);
tracker.process_packet(pkt, false);
assert_eq!(sync_count(&tracker), 1);
let now = base_ts + 130_000_000;
let removed = tracker.cleanup_stale_flows(now);
assert_eq!(removed, 1);
assert_eq!(sync_count(&tracker), 0);
}
#[test]
fn cleanup_keeps_active_flows() {
let tracker = FlowTracker::new(10000, test_limits());
let base_ts = 1_000_000_000u64;
let pkt = make_packet(base_ts, 0x02);
tracker.process_packet(pkt, false);
let now = base_ts + 10_000_000;
let removed = tracker.cleanup_stale_flows(now);
assert_eq!(removed, 0);
assert_eq!(sync_count(&tracker), 1);
}
#[test]
fn cleanup_removes_terminated_flows_after_short_idle() {
let tracker = FlowTracker::new(10000, test_limits());
let base_ts = 1_000_000_000u64;
let pkt1 = make_packet(base_ts, 0x02);
tracker.process_packet(pkt1, false);
let pkt2 = make_packet(base_ts + 1_000_000, 0x01);
tracker.process_packet(pkt2, false);
let now = base_ts + 7_000_000;
let removed = tracker.cleanup_stale_flows(now);
assert_eq!(removed, 1);
assert_eq!(sync_count(&tracker), 0);
}
#[test]
fn cleanup_keeps_recently_terminated_flows() {
let tracker = FlowTracker::new(10000, test_limits());
let base_ts = 1_000_000_000u64;
let pkt1 = make_packet(base_ts, 0x02);
tracker.process_packet(pkt1, false);
let pkt2 = make_packet(base_ts + 1_000_000, 0x01);
tracker.process_packet(pkt2, false);
let now = base_ts + 3_000_000;
let removed = tracker.cleanup_stale_flows(now);
assert_eq!(removed, 0);
assert_eq!(sync_count(&tracker), 1);
}
}

View File

@ -1,8 +1,9 @@
pub mod aggregator;
pub mod alert;
pub mod config_loader;
pub mod drift_detector;
pub mod engine;
pub mod manifest;
pub mod flow_tracker;
pub mod model_adapter;
pub mod model_loader;
pub mod model_watcher;
pub mod runner;

View File

@ -10,10 +10,13 @@ use std::path::PathBuf;
use std::sync::Arc;
use std::time::SystemTime;
use super::manifest::LabelSpec;
use crate::domain::detection::ml_detection::RunnableModel;
use tract_onnx::prelude::{Graph, SimplePlan, TypedFact, TypedOp};
use crate::domain::detection::manifest::LabelSpec;
use crate::domain::detection::model_source::{ModelInfo, ModelSourceStatus};
pub type RunnableModel = SimplePlan<TypedFact, Box<dyn TypedOp>, Graph<TypedFact, Box<dyn TypedOp>>>;
/// Compile-time sanity: `RunnableModel` must be `Send + Sync` because we
/// stuff it inside an `ArcSwap`. If a future `tract-onnx` upgrade silently
/// drops the bounds, this line stops compiling and we catch it before it

View File

@ -16,13 +16,13 @@ use tract_onnx::prelude::*;
use tract_onnx::tract_hir::infer::Factoid;
use tract_onnx::tract_hir::internal::DimLike;
use crate::core::inference::model_adapter::MLModelAdapter;
use crate::core::inference::model_adapter::RunnableModel;
use crate::domain::common::config::constants::MODELS_DIR;
use crate::domain::detection::error::MLError;
use crate::domain::detection::log::MLLog;
use crate::domain::detection::manifest::{AdapterKind, LabelSpec, ModelManifest};
use crate::domain::detection::ml_detection::RunnableModel;
use crate::domain::detection::ml_inference_config::MLInferenceConfig;
use crate::domain::detection::model_adapter::MLModelAdapter;
/// Build an `MLModelAdapter` by loading the ONNX file(s) the manifest names,
/// validating shape against the inference config's feature counts, and

View File

@ -16,12 +16,12 @@ use tokio::time::sleep;
use super::model_loader::build_adapter;
use super::runner::Inference;
use crate::core::inference::model_adapter::ModelSourceState;
use crate::domain::common::config::AppConfig;
use crate::domain::common::config::constants::{MANIFEST_FILENAME, MODELS_DIR, STAGING_SUBDIR};
use crate::domain::detection::error::MLError;
use crate::domain::detection::log::MLLog;
use crate::domain::detection::ml_inference_config::MLInferenceConfig;
use crate::domain::detection::model_adapter::ModelSourceState;
use crate::domain::detection::model_source::ModelInfo;
pub struct ModelWatcher {

View File

@ -20,14 +20,14 @@ use arc_swap::ArcSwap;
use macros::log;
use tract_onnx::prelude::*;
use crate::core::inference::model_adapter::{MLModelAdapter, ModelSourceState, RunnableModel};
use crate::domain::common::config::AppConfig;
use crate::domain::detection::flow_features::FlowFeatures;
use crate::domain::detection::flow_tracker::FlowData;
use crate::domain::detection::log::MLLog;
use crate::domain::detection::manifest::LabelSpec;
use crate::domain::detection::ml_detection::{DetectionResult, RunnableModel};
use crate::domain::detection::ml_detection::DetectionResult;
use crate::domain::detection::ml_inference_config::MLInferenceConfig;
use crate::domain::detection::model_adapter::{MLModelAdapter, ModelSourceState};
use crate::domain::detection::model_source::ModelSourceStatus;
/// (anomaly_scores, per_class_probs, c2_scores) — MultiTask batch output.
@ -80,7 +80,7 @@ impl Inference {
/// Current state snapshot for wire broadcast. Merges the in-memory
/// `qps_recent` atomic into the Active info so the UI sees live QPS.
pub fn current_status(&self) -> ModelSourceStatus {
pub fn model_source_status(&self) -> ModelSourceStatus {
let guard = self.state.load();
let mut status = guard.to_status();
if let ModelSourceStatus::Active { ref mut info } = status {
@ -176,7 +176,10 @@ impl Inference {
let (normal_idx, c2_idx) = (*normal_idx, *c2_idx);
let n = flows.len();
let all_ae_features: Vec<Vec<f32>> = flows.iter().map(|f| self.preprocess_ae_features(f)).collect();
let mut flat_ae: Vec<f32> = Vec::with_capacity(n * n_ae);
for f in flows {
flat_ae.extend(self.preprocess_ae_features(f));
}
let mut ae_scores = Vec::with_capacity(n);
for chunk_start in (0..n).step_by(batch_size) {
@ -184,7 +187,7 @@ impl Inference {
let actual = chunk_end - chunk_start;
let ae_input = tract_ndarray::Array2::<f32>::from_shape_fn((batch_size, n_ae), |(i, j)| {
if i < actual {
all_ae_features[chunk_start + i][j]
flat_ae[(chunk_start + i) * n_ae + j]
} else {
0.0
}
@ -211,7 +214,7 @@ impl Inference {
let cls_input = tract_ndarray::Array2::<f32>::from_shape_fn((batch_size, n_cls), |(i, j)| {
if i < actual {
if j < n_ae {
all_ae_features[chunk_start + i][j]
flat_ae[(chunk_start + i) * n_ae + j]
} else {
ae_scores[chunk_start + i]
}
@ -285,7 +288,7 @@ impl Inference {
} else {
String::new()
},
flow_key_raw: flow.flow_key.clone(),
flow_key_raw: flow.flow_key,
direction: flow.direction,
is_attack,
attack_type: if is_attack { Some(attack_type) } else { None },
@ -346,7 +349,7 @@ impl Inference {
} else {
String::new()
},
flow_key_raw: flow.flow_key.clone(),
flow_key_raw: flow.flow_key,
direction: flow.direction,
is_attack,
attack_type: if is_attack { Some("anomaly".to_string()) } else { None },
@ -415,7 +418,7 @@ impl Inference {
} else {
String::new()
},
flow_key_raw: flow.flow_key.clone(),
flow_key_raw: flow.flow_key,
direction: flow.direction,
is_attack,
attack_type: if is_attack { Some(attack_type) } else { None },

View File

@ -43,7 +43,7 @@ const AUDIT_ACTOR_SYSTEM: &str = "system";
const AUDIT_ACTION_FLOW_TRACE_STOPPED: &str = "flow_trace_stopped";
/// Rotation thresholds. Immutable after logger construction — change
/// requires a full logger restart through `AppServices`.
/// requires a full logger restart through `InferenceRuntime`.
#[derive(Debug, Clone)]
pub struct RotationPolicy {
pub max_file_bytes: u64,

View File

@ -1,7 +1,7 @@
use chrono::Local;
use crate::domain::common::error::Error;
use crate::interface::port::setting::SettingRepo;
use crate::interface::setting::SettingRepo;
/// Generate an HTML weekly report email body.
///

View File

@ -2,115 +2,22 @@ use std::sync::Arc;
use arc_swap::ArcSwap;
use chrono::{Local, Weekday};
use lettre::message::header::ContentType;
use lettre::transport::smtp::authentication::Credentials;
use lettre::{Message, SmtpTransport, Transport};
use macros::log;
use tokio::task::{JoinHandle, spawn_blocking};
use tokio::time::{self, Duration};
use super::email_report as report;
use crate::domain::common::config::AppConfig;
use crate::domain::common::config::notification::SmtpConfig;
use crate::domain::common::error::Error;
use crate::domain::common::error::notification::NotificationError;
use crate::domain::common::log::system::SystemLog;
use crate::interface::port::secret_store::SecretStorePort;
use crate::interface::port::setting::SettingRepo;
pub struct SmtpClient {
host: String,
port: u16,
username: String,
password: String,
/// The sender email address. Falls back to `username` if not set.
sender: String,
}
impl SmtpClient {
pub fn from_config(cfg: &SmtpConfig, secrets: Option<&dyn SecretStorePort>) -> Result<Option<Self>, Error> {
if cfg.host.is_empty() || cfg.username.is_empty() {
return Ok(None);
}
let password = match secrets.and_then(|ss| ss.get_secret("smtp_password").ok().flatten()) {
Some(pw) if !pw.is_empty() => pw,
_ => return Ok(None),
};
let sender = if cfg.sender.is_empty() {
cfg.username.clone()
} else {
cfg.sender.clone()
};
if !sender.contains('@') {
return Ok(None);
}
Ok(Some(Self {
host: cfg.host.clone(),
port: cfg.port,
username: cfg.username.clone(),
password,
sender,
}))
}
/// Send an HTML email using the configured SMTP transport.
pub fn send(&self, to: &str, subject: &str, html_body: &str) -> Result<(), Error> {
let from_addr = self
.sender
.parse()
.map_err(|e| NotificationError::InvalidAddress("from", e))?;
let to_addr = to.parse().map_err(|e| NotificationError::InvalidAddress("to", e))?;
let email = Message::builder()
.from(from_addr)
.to(to_addr)
.subject(subject)
.header(ContentType::TEXT_HTML)
.body(html_body.to_string())
.map_err(NotificationError::MessageBuildFailed)?;
let creds = Credentials::new(self.username.clone(), self.password.clone());
let mailer = match self.port {
465 => {
// Implicit TLS (SMTPS)
SmtpTransport::relay(&self.host)
.map_err(NotificationError::SmtpConnectionFailed)?
.port(self.port)
.credentials(creds)
.build()
}
25 | 587 => {
// STARTTLS (standard submission ports)
SmtpTransport::starttls_relay(&self.host)
.map_err(NotificationError::SmtpConnectionFailed)?
.port(self.port)
.credentials(creds)
.build()
}
_ => {
// Non-standard port — use unencrypted transport with credentials
SmtpTransport::builder_dangerous(&self.host)
.port(self.port)
.credentials(creds)
.build()
}
};
mailer.send(&email).map_err(NotificationError::SmtpSendFailed)?;
Ok(())
}
}
use crate::interface::email_sender::EmailSenderFactory;
use crate::interface::secret_store::SecretStorePort;
use crate::interface::setting::SettingRepo;
pub struct ReportScheduler {
db: Arc<dyn SettingRepo + Send + Sync>,
config: Arc<ArcSwap<AppConfig>>,
secrets: Option<Arc<dyn SecretStorePort>>,
email_sender_factory: Arc<dyn EmailSenderFactory>,
}
impl ReportScheduler {
@ -118,8 +25,14 @@ impl ReportScheduler {
db: Arc<dyn SettingRepo + Send + Sync>,
config: Arc<ArcSwap<AppConfig>>,
secrets: Option<Arc<dyn SecretStorePort>>,
email_sender_factory: Arc<dyn EmailSenderFactory>,
) -> Self {
Self { db, config, secrets }
Self {
db,
config,
secrets,
email_sender_factory,
}
}
/// Spawn a background tokio task that runs the weekly check loop.
@ -127,6 +40,7 @@ impl ReportScheduler {
let db = Arc::clone(&self.db);
let config = Arc::clone(&self.config);
let secrets = self.secrets.clone();
let email_sender_factory = Arc::clone(&self.email_sender_factory);
tokio::spawn(async move {
log!(SystemLog::WeeklyReportSchedulerStarted);
let mut interval = time::interval(Duration::from_secs(3600));
@ -141,7 +55,7 @@ impl ReportScheduler {
let smtp_cfg = config.load().notification.smtp.clone();
let smtp = match SmtpClient::from_config(&smtp_cfg, secrets.as_deref()) {
let smtp = match email_sender_factory.build_smtp_sender(&smtp_cfg, secrets.as_deref()) {
Ok(Some(client)) => client,
Ok(None) => {
log!(SystemLog::SmtpNotConfigured);

View File

@ -1,4 +1,5 @@
pub mod email_report;
pub mod email_scheduler;
pub mod report_data_builder;
pub mod report_engine;
pub mod stats_aggregator;

View File

@ -0,0 +1,124 @@
use chrono::{Duration as ChronoDuration, Local};
use crate::domain::common::error::Error;
use crate::domain::report::data::{
BlockedIpItem, ExecutiveSummary, GeoItem, ReportData, SoarActivity, SystemHealthSummary, ThreatBreakdownItem,
};
use crate::interface::setting::SettingRepo;
pub fn build_report_data(db: &dyn SettingRepo) -> Result<ReportData, Error> {
let now = Local::now();
let period = format!(
"{} — {}",
(now - ChronoDuration::days(7)).format("%Y-%m-%d"),
now.format("%Y-%m-%d")
);
let threats_count: u64 = db
.get_setting("weekly_threats_count")?
.and_then(|v| v.parse().ok())
.unwrap_or(0);
let top_ips: Vec<BlockedIpItem> = db
.get_setting("weekly_top_ips")?
.and_then(|v| serde_json::from_str(&v).ok())
.unwrap_or_else(|| {
vec![BlockedIpItem {
ip: "".into(),
count: 0,
country: "N/A".into(),
}]
});
let breakdown: Vec<ThreatBreakdownItem> = db
.get_setting("weekly_threat_breakdown")?
.and_then(|v| {
let obj: serde_json::Value = serde_json::from_str(&v).ok()?;
let items = obj
.as_object()?
.iter()
.map(|(k, v)| ThreatBreakdownItem {
threat_type: k.clone(),
count: v.as_u64().unwrap_or(0),
trend: "".into(),
})
.collect();
Some(items)
})
.unwrap_or_default();
let health: SystemHealthSummary = db
.get_setting("weekly_system_health")?
.and_then(|v| serde_json::from_str(&v).ok())
.unwrap_or(SystemHealthSummary {
avg_cpu_percent: 0.0,
avg_memory_percent: 0.0,
disk_usage_percent: 0.0,
ebpf_status: "running".into(),
});
let mut recommendations = Vec::new();
if threats_count > 10 {
recommendations.push("Consider enabling geo-blocking for high-risk regions".into());
}
if breakdown.iter().any(|b| b.threat_type == "port_scan" && b.count > 50) {
recommendations.push("Review exposed ports and consider tightening protocol filter rules".into());
}
if recommendations.is_empty() {
recommendations.push("No action needed — your network security posture is healthy".into());
}
let uptime_percent: f64 = db
.get_setting("system_uptime_percent")?
.and_then(|v| v.parse().ok())
.unwrap_or(0.0);
let active_rules: u64 = db
.get_setting("active_rules_count")?
.and_then(|v| v.parse().ok())
.unwrap_or(0);
let geo_distribution: Vec<GeoItem> = db
.get_setting("weekly_geo_distribution")?
.and_then(|v| serde_json::from_str(&v).ok())
.unwrap_or_default();
let auto_blocks: u64 = db
.get_setting("weekly_soar_blocks")?
.and_then(|v| v.parse().ok())
.unwrap_or(0);
let playbooks_triggered: u64 = db
.get_setting("weekly_soar_triggers")?
.and_then(|v| v.parse().ok())
.unwrap_or(0);
let auto_unblocks: u64 = db
.get_setting("weekly_soar_unblocks")?
.and_then(|v| v.parse().ok())
.unwrap_or(0);
let blocked_count: u64 = db
.get_setting("weekly_blocked_count")?
.and_then(|v| v.parse().ok())
.unwrap_or(auto_blocks);
Ok(ReportData {
period,
generated_at: now.format("%Y-%m-%d %H:%M:%S").to_string(),
executive_summary: ExecutiveSummary {
total_threats: threats_count,
total_blocked: blocked_count,
uptime_percent,
active_rules,
},
threat_breakdown: breakdown,
top_blocked_ips: top_ips,
geo_distribution,
soar_activity: SoarActivity {
auto_blocks_executed: auto_blocks,
playbooks_triggered,
auto_unblocks,
},
system_health: health,
recommendations,
})
}

View File

@ -4,17 +4,18 @@ use std::path::PathBuf;
use chrono::Local;
use macros::log;
use super::report_data_builder::build_report_data;
use crate::domain::common::error::Error;
use crate::domain::common::error::io::IOError;
use crate::domain::common::error::misc::MiscError;
use crate::domain::common::log::system::SystemLog;
use crate::domain::report::data::ReportData;
use crate::interface::port::setting::SettingRepo;
use crate::interface::setting::SettingRepo;
/// 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 SettingRepo, output_dir: &str) -> Result<PathBuf, Error> {
let data = ReportData::from_database(db)?;
let data = build_report_data(db)?;
let html = render_html_report(&data);
let html_path = PathBuf::from(output_dir).join(format!(
@ -203,6 +204,6 @@ fn html_escape(s: &str) -> String {
/// Generate report data and format as JSON (for API responses).
pub fn generate_report_json(db: &dyn SettingRepo) -> Result<serde_json::Value, Error> {
let data = ReportData::from_database(db)?;
let data = build_report_data(db)?;
serde_json::to_value(&data).map_err(|e| MiscError::SerializeError(e).into())
}

View File

@ -7,19 +7,23 @@ use tokio::time::{self, Duration};
use crate::domain::common::error::Error;
use crate::domain::common::log::system::SystemLog;
use crate::interface::port::setting::SettingRepo;
use crate::interface::port::stats::StatsRepo;
use crate::interface::health_query::HealthQuery;
use crate::interface::setting::SettingRepo;
use crate::interface::stats::StatsRepo;
/// 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 StatsRepo>,
repo: Arc<dyn SettingRepo + Send + Sync>,
health: Arc<dyn HealthQuery>,
}
impl StatsAggregator {
pub fn new(stats: Arc<dyn StatsRepo>, repo: Arc<dyn SettingRepo + Send + Sync>) -> Self {
Self { stats, repo }
pub fn new(
stats: Arc<dyn StatsRepo>,
repo: Arc<dyn SettingRepo + Send + Sync>,
health: Arc<dyn HealthQuery>,
) -> Self {
Self { stats, repo, health }
}
/// Spawn a background task that runs aggregation every hour.
@ -61,25 +65,23 @@ impl StatsAggregator {
self.repo
.set_setting("weekly_blocked_count", &blocks_count.to_string())?;
// Threat breakdown by type
let breakdown = self.stats.weekly_threat_breakdown(days)?;
let breakdown_json: serde_json::Map<String, serde_json::Value> = breakdown
.into_iter()
.map(|(k, v)| (k, Value::Number(v.into())))
.map(|entry| (entry.threat_type, Value::Number(entry.count.into())))
.collect();
self.repo.set_setting(
"weekly_threat_breakdown",
&serde_json::to_string(&breakdown_json).unwrap_or_else(|_| "{}".to_string()),
)?;
// Top blocked IPs
let top_ips = self.stats.weekly_top_ips(days, 10)?;
let top_ips_json: Vec<serde_json::Value> = top_ips
.into_iter()
.map(|(ip, count)| {
.map(|entry| {
serde_json::json!({
"ip": ip,
"count": count,
"ip": entry.ip,
"count": entry.count,
"country": "N/A",
})
})
@ -93,34 +95,23 @@ impl StatsAggregator {
let active_rules = self.stats.count_acl_rules()?;
self.repo.set_setting("active_rules_count", &active_rules.to_string())?;
// System health snapshot using sysinfo
{
use sysinfo::System;
let mut sys = System::new();
sys.refresh_cpu_all();
sys.refresh_memory();
let cpu_usage = sys.global_cpu_usage() as f64;
let mem_total = sys.total_memory();
let mem_used = sys.used_memory();
let mem_percent = if mem_total > 0 {
(mem_used as f64 / mem_total as f64) * 100.0
} else {
0.0
};
let metrics = self.health.get_current_metrics();
let cpu_usage = metrics.cpu_details.cpu_usage as f64;
let mem_percent = metrics.memory_usage.usage_percent as f64;
let health_json = serde_json::json!({
"avg_cpu_percent": cpu_usage,
"avg_memory_percent": mem_percent,
"disk_usage_percent": 0.0,
"ebpf_status": "running",
"ebpf_status": format!("{:?}", metrics.ebpf),
});
self.repo.set_setting(
"weekly_system_health",
&serde_json::to_string(&health_json).unwrap_or_else(|_| "{}".to_string()),
)?;
// System uptime
let uptime_secs = System::uptime();
let uptime_secs = metrics.uptime_seconds;
let week_secs = (days as u64) * 86400;
let uptime_percent = if uptime_secs >= week_secs {
100.0
@ -151,6 +142,16 @@ impl StatsAggregator {
mod tests {
use super::*;
use crate::adapter::persistence::Database;
use crate::domain::common::config::AppConfig;
use crate::domain::common::system::health::EbpfHealth;
use crate::infrastructure::health::SystemHealth;
fn test_health(db: &Arc<Database>) -> Arc<dyn HealthQuery> {
use arc_swap::ArcSwap;
let config = Arc::new(ArcSwap::from_pointee(AppConfig::from_settings(db.as_ref()).unwrap()));
let ebpf_health = Arc::new(ArcSwap::from_pointee(EbpfHealth::Healthy));
Arc::new(SystemHealth::new(config, ebpf_health).unwrap())
}
#[test]
fn aggregator_writes_weekly_stats() {
@ -163,13 +164,14 @@ 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 health = test_health(&db);
let aggregator = StatsAggregator::new(
db.clone() as Arc<dyn StatsRepo>,
db.clone() as Arc<dyn SettingRepo + Send + Sync>,
health,
);
aggregator.aggregate().expect("aggregation should succeed");
// Verify settings were written
let threats = db.get_setting("weekly_threats_count").unwrap().unwrap();
assert_eq!(threats, "2");
@ -187,17 +189,19 @@ mod tests {
let uptime = db.get_setting("system_uptime_percent").unwrap().unwrap();
assert!(!uptime.is_empty());
let health = db.get_setting("weekly_system_health").unwrap().unwrap();
let health_val: serde_json::Value = serde_json::from_str(&health).unwrap();
assert!(health_val["ebpf_status"].as_str() == Some("running"));
let sys_health = db.get_setting("weekly_system_health").unwrap().unwrap();
let health_val: serde_json::Value = serde_json::from_str(&sys_health).unwrap();
assert!(health_val["avg_cpu_percent"].as_f64().is_some());
}
#[test]
fn aggregator_handles_empty_db() {
let db = Arc::new(Database::new(":memory:").expect("test db"));
let health = test_health(&db);
let aggregator = StatsAggregator::new(
db.clone() as Arc<dyn StatsRepo>,
db.clone() as Arc<dyn SettingRepo + Send + Sync>,
health,
);
aggregator
.aggregate()

View File

@ -17,17 +17,16 @@ use tokio::net::lookup_host;
use tokio::task::spawn_blocking;
use url::Url;
use crate::core::reporting::email_scheduler::SmtpClient;
use crate::core::response::engine::SoarEngine;
use crate::core::response::playbook_service::ip_version_from_str;
use crate::domain::common::error::Error;
use crate::domain::common::event::ThreatDetectedEvent;
use crate::domain::common::notification::AlertPayload;
use crate::domain::response::error::SoarError;
use crate::domain::response::log::SoarLog;
use crate::domain::response::playbook::{Playbook, PlaybookAction};
use crate::interface::port::access_control::AccessControlPort;
use crate::interface::port::app_repo::AppRepo;
use crate::interface::port::notification::AlertPayload;
use crate::interface::access_control::AccessControlPort;
use crate::interface::app_repo::AppRepo;
use crate::utils::ip_address::{ip_version_from_str, is_private_ip};
/// Lower bound on the rate-limit factor — anything below 1% of current
/// would brick traffic flow.
@ -297,7 +296,10 @@ impl SoarEngine {
/// Send email alert.
async fn action_send_email(&self, event: &ThreatDetectedEvent) -> Result<String, Error> {
let smtp_cfg = self.matcher.config.load().notification.smtp.clone();
match SmtpClient::from_config(&smtp_cfg, self.secrets.as_deref())? {
match self
.email_sender_factory
.build_smtp_sender(&smtp_cfg, self.secrets.as_deref())?
{
Some(smtp) => {
let subject = format!(
"[NetGuardia] Threat Alert: {} from {}",
@ -361,7 +363,7 @@ impl SoarEngine {
}
for addr in &addrs {
if crate::domain::response::matcher::is_private_ip(&addr.ip()) {
if is_private_ip(&addr.ip()) {
log!(SoarLog::EventHandlingFailed(format!(
"SSRF blocked: webhook URL '{}' resolved to private IP {}",
url_str,

Some files were not shown because too many files have changed in this diff Show More