mirror of
https://github.com/DaLaw2/NetGuardia.git
synced 2026-08-24 14:10:28 +09:00
Compare commits
2 Commits
7043ec0c43
...
b7ee352ca5
| Author | SHA1 | Date | |
|---|---|---|---|
| b7ee352ca5 | |||
| f623eeba31 |
12
Cargo.lock
generated
12
Cargo.lock
generated
@ -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",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ng-cli"
|
||||
version = "0.1.0"
|
||||
version = "1.0.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "egress-ebpf"
|
||||
version = "0.1.0"
|
||||
version = "1.0.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ingress-ebpf"
|
||||
version = "0.1.0"
|
||||
version = "1.0.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "macros"
|
||||
version = "0.1.0"
|
||||
version = "1.0.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
|
||||
@ -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(())
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "mcp-server"
|
||||
version = "0.1.0"
|
||||
version = "1.0.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "net-guardia"
|
||||
version = "0.1.0"
|
||||
version = "1.0.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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>>,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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)?),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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>>>,
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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()})),
|
||||
}
|
||||
|
||||
@ -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,
|
||||
})),
|
||||
|
||||
@ -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(®.username) {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({"error": msg}));
|
||||
}
|
||||
if let Err(msg) = validate_password(®.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(®.password) {
|
||||
Ok(h) => h,
|
||||
Err(_) => {
|
||||
return HttpResponse::InternalServerError().json(serde_json::json!({"error": "Failed to hash password"}));
|
||||
match auth_svc.register(®.username, ®.password, ®.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(®.username, &hash, ®.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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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()))
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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::*;
|
||||
@ -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();
|
||||
|
||||
|
||||
218
net-guardia/src/adapter/http/middleware/auth.rs
Normal file
218
net-guardia/src/adapter/http/middleware/auth.rs
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
@ -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/")
|
||||
5
net-guardia/src/adapter/http/middleware/mod.rs
Normal file
5
net-guardia/src/adapter/http/middleware/mod.rs
Normal file
@ -0,0 +1,5 @@
|
||||
pub mod auth;
|
||||
pub mod csrf;
|
||||
pub mod extractor;
|
||||
pub mod https_redirect;
|
||||
pub mod setup_guard;
|
||||
@ -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.
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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")
|
||||
|
||||
23
net-guardia/src/adapter/http/ready.rs
Normal file
23
net-guardia/src/adapter/http/ready.rs
Normal 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,
|
||||
}))
|
||||
}
|
||||
@ -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")
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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'"}));
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
2
net-guardia/src/adapter/model_loading/mod.rs
Normal file
2
net-guardia/src/adapter/model_loading/mod.rs
Normal file
@ -0,0 +1,2 @@
|
||||
pub mod config_loader;
|
||||
pub mod manifest;
|
||||
1
net-guardia/src/adapter/notification/mod.rs
Normal file
1
net-guardia/src/adapter/notification/mod.rs
Normal file
@ -0,0 +1 @@
|
||||
pub mod smtp;
|
||||
105
net-guardia/src/adapter/notification/smtp.rs
Normal file
105
net-guardia/src/adapter/notification/smtp.rs
Normal 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>))
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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"];
|
||||
|
||||
|
||||
@ -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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,3 +1,4 @@
|
||||
pub mod config_service;
|
||||
pub mod log_buffer;
|
||||
pub mod enforce_mode_handler;
|
||||
pub mod notification_service;
|
||||
pub mod statistics;
|
||||
|
||||
@ -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."))?;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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() {
|
||||
@ -1 +1,5 @@
|
||||
pub mod botnet;
|
||||
pub mod correlation_cleanup;
|
||||
pub mod engine;
|
||||
pub mod lateral;
|
||||
pub mod scan;
|
||||
|
||||
@ -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,
|
||||
@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>,
|
||||
@ -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.
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
pub mod acl_service;
|
||||
pub mod dns_filter;
|
||||
pub mod dns_filter_service;
|
||||
pub mod rate_limit_service;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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(×tamps);
|
||||
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(×tamps);
|
||||
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(×tamps);
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,2 +1,3 @@
|
||||
pub mod beaconing;
|
||||
pub mod metrics;
|
||||
pub mod orchestrator;
|
||||
|
||||
@ -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.
|
||||
|
||||
165
net-guardia/src/core/identity/auth_service.rs
Normal file
165
net-guardia/src/core/identity/auth_service.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
138
net-guardia/src/core/inference/flow_features.rs
Normal file
138
net-guardia/src/core/inference/flow_features.rs
Normal 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
|
||||
}
|
||||
}
|
||||
244
net-guardia/src/core/inference/flow_tracker.rs
Normal file
244
net-guardia/src/core/inference/flow_tracker.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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.
|
||||
///
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
124
net-guardia/src/core/reporting/report_data_builder.rs
Normal file
124
net-guardia/src/core/reporting/report_data_builder.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
@ -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())
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user