Compare commits

...

3 Commits

Author SHA1 Message Date
c2c4ed5b23 fix: CI Failed 2026-04-25 19:18:56 +08:00
965480fb9e fix: CI Failed 2026-04-25 19:18:53 +08:00
6ca57dbe10 chore: Update something 2026-04-25 18:40:43 +08:00
56 changed files with 928 additions and 1176 deletions

@ -1 +1 @@
Subproject commit 00d347c5eae3ed32f595b0a3553601f16bedfa7e
Subproject commit 9f1621ca8f2c9fe4e3a5e315abbc0b6c5f16498d

View File

@ -11,6 +11,7 @@ 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;
pub struct DropMonitor {
broadcast_tx: broadcast::Sender<DropEventMessage>,
@ -30,18 +31,13 @@ impl DropMonitor {
self.broadcast_tx.subscribe()
}
pub fn get_counters(&self) -> DropCounters {
self.counters.snapshot()
}
/// 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.
pub fn record_userspace_drop_count_only(&self, reason: u8) {
self.counters.total.fetch_add(1, Ordering::Relaxed);
let bucket = match reason {
fn bucket_for(&self, reason: u8) -> Option<&std::sync::atomic::AtomicU64> {
match reason {
DROP_REASON_ACL_BLACKLIST => Some(&self.counters.acl_blacklist),
DROP_REASON_RATE_LIMIT_PKT => Some(&self.counters.rate_limit_pkt),
DROP_REASON_RATE_LIMIT_SYN => Some(&self.counters.rate_limit_syn),
@ -51,28 +47,22 @@ impl DropMonitor {
DROP_REASON_DNS_BLACKLIST => Some(&self.counters.dns_blacklist),
DROP_REASON_GEO_BLOCK => Some(&self.counters.geo_block),
_ => None,
};
if let Some(counter) = bucket {
}
}
fn record_drop(&self, reason: u8) {
self.counters.total.fetch_add(1, Ordering::Relaxed);
if let Some(counter) = self.bucket_for(reason) {
counter.fetch_add(1, Ordering::Relaxed);
}
}
pub fn record_userspace_drop_count_only(&self, reason: u8) {
self.record_drop(reason);
}
fn process_event(&self, raw: &RawDropEvent) {
self.counters.total.fetch_add(1, Ordering::Relaxed);
let bucket = match raw.reason {
DROP_REASON_ACL_BLACKLIST => Some(&self.counters.acl_blacklist),
DROP_REASON_RATE_LIMIT_PKT => Some(&self.counters.rate_limit_pkt),
DROP_REASON_RATE_LIMIT_SYN => Some(&self.counters.rate_limit_syn),
DROP_REASON_RATE_LIMIT_UDP => Some(&self.counters.rate_limit_udp),
DROP_REASON_RATE_LIMIT_DNS => Some(&self.counters.rate_limit_dns),
DROP_REASON_PROTOCOL_FILTER => Some(&self.counters.protocol_filter),
DROP_REASON_DNS_BLACKLIST => Some(&self.counters.dns_blacklist),
DROP_REASON_GEO_BLOCK => Some(&self.counters.geo_block),
_ => None,
};
if let Some(counter) = bucket {
counter.fetch_add(1, Ordering::Relaxed);
}
self.record_drop(raw.reason);
let reason_str = reason_to_str(raw.reason);
@ -94,6 +84,12 @@ impl DropMonitor {
}
}
impl DropStatsPort for DropMonitor {
fn get_counters(&self) -> DropCounters {
self.counters.snapshot()
}
}
fn format_ips(raw: &RawDropEvent) -> (String, String) {
match raw.ip_version {
4 => {

View File

@ -11,6 +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;
pub struct ProtocolFilter {
ipv4_http_service: RwLock<HttpServiceWrapper<AddrPortV4>>,
@ -176,6 +177,116 @@ impl ProtocolFilter {
}
}
impl ProtocolFilterPort for ProtocolFilter {
fn get_ipv4_http_service(&self) -> HashMap<SocketAddrV4, Vec<HttpMethod>> {
self.get_ipv4_http_service()
}
fn get_ipv6_http_service(&self) -> HashMap<SocketAddrV6, Vec<HttpMethod>> {
self.get_ipv6_http_service()
}
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 is_ssh_white_list_enable(&self) -> bool {
self.is_ssh_white_list_enable()
}
fn enable_ssh_white_list(&self) -> Result<(), Error> {
self.enable_ssh_white_list()
}
fn disable_ssh_white_list(&self) -> Result<(), Error> {
self.disable_ssh_white_list()
}
fn get_ipv4_ssh_service(&self) -> Vec<SocketAddrV4> {
self.get_ipv4_ssh_service()
}
fn get_ipv6_ssh_service(&self) -> Vec<SocketAddrV6> {
self.get_ipv6_ssh_service()
}
fn add_ipv4_ssh_service(&self, addr: SocketAddrV4) -> Result<(), Error> {
self.add_ipv4_ssh_service(addr)
}
fn add_ipv6_ssh_service(&self, addr: SocketAddrV6) -> Result<(), Error> {
self.add_ipv6_ssh_service(addr)
}
fn remove_ipv4_ssh_service(&self, addr: SocketAddrV4) -> Result<(), Error> {
self.remove_ipv4_ssh_service(addr)
}
fn remove_ipv6_ssh_service(&self, addr: SocketAddrV6) -> Result<(), Error> {
self.remove_ipv6_ssh_service(addr)
}
fn get_ipv4_ssh_white_list(&self) -> Vec<Ipv4Addr> {
self.get_ipv4_ssh_white_list()
}
fn get_ipv6_ssh_white_list(&self) -> Vec<Ipv6Addr> {
self.get_ipv6_ssh_white_list()
}
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)
}
}
struct WhiteListControl {
map: Option<AyaArray<MapData, PlaceHolder>>,
}

View File

@ -123,7 +123,6 @@ impl XskManager {
network.clone(),
queue_id,
&network.ingress_ifname,
&network.egress_ifname,
Direction::Ingress,
sink.clone(),
dns_filter.clone(),
@ -134,7 +133,6 @@ impl XskManager {
network.clone(),
queue_id,
&network.egress_ifname,
&network.ingress_ifname,
Direction::Egress,
sink,
None,
@ -194,7 +192,6 @@ impl XskPair {
config: EbpfConfig,
queue_id: u32,
rx_ifname: &str,
_tx_ifname: &str,
direction: Direction,
sink: Option<Arc<dyn PacketSink>>,
dns_filter: Option<Arc<dyn DnsQueryFilter>>,

View File

@ -5,6 +5,7 @@ use serde::Deserialize;
use crate::adapter::http::response::ok_or_error;
use crate::core::identity::extractor::AuthClaims;
use crate::core::identity::jwt::JwtService;
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;
@ -118,10 +119,10 @@ async fn login(body: web::Json<LoginRequest>, db: web::Data<Repo>, jwt: web::Dat
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 == "Administrator") {
"admin".to_string()
let role = if groups.iter().any(|(_id, name, _desc, _perms)| name == GROUP_ADMIN) {
ROLE_ADMIN.to_string()
} else {
"viewer".to_string()
ROLE_VIEWER.to_string()
};
match jwt.create_token(id, &username, &role, permissions) {
@ -146,12 +147,12 @@ async fn register(auth: AuthClaims, body: web::Json<RegisterRequest>, db: web::D
}
// Validate role
if reg.role != "admin" && reg.role != "viewer" {
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 == "admin" && auth.role != "admin" {
if reg.role == ROLE_ADMIN && auth.role != ROLE_ADMIN {
return HttpResponse::Forbidden()
.json(serde_json::json!({"error": "Only administrators can create admin accounts"}));
}
@ -166,7 +167,11 @@ async fn register(auth: AuthClaims, body: web::Json<RegisterRequest>, db: web::D
match db.insert_user(&reg.username, &hash, &reg.role, false) {
Ok(new_user_id) => {
// Auto-assign to default group based on role
let default_group_name = if reg.role == "admin" { "Administrator" } else { "Viewer" };
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)
@ -186,10 +191,10 @@ async fn me(auth: AuthClaims, db: web::Data<Repo>) -> impl Responder {
.iter()
.map(|(_id, name, _desc, _perms)| name.clone())
.collect();
let role = if group_names.iter().any(|n| n == "Administrator") {
"admin"
let role = if group_names.iter().any(|n| n == GROUP_ADMIN) {
ROLE_ADMIN
} else {
"viewer"
ROLE_VIEWER
};
let permissions = db.list_user_permissions(auth.sub).unwrap_or_default();
HttpResponse::Ok().json(serde_json::json!({
@ -257,10 +262,10 @@ async fn list_users(_auth: AuthClaims, db: web::Data<Repo>) -> impl Responder {
.iter()
.map(|(gid, name)| serde_json::json!({"id": gid, "name": name}))
.collect();
let role = if user_groups.iter().any(|(_id, name)| name == "Administrator") {
"admin"
let role = if user_groups.iter().any(|(_id, name)| name == GROUP_ADMIN) {
ROLE_ADMIN
} else {
"viewer"
ROLE_VIEWER
};
serde_json::json!({
"id": id,
@ -288,7 +293,7 @@ async fn delete_user(_auth: AuthClaims, path: web::Path<i64>, db: web::Data<Repo
// Protect the built-in admin account
match db.find_user_by_id(user_id) {
Ok(Some((_, ref username, _, _, _))) if username == "admin" => {
Ok(Some((_, ref username, _, _, _))) if username == DEFAULT_ADMIN_USERNAME => {
return HttpResponse::Forbidden()
.json(serde_json::json!({"error": "Cannot delete the built-in admin account"}));
}
@ -316,7 +321,7 @@ async fn update_role(
}
let role = match body.get("role").and_then(|v| v.as_str()) {
Some(r) if r == "admin" || r == "viewer" => r,
Some(r) if r == ROLE_ADMIN || r == ROLE_VIEWER => r,
_ => {
return HttpResponse::BadRequest().json(serde_json::json!({"error": "Role must be 'admin' or 'viewer'"}));
}
@ -472,7 +477,7 @@ async fn update_group(
let existing = match db.get_user_group(group_id) {
Ok(Some(g)) => {
// Protect built-in groups
if g.1 == "Administrator" || g.1 == "Viewer" {
if g.1 == GROUP_ADMIN || g.1 == GROUP_VIEWER {
return HttpResponse::Forbidden().json(serde_json::json!({"error": "Cannot modify built-in groups"}));
}
g
@ -508,7 +513,7 @@ async fn delete_group(_auth: AuthClaims, path: web::Path<i64>, db: web::Data<Rep
// Protect built-in groups
match db.get_user_group(group_id) {
Ok(Some(g)) if g.1 == "Administrator" || g.1 == "Viewer" => {
Ok(Some(g)) if g.1 == GROUP_ADMIN || g.1 == GROUP_VIEWER => {
return HttpResponse::Forbidden().json(serde_json::json!({"error": "Cannot delete built-in groups"}));
}
_ => {}
@ -531,7 +536,7 @@ async fn set_user_groups(
// Protect the default admin account
match db.find_user_by_id(user_id) {
Ok(Some((_, ref username, _, _, _))) if username == "admin" => {
Ok(Some((_, ref username, _, _, _))) if username == DEFAULT_ADMIN_USERNAME => {
return HttpResponse::Forbidden()
.json(serde_json::json!({"error": "Cannot modify groups for the built-in admin account"}));
}

View File

@ -4,9 +4,9 @@ use actix_web::{HttpResponse, Responder, Scope, web};
use common::model::http_method::HttpMethod;
use serde::Deserialize;
use crate::adapter::ebpf::protocol_filter::ProtocolFilter;
use crate::adapter::http::response::ok_or_error;
use crate::core::data_plane::dns_filter_service::DnsFilterService;
use crate::interface::port::protocol_filter::ProtocolFilterPort;
pub fn initialize() -> Scope {
web::scope("/filter")
@ -16,7 +16,7 @@ pub fn initialize() -> Scope {
}
#[derive(Deserialize)]
struct DnsDomainsPayload {
struct DnsDomainsRequest {
domains: Vec<String>,
}
@ -34,7 +34,7 @@ async fn get_dns_blacklist(service: web::Data<DnsFilterService>) -> impl Respond
}
async fn add_dns_blacklist(
payload: web::Json<DnsDomainsPayload>,
payload: web::Json<DnsDomainsRequest>,
service: web::Data<DnsFilterService>,
) -> impl Responder {
let domains = payload.into_inner().domains;
@ -45,7 +45,7 @@ async fn add_dns_blacklist(
}
async fn remove_dns_blacklist(
payload: web::Json<DnsDomainsPayload>,
payload: web::Json<DnsDomainsRequest>,
service: web::Data<DnsFilterService>,
) -> impl Responder {
let domains = payload.into_inner().domains;
@ -102,17 +102,17 @@ fn ssh_blacklist_scope() -> Scope {
// --- HTTP service handlers ---
async fn get_ipv4_http_service(service: web::Data<ProtocolFilter>) -> impl Responder {
async fn get_ipv4_http_service(service: web::Data<dyn ProtocolFilterPort>) -> impl Responder {
HttpResponse::Ok().json(service.get_ipv4_http_service())
}
async fn get_ipv6_http_service(service: web::Data<ProtocolFilter>) -> impl Responder {
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>)>,
service: web::Data<ProtocolFilter>,
service: web::Data<dyn ProtocolFilterPort>,
) -> impl Responder {
let (addr, methods) = payload.into_inner();
ok_or_error(service.add_ipv4_http_service(addr, methods))
@ -120,7 +120,7 @@ async fn add_ipv4_http_service(
async fn add_ipv6_http_service(
payload: web::Json<(SocketAddrV6, Vec<HttpMethod>)>,
service: web::Data<ProtocolFilter>,
service: web::Data<dyn ProtocolFilterPort>,
) -> impl Responder {
let (addr, methods) = payload.into_inner();
ok_or_error(service.add_ipv6_http_service(addr, methods))
@ -128,7 +128,7 @@ async fn add_ipv6_http_service(
async fn remove_ipv4_http_service(
payload: web::Json<(SocketAddrV4, Vec<HttpMethod>)>,
service: web::Data<ProtocolFilter>,
service: web::Data<dyn ProtocolFilterPort>,
) -> impl Responder {
let (addr, methods) = payload.into_inner();
ok_or_error(service.remove_ipv4_http_service(addr, methods))
@ -136,7 +136,7 @@ async fn remove_ipv4_http_service(
async fn remove_ipv6_http_service(
payload: web::Json<(SocketAddrV6, Vec<HttpMethod>)>,
service: web::Data<ProtocolFilter>,
service: web::Data<dyn ProtocolFilterPort>,
) -> impl Responder {
let (addr, methods) = payload.into_inner();
ok_or_error(service.remove_ipv6_http_service(addr, methods))
@ -144,108 +144,126 @@ async fn remove_ipv6_http_service(
// --- SSH service handlers ---
async fn get_ipv4_ssh_service(service: web::Data<ProtocolFilter>) -> impl Responder {
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<ProtocolFilter>) -> impl Responder {
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<ProtocolFilter>) -> impl Responder {
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<ProtocolFilter>) -> impl Responder {
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<ProtocolFilter>,
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<ProtocolFilter>,
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<ProtocolFilter>) -> impl Responder {
async fn is_ssh_white_list_enable(service: web::Data<dyn ProtocolFilterPort>) -> impl Responder {
HttpResponse::Ok().json(service.is_ssh_white_list_enable())
}
async fn enable_ssh_white_list(service: web::Data<ProtocolFilter>) -> impl Responder {
async fn enable_ssh_white_list(service: web::Data<dyn ProtocolFilterPort>) -> impl Responder {
ok_or_error(service.enable_ssh_white_list())
}
async fn disable_ssh_white_list(service: web::Data<ProtocolFilter>) -> impl Responder {
async fn disable_ssh_white_list(service: web::Data<dyn ProtocolFilterPort>) -> impl Responder {
ok_or_error(service.disable_ssh_white_list())
}
async fn get_ipv4_ssh_white_list(service: web::Data<ProtocolFilter>) -> impl Responder {
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_ipv6_ssh_white_list(service: web::Data<ProtocolFilter>) -> impl Responder {
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>, service: web::Data<ProtocolFilter>) -> impl Responder {
async fn add_ipv4_ssh_white_list(
ip_addr: web::Json<Ipv4Addr>,
service: web::Data<dyn ProtocolFilterPort>,
) -> impl Responder {
ok_or_error(service.add_ipv4_ssh_white_list(ip_addr.into_inner()))
}
async fn add_ipv6_ssh_white_list(ip_addr: web::Json<Ipv6Addr>, service: web::Data<ProtocolFilter>) -> impl Responder {
async fn add_ipv6_ssh_white_list(
ip_addr: web::Json<Ipv6Addr>,
service: web::Data<dyn ProtocolFilterPort>,
) -> impl Responder {
ok_or_error(service.add_ipv6_ssh_white_list(ip_addr.into_inner()))
}
async fn remove_ipv4_ssh_white_list(
ip_addr: web::Json<Ipv4Addr>,
service: web::Data<ProtocolFilter>,
service: web::Data<dyn ProtocolFilterPort>,
) -> impl Responder {
ok_or_error(service.remove_ipv4_ssh_white_list(ip_addr.into_inner()))
}
async fn remove_ipv6_ssh_white_list(
ip_addr: web::Json<Ipv6Addr>,
service: web::Data<ProtocolFilter>,
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<ProtocolFilter>) -> impl Responder {
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<ProtocolFilter>) -> impl Responder {
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<ProtocolFilter>) -> impl Responder {
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<ProtocolFilter>) -> impl Responder {
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<ProtocolFilter>,
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<ProtocolFilter>,
service: web::Data<dyn ProtocolFilterPort>,
) -> impl Responder {
ok_or_error(service.remove_ipv6_ssh_black_list(ip_addr.into_inner()))
}

View File

@ -3,7 +3,7 @@ use tokio::sync::broadcast;
use crate::core::identity::extractor::AuthClaims;
use crate::core::inference::engine::Engine;
use crate::core::inference::inference::Inference;
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;

View File

@ -36,8 +36,8 @@ use tokio::task;
use uuid::Uuid;
use crate::core::identity::extractor::AuthClaims;
use crate::core::inference::inference::Inference;
use crate::core::inference::model_loader::build_adapter;
use crate::core::inference::runner::Inference;
use crate::domain::common::config::AppConfig;
use crate::domain::common::config::constants::{
AUDIT_ACTOR_SECURITY_ADMIN_PREFIX, MANIFEST_FILENAME, MODELS_DIR, STAGING_SUBDIR,
@ -164,16 +164,16 @@ async fn upload(
return e.into_response();
}
};
let outcome = validate_and_promote(
&staging_dir,
&summary,
inference.get_ref(),
audit_tx.get_ref(),
promote_lock.get_ref(),
&claims.username,
let outcome = validate_and_promote(&PromoteContext {
staging_dir: &staging_dir,
summary: &summary,
inference: inference.get_ref(),
audit_tx: audit_tx.get_ref(),
promote_lock: promote_lock.get_ref(),
actor_username: &claims.username,
batch_size,
onnx_load_timeout,
)
})
.await;
// Always sweep staging — successful promote renames the files out,
@ -512,16 +512,26 @@ pub fn looks_like_onnx(first_chunk: &[u8]) -> bool {
/// logged but does not roll back the rename; the chain prefers a
/// missing audit entry to a rolled-back promote that a downstream
/// subscriber may already have reacted to.
async fn validate_and_promote(
staging_dir: &Path,
summary: &UploadSummary,
inference: &Inference,
audit_tx: &broadcast::Sender<AuditEvent>,
promote_lock: &PromoteGate,
actor_username: &str,
struct PromoteContext<'a> {
staging_dir: &'a Path,
summary: &'a UploadSummary,
inference: &'a Inference,
audit_tx: &'a broadcast::Sender<AuditEvent>,
promote_lock: &'a PromoteGate,
actor_username: &'a str,
batch_size: usize,
onnx_load_timeout: Duration,
) -> Result<PromoteReport, PromoteError> {
}
async fn validate_and_promote(ctx: &PromoteContext<'_>) -> Result<PromoteReport, PromoteError> {
let staging_dir = ctx.staging_dir;
let summary = ctx.summary;
let inference = ctx.inference;
let audit_tx = ctx.audit_tx;
let promote_lock = ctx.promote_lock;
let actor_username = ctx.actor_username;
let batch_size = ctx.batch_size;
let onnx_load_timeout = ctx.onnx_load_timeout;
let staging_manifest = staging_dir.join(MANIFEST_FILENAME);
// Structural manifest validation. The full `build_adapter` pipeline

View File

@ -11,6 +11,7 @@ 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::secret_store::SecretStore;
use crate::interface::port::secret_store::SecretStorePort;
@ -143,7 +144,7 @@ async fn complete_setup(
match password::hash_password(&body.admin_password) {
Ok(hash) => {
// Find admin user and update password
if let Ok(Some(user)) = db.find_user("admin") {
if let Ok(Some(user)) = db.find_user(DEFAULT_ADMIN_USERNAME) {
if let Err(e) = db.update_user_password(user.0, &hash) {
log!(SystemError::SetupPasswordUpdateFailed(e));
}

View File

@ -1,12 +1,14 @@
use std::str::FromStr;
use actix_web::{HttpResponse, Scope, web};
use arc_swap::ArcSwap;
use serde::Deserialize;
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;
use crate::domain::common::event::{DetectionSource, ThreatDetectedEvent};
use crate::domain::response::playbook_data::{CreateConditionInput, CreatePlaybookInput};
@ -36,6 +38,47 @@ struct CreateConditionRequest {
value2: Option<String>,
}
fn map_request_to_input(body: &CreatePlaybookRequest, fallback_cooldown: i64) -> CreatePlaybookInput {
let actions = body
.actions
.iter()
.map(|a| {
let params_str = a
.params
.as_ref()
.map(|v| serde_json::to_string(v).unwrap_or_else(|_| "{}".into()))
.unwrap_or_else(|| "{}".into());
(a.action_type.clone(), params_str)
})
.collect();
let conditions = body
.conditions
.as_deref()
.unwrap_or_default()
.iter()
.map(|c| {
CreateConditionInput::new(
c.condition_type.clone(),
c.operator.clone(),
c.value.clone(),
c.value2.clone(),
)
})
.collect();
CreatePlaybookInput {
name: body.name.clone(),
trigger_event: body.trigger_event.clone(),
condition_threshold: body.condition_threshold,
condition_count: body.condition_count,
condition_window_secs: body.condition_window_secs,
cooldown_secs: body.cooldown_secs.unwrap_or(fallback_cooldown),
actions,
conditions,
}
}
pub fn initialize() -> Scope {
web::scope("/soar")
.route("/playbooks", web::get().to(list_playbooks))
@ -53,100 +96,16 @@ pub fn initialize() -> Scope {
}
async fn list_playbooks(_auth: AuthClaims, svc: web::Data<PlaybookService>) -> HttpResponse {
match svc.list_playbooks() {
Ok(playbooks) => {
let responses: Vec<serde_json::Value> = playbooks
.into_iter()
.map(|pb| {
let actions: Vec<serde_json::Value> = pb
.actions
.into_iter()
.map(|a| {
serde_json::json!({
"id": a.id,
"action_order": a.action_order,
"action_type": a.action_type,
"params": a.params,
})
})
.collect();
let conditions: Vec<serde_json::Value> = pb
.conditions
.into_iter()
.map(|c| {
serde_json::json!({
"id": c.id,
"condition_type": c.condition_type,
"operator": c.operator,
"value": c.value,
"value2": c.value2,
})
})
.collect();
serde_json::json!({
"id": pb.id,
"name": pb.name,
"enabled": pb.enabled,
"trigger_event": pb.trigger_event,
"condition_threshold": pb.condition_threshold,
"condition_count": pb.condition_count,
"condition_window_secs": pb.condition_window_secs,
"cooldown_secs": pb.cooldown_secs,
"actions": actions,
"conditions": conditions,
})
})
.collect();
HttpResponse::Ok().json(responses)
}
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
ok_json_or_error(svc.list_playbooks())
}
async fn create_playbook(
_auth: AuthClaims,
svc: web::Data<PlaybookService>,
app_config: web::Data<ArcSwap<AppConfig>>,
body: web::Json<CreatePlaybookRequest>,
) -> HttpResponse {
let actions: Vec<(String, String)> = body
.actions
.iter()
.map(|a| {
let params_str = a
.params
.as_ref()
.map(|v| serde_json::to_string(v).unwrap_or_else(|_| "{}".into()))
.unwrap_or_else(|| "{}".into());
(a.action_type.clone(), params_str)
})
.collect();
let conditions: Vec<CreateConditionInput> = body
.conditions
.as_deref()
.unwrap_or_default()
.iter()
.map(|c| {
CreateConditionInput::new(
c.condition_type.clone(),
c.operator.clone(),
c.value.clone(),
c.value2.clone(),
)
})
.collect();
let input = CreatePlaybookInput {
name: body.name.clone(),
trigger_event: body.trigger_event.clone(),
condition_threshold: body.condition_threshold,
condition_count: body.condition_count,
condition_window_secs: body.condition_window_secs,
cooldown_secs: body.cooldown_secs.unwrap_or(300),
actions,
conditions,
};
let input = map_request_to_input(&body, app_config.load().soar.fallback_cooldown_secs);
match svc.create_playbook(&input) {
Ok(id) => HttpResponse::Created().json(serde_json::json!({"id": id})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
@ -156,50 +115,12 @@ async fn create_playbook(
async fn update_playbook(
_auth: AuthClaims,
svc: web::Data<PlaybookService>,
app_config: web::Data<ArcSwap<AppConfig>>,
path: web::Path<i64>,
body: web::Json<CreatePlaybookRequest>,
) -> HttpResponse {
let id = path.into_inner();
let actions: Vec<(String, String)> = body
.actions
.iter()
.map(|a| {
let params_str = a
.params
.as_ref()
.map(|v| serde_json::to_string(v).unwrap_or_else(|_| "{}".into()))
.unwrap_or_else(|| "{}".into());
(a.action_type.clone(), params_str)
})
.collect();
let conditions: Vec<CreateConditionInput> = body
.conditions
.as_deref()
.unwrap_or_default()
.iter()
.map(|c| {
CreateConditionInput::new(
c.condition_type.clone(),
c.operator.clone(),
c.value.clone(),
c.value2.clone(),
)
})
.collect();
let input = CreatePlaybookInput {
name: body.name.clone(),
trigger_event: body.trigger_event.clone(),
condition_threshold: body.condition_threshold,
condition_count: body.condition_count,
condition_window_secs: body.condition_window_secs,
cooldown_secs: body.cooldown_secs.unwrap_or(300),
actions,
conditions,
};
let input = map_request_to_input(&body, app_config.load().soar.fallback_cooldown_secs);
match svc.update_playbook(id, &input) {
Ok(true) => HttpResponse::Ok().json(serde_json::json!({"updated": true})),
Ok(false) => HttpResponse::NotFound().json(serde_json::json!({"error": "Playbook not found"})),
@ -234,23 +155,7 @@ async fn delete_playbook(_auth: AuthClaims, svc: web::Data<PlaybookService>, pat
}
async fn list_active_blocks(_auth: AuthClaims, svc: web::Data<PlaybookService>) -> HttpResponse {
match svc.list_active_blocks() {
Ok(blocks) => {
let responses: Vec<serde_json::Value> = blocks
.into_iter()
.map(|b| {
serde_json::json!({
"id": b.id,
"source_ip": b.source_ip,
"playbook_id": b.playbook_id,
"expires_at": b.expires_at,
})
})
.collect();
HttpResponse::Ok().json(responses)
}
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
ok_json_or_error(svc.list_active_blocks())
}
async fn manual_unblock(_auth: AuthClaims, svc: web::Data<PlaybookService>, path: web::Path<i64>) -> HttpResponse {
@ -258,25 +163,7 @@ async fn manual_unblock(_auth: AuthClaims, svc: web::Data<PlaybookService>, path
}
async fn list_executions(_auth: AuthClaims, svc: web::Data<PlaybookService>) -> HttpResponse {
match svc.list_executions(100) {
Ok(executions) => {
let responses: Vec<serde_json::Value> = executions
.into_iter()
.map(|ex| {
serde_json::json!({
"id": ex.id,
"playbook_id": ex.playbook_id,
"source_ip": ex.source_ip,
"trigger_event": ex.trigger_event,
"actions_executed": ex.actions_executed,
"created_at": ex.created_at,
})
})
.collect();
HttpResponse::Ok().json(responses)
}
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
ok_json_or_error(svc.list_executions(100))
}
async fn list_whitelist(_auth: AuthClaims, svc: web::Data<PlaybookService>) -> HttpResponse {

View File

@ -1,7 +1,7 @@
use actix_web::{HttpResponse, Responder, Scope, web};
use crate::adapter::ebpf::drop_monitor::DropMonitor;
use crate::infrastructure::statistics::FlowStatistics;
use crate::interface::port::drop_stats::DropStatsPort;
pub fn initialize() -> Scope {
web::scope("/stats")
@ -24,6 +24,6 @@ async fn get_summary(stats: web::Data<FlowStatistics>) -> impl Responder {
HttpResponse::Ok().json(stats.get_summary())
}
async fn get_drop_stats(monitor: web::Data<DropMonitor>) -> impl Responder {
async fn get_drop_stats(monitor: web::Data<dyn DropStatsPort>) -> impl Responder {
HttpResponse::Ok().json(monitor.get_counters())
}

View File

@ -3,6 +3,7 @@ use serde::Deserialize;
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::infrastructure::logger::Logger;
use crate::infrastructure::system::{ShutdownHandle, ShutdownMode};
@ -135,7 +136,7 @@ async fn update_config(
}
async fn shutdown(auth: AuthClaims, handle: web::Data<ShutdownHandle>) -> impl Responder {
if !auth.permissions.iter().any(|p| p == "system:admin") {
if !auth.permissions.iter().any(|p| p == PERMISSION_SYSTEM_ADMIN) {
return HttpResponse::Forbidden().json(serde_json::json!({"error": "Requires system:admin permission"}));
}
if handle.trigger(ShutdownMode::Shutdown) {
@ -146,7 +147,7 @@ async fn shutdown(auth: AuthClaims, handle: web::Data<ShutdownHandle>) -> impl R
}
async fn restart(auth: AuthClaims, handle: web::Data<ShutdownHandle>) -> impl Responder {
if !auth.permissions.iter().any(|p| p == "system:admin") {
if !auth.permissions.iter().any(|p| p == PERMISSION_SYSTEM_ADMIN) {
return HttpResponse::Forbidden().json(serde_json::json!({"error": "Requires system:admin permission"}));
}
if handle.trigger(ShutdownMode::Restart) {

View File

@ -106,7 +106,7 @@ impl Database {
Ok(conn.last_insert_rowid())
}
pub fn list_api_keys(&self) -> Result<Vec<(i64, String, String, String, Option<String>)>, Error> {
pub fn list_api_keys(&self) -> Result<Vec<ApiKeyListItem>, 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| {

View File

@ -18,6 +18,7 @@ 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};
/// 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).
@ -139,11 +140,8 @@ impl Database {
conn.query_row("SELECT count(*) FROM sqlite_master", [], |_| Ok(()))
.map_err(|_| DatabaseError::DatabaseNotReadable)?;
// Attach a plaintext destination (empty key = no encryption)
conn.execute_batch(&format!(
"ATTACH DATABASE '{}' AS plaintext KEY '';",
dest_path.replace('\'', "''"),
))
.map_err(DatabaseError::QueryFailed)?;
conn.execute("ATTACH DATABASE ?1 AS plaintext KEY '';", params![dest_path])
.map_err(DatabaseError::QueryFailed)?;
conn.query_row("SELECT sqlcipher_export('plaintext')", [], |_| Ok(()))
.map_err(DatabaseError::QueryFailed)?;
conn.execute_batch("DETACH DATABASE plaintext;")
@ -158,12 +156,8 @@ impl Database {
// Verify it's readable as plaintext
conn.query_row("SELECT count(*) FROM sqlite_master", [], |_| Ok(()))
.map_err(|_| DatabaseError::SourceDatabaseNotReadable)?;
conn.execute_batch(&format!(
"ATTACH DATABASE '{}' AS encrypted KEY '{}';",
dest_path.replace('\'', "''"),
key.replace('\'', "''"),
))
.map_err(DatabaseError::QueryFailed)?;
conn.execute("ATTACH DATABASE ?1 AS encrypted KEY ?2;", params![dest_path, key])
.map_err(DatabaseError::QueryFailed)?;
conn.query_row("SELECT sqlcipher_export('encrypted')", [], |_| Ok(()))
.map_err(DatabaseError::QueryFailed)?;
conn.execute_batch("DETACH DATABASE encrypted;")
@ -388,15 +382,11 @@ impl Database {
conn_ref.execute(
"INSERT INTO user_groups (name, description, permissions) VALUES (?1, ?2, ?3)",
params![
"Administrator",
"Full system access with all permissions",
&all_permissions
],
params![GROUP_ADMIN, "Full system access with all permissions", &all_permissions],
)?;
conn_ref.execute(
"INSERT INTO user_groups (name, description, permissions) VALUES (?1, ?2, ?3)",
params!["Viewer", "Read-only access to all modules", &viewer_permissions],
params![GROUP_VIEWER, "Read-only access to all modules", &viewer_permissions],
)?;
}

View File

@ -26,7 +26,7 @@ impl Database {
Ok(())
}
pub fn get_app_secret(&self, key: &str) -> Result<Option<String>, Error> {
fn get_app_secret(&self, key: &str) -> Result<Option<String>, Error> {
let conn = self.conn()?;
let result = conn.query_row("SELECT value FROM app_secrets WHERE key = ?1", params![key], |row| {
row.get(0)
@ -38,7 +38,7 @@ impl Database {
}
}
pub fn set_app_secret(&self, key: &str, value: &str) -> Result<(), Error> {
fn set_app_secret(&self, key: &str, value: &str) -> Result<(), Error> {
let conn = self.conn()?;
conn.execute(
"INSERT OR REPLACE INTO app_secrets (key, value) VALUES (?1, ?2)",

View File

@ -1,9 +1,32 @@
use std::collections::HashMap;
use rusqlite::params;
use serde_json::Value;
use super::Database;
use crate::domain::common::error::Error;
use crate::domain::response::playbook_data::UpdatePlaybookInput;
use crate::interface::port::soar::{PlaybookRow, SoarExecutionRow, SoarRepo};
use crate::domain::response::playbook_data::{
ActionView, ActiveBlockView, ConditionView, CreatePlaybookInput, ExecutionView, PendingUnblock, PlaybookView,
UpdatePlaybookInput,
};
use crate::interface::port::soar::SoarRepo;
/// Intermediate row from the playbooks LEFT JOIN playbook_actions query.
/// Private to this module; consumed only by `SoarRepo::list_playbooks`.
struct PlaybookActionRow {
pb_id: i64,
name: String,
enabled: bool,
trigger_event: String,
condition_threshold: Option<f64>,
condition_count: Option<i64>,
condition_window_secs: Option<i64>,
cooldown_secs: i64,
action_id: Option<i64>,
action_order: Option<i64>,
action_type: Option<String>,
action_params: Option<String>,
}
impl Database {
pub fn insert_playbook(
@ -39,26 +62,7 @@ impl Database {
}
/// Load all playbooks with their actions in a single JOIN query (avoids N+1).
/// Returns Vec of (playbook fields..., action fields...).
pub fn list_playbooks_with_actions(
&self,
) -> Result<
Vec<(
i64,
String,
bool,
String,
Option<f64>,
Option<i64>,
Option<i64>,
i64,
Option<i64>,
Option<i64>,
Option<String>,
Option<String>,
)>,
Error,
> {
fn list_playbooks_with_actions(&self) -> Result<Vec<PlaybookActionRow>, Error> {
let conn = self.conn()?;
let mut stmt = conn.prepare(
"SELECT p.id, p.name, p.enabled, p.trigger_event, p.condition_threshold, \
@ -69,20 +73,20 @@ impl Database {
ORDER BY p.id, a.action_order",
)?;
let rows = stmt.query_map([], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, bool>(2)?,
row.get::<_, String>(3)?,
row.get::<_, Option<f64>>(4)?,
row.get::<_, Option<i64>>(5)?,
row.get::<_, Option<i64>>(6)?,
row.get::<_, i64>(7)?,
row.get::<_, Option<i64>>(8)?,
row.get::<_, Option<i64>>(9)?,
row.get::<_, Option<String>>(10)?,
row.get::<_, Option<String>>(11)?,
))
Ok(PlaybookActionRow {
pb_id: row.get::<_, i64>(0)?,
name: row.get::<_, String>(1)?,
enabled: row.get::<_, bool>(2)?,
trigger_event: row.get::<_, String>(3)?,
condition_threshold: row.get::<_, Option<f64>>(4)?,
condition_count: row.get::<_, Option<i64>>(5)?,
condition_window_secs: row.get::<_, Option<i64>>(6)?,
cooldown_secs: row.get::<_, i64>(7)?,
action_id: row.get::<_, Option<i64>>(8)?,
action_order: row.get::<_, Option<i64>>(9)?,
action_type: row.get::<_, Option<String>>(10)?,
action_params: row.get::<_, Option<String>>(11)?,
})
})?;
let mut result = Vec::new();
for row in rows {
@ -123,9 +127,7 @@ impl Database {
Ok(conn.last_insert_rowid())
}
pub fn list_all_playbook_conditions(
&self,
) -> Result<Vec<(i64, i64, String, String, String, Option<String>)>, Error> {
fn list_all_playbook_conditions(&self) -> Result<Vec<(i64, ConditionView)>, Error> {
let conn = self.conn()?;
let mut stmt = conn.prepare(
"SELECT id, playbook_id, condition_type, operator, value, value2 \
@ -133,12 +135,14 @@ impl Database {
)?;
let rows = stmt.query_map([], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, i64>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
row.get::<_, String>(4)?,
row.get::<_, Option<String>>(5)?,
ConditionView {
id: row.get::<_, i64>(0)?,
condition_type: row.get::<_, String>(2)?,
operator: row.get::<_, String>(3)?,
value: row.get::<_, String>(4)?,
value2: row.get::<_, Option<String>>(5)?,
},
))
})?;
let mut result = Vec::new();
@ -163,23 +167,21 @@ impl Database {
Ok(conn.last_insert_rowid())
}
pub fn list_soar_executions(
&self,
limit: i64,
) -> Result<Vec<(i64, i64, Option<String>, String, String, String)>, Error> {
pub fn list_soar_executions(&self, limit: i64) -> Result<Vec<ExecutionView>, Error> {
let conn = self.conn()?;
let mut stmt = conn.prepare(
"SELECT id, playbook_id, source_ip, trigger_event, actions_executed, executed_at FROM soar_executions ORDER BY executed_at DESC LIMIT ?1"
)?;
let rows = stmt.query_map(params![limit], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, i64>(1)?,
row.get::<_, Option<String>>(2)?,
row.get::<_, String>(3)?,
row.get::<_, String>(4)?,
row.get::<_, String>(5)?,
))
let actions_str: String = row.get(4)?;
Ok(ExecutionView {
id: row.get::<_, i64>(0)?,
playbook_id: row.get::<_, i64>(1)?,
source_ip: row.get::<_, Option<String>>(2)?,
trigger_event: row.get::<_, String>(3)?,
actions_executed: serde_json::from_str(&actions_str).unwrap_or(Value::Null),
created_at: row.get::<_, String>(5)?,
})
})?;
let mut result = Vec::new();
for row in rows {
@ -236,8 +238,72 @@ impl Database {
}
impl SoarRepo for Database {
fn list_playbooks_with_actions(&self) -> Result<Vec<PlaybookRow>, Error> {
self.list_playbooks_with_actions()
fn list_playbooks(&self) -> Result<Vec<PlaybookView>, Error> {
let rows = self.list_playbooks_with_actions()?;
let mut result: Vec<PlaybookView> = Vec::new();
for row in rows {
let pb = if let Some(last) = result.last_mut() {
if last.id == row.pb_id {
last
} else {
result.push(PlaybookView {
id: row.pb_id,
name: row.name,
enabled: row.enabled,
trigger_event: row.trigger_event,
condition_threshold: row.condition_threshold,
condition_count: row.condition_count,
condition_window_secs: row.condition_window_secs,
cooldown_secs: row.cooldown_secs,
actions: Vec::new(),
conditions: Vec::new(),
});
// SAFETY: just pushed above, Vec cannot be empty
result.last_mut().unwrap_or_else(|| unreachable!())
}
} else {
result.push(PlaybookView {
id: row.pb_id,
name: row.name,
enabled: row.enabled,
trigger_event: row.trigger_event,
condition_threshold: row.condition_threshold,
condition_count: row.condition_count,
condition_window_secs: row.condition_window_secs,
cooldown_secs: row.cooldown_secs,
actions: Vec::new(),
conditions: Vec::new(),
});
// SAFETY: just pushed above, Vec cannot be empty
result.last_mut().unwrap_or_else(|| unreachable!())
};
if let (Some(aid), Some(order), Some(atype), Some(params_str)) =
(row.action_id, row.action_order, row.action_type, row.action_params)
{
pb.actions.push(ActionView {
id: aid,
action_order: order,
action_type: atype,
params: serde_json::from_str(&params_str).unwrap_or(Value::Null),
});
}
}
// Load conditions and attach to playbooks
let cond_rows = self.list_all_playbook_conditions()?;
let mut cond_map: HashMap<i64, Vec<ConditionView>> = HashMap::new();
for (pb_id, cond) in cond_rows {
cond_map.entry(pb_id).or_default().push(cond);
}
for pb in &mut result {
if let Some(conds) = cond_map.remove(&pb.id) {
pb.conditions = conds;
}
}
Ok(result)
}
fn update_playbook_enabled(&self, id: i64, enabled: bool) -> Result<bool, Error> {
@ -252,23 +318,19 @@ impl SoarRepo for Database {
self.seed_default_playbooks()
}
fn list_all_playbook_conditions(&self) -> Result<Vec<(i64, i64, String, String, String, Option<String>)>, Error> {
self.list_all_playbook_conditions()
}
fn count_active_soar_blocks(&self) -> Result<u32, Error> {
self.count_active_soar_blocks()
}
fn list_active_soar_blocks(&self) -> Result<Vec<(i64, String, i64, String)>, Error> {
fn list_active_soar_blocks(&self) -> Result<Vec<ActiveBlockView>, Error> {
self.list_active_soar_blocks()
}
fn find_soar_block_by_id(&self, id: i64) -> Result<Option<(i64, String, i64, String)>, Error> {
fn find_soar_block_by_id(&self, id: i64) -> Result<Option<ActiveBlockView>, Error> {
self.find_soar_block_by_id(id)
}
fn list_expired_soar_blocks(&self) -> Result<Vec<(i64, String, i64)>, Error> {
fn list_expired_soar_blocks(&self) -> Result<Vec<ActiveBlockView>, Error> {
self.list_expired_soar_blocks()
}
@ -280,7 +342,7 @@ impl SoarRepo for Database {
self.insert_pending_unblock(source_ip)
}
fn list_pending_unblocks(&self) -> Result<Vec<(i64, String, i64)>, Error> {
fn list_pending_unblocks(&self) -> Result<Vec<PendingUnblock>, Error> {
self.list_pending_unblocks()
}
@ -302,18 +364,13 @@ impl SoarRepo for Database {
self.insert_soar_execution(playbook_id, source_ip, trigger_event, actions_json)
}
fn list_soar_executions(&self, limit: i64) -> Result<Vec<SoarExecutionRow>, Error> {
fn list_soar_executions(&self, limit: i64) -> Result<Vec<ExecutionView>, Error> {
self.list_soar_executions(limit)
}
fn insert_playbook_atomic(
&self,
name: &str,
trigger_event: &str,
threshold: Option<f64>,
count: Option<i64>,
window: Option<i64>,
cooldown: i64,
input: &CreatePlaybookInput,
actions: &[(i64, String, String)],
conditions: &[(String, String, String, Option<String>)],
) -> Result<i64, Error> {
@ -321,7 +378,7 @@ impl SoarRepo for Database {
let tx = conn.transaction()?;
tx.execute(
"INSERT INTO playbooks (name, trigger_event, condition_threshold, condition_count, condition_window_secs, cooldown_secs) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![name, trigger_event, threshold, count, window, cooldown],
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 {
@ -396,15 +453,24 @@ mod tests {
use crate::interface::port::soar::SoarRepo;
let db = test_db();
use crate::domain::response::playbook_data::CreatePlaybookInput;
let input = CreatePlaybookInput {
name: "atom_pb".to_string(),
trigger_event: "threat".to_string(),
condition_threshold: Some(0.8),
condition_count: None,
condition_window_secs: None,
cooldown_secs: 300,
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 id = db
.insert_playbook_atomic("atom_pb", "threat", Some(0.8), None, None, 300, &actions, &conditions)
.unwrap();
let id = db.insert_playbook_atomic(&input, &actions, &conditions).unwrap();
assert!(id > 0);
let loaded = db.list_playbooks_with_actions().unwrap();
let loaded = db.list_playbooks().unwrap();
assert!(!loaded.is_empty());
let cond_rows = db.list_all_playbook_conditions().unwrap();
assert_eq!(cond_rows.len(), 1);
assert_eq!(loaded[0].conditions.len(), 1);
}
}

View File

@ -2,6 +2,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;
impl Database {
@ -28,13 +29,18 @@ impl Database {
Ok(count)
}
pub fn list_expired_soar_blocks(&self) -> Result<Vec<(i64, String, i64)>, Error> {
pub fn list_expired_soar_blocks(&self) -> Result<Vec<ActiveBlockView>, Error> {
let conn = self.conn()?;
let mut stmt = conn.prepare(
"SELECT id, source_ip, playbook_id FROM soar_block_rules WHERE expires_at <= datetime('now') AND unblocked_at IS NULL",
"SELECT id, source_ip, playbook_id, expires_at FROM soar_block_rules WHERE expires_at <= datetime('now') AND unblocked_at IS NULL",
)?;
let rows = stmt.query_map([], |row| {
Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?, row.get::<_, i64>(2)?))
Ok(ActiveBlockView {
id: row.get::<_, i64>(0)?,
source_ip: row.get::<_, String>(1)?,
playbook_id: row.get::<_, i64>(2)?,
expires_at: row.get::<_, String>(3)?,
})
})?;
let mut result = Vec::new();
for row in rows {
@ -43,18 +49,18 @@ impl Database {
Ok(result)
}
/// Get a single SOAR block rule by ID, returning (id, source_ip, playbook_id, expires_at).
pub fn find_soar_block_by_id(&self, id: i64) -> Result<Option<(i64, String, i64, String)>, Error> {
/// Get a single SOAR block rule by ID.
pub fn find_soar_block_by_id(&self, id: i64) -> Result<Option<ActiveBlockView>, Error> {
let conn = self.conn()?;
let mut stmt =
conn.prepare("SELECT id, source_ip, playbook_id, expires_at FROM soar_block_rules WHERE id = ?1")?;
let mut rows = stmt.query_map(params![id], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, i64>(2)?,
row.get::<_, String>(3)?,
))
Ok(ActiveBlockView {
id: row.get::<_, i64>(0)?,
source_ip: row.get::<_, String>(1)?,
playbook_id: row.get::<_, i64>(2)?,
expires_at: row.get::<_, String>(3)?,
})
})?;
match rows.next() {
Some(row) => Ok(Some(row?)),
@ -71,18 +77,18 @@ impl Database {
Ok(())
}
pub fn list_active_soar_blocks(&self) -> Result<Vec<(i64, String, i64, String)>, Error> {
pub fn list_active_soar_blocks(&self) -> Result<Vec<ActiveBlockView>, Error> {
let conn = self.conn()?;
let mut stmt = conn.prepare(
"SELECT id, source_ip, playbook_id, expires_at FROM soar_block_rules WHERE unblocked_at IS NULL AND expires_at > datetime('now')"
)?;
let rows = stmt.query_map([], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, i64>(2)?,
row.get::<_, String>(3)?,
))
Ok(ActiveBlockView {
id: row.get::<_, i64>(0)?,
source_ip: row.get::<_, String>(1)?,
playbook_id: row.get::<_, i64>(2)?,
expires_at: row.get::<_, String>(3)?,
})
})?;
let mut result = Vec::new();
for row in rows {
@ -100,11 +106,15 @@ impl Database {
Ok(conn.last_insert_rowid())
}
pub fn list_pending_unblocks(&self) -> Result<Vec<(i64, String, i64)>, Error> {
pub fn list_pending_unblocks(&self) -> Result<Vec<PendingUnblock>, Error> {
let conn = self.conn()?;
let mut stmt = conn.prepare("SELECT id, source_ip, retry_count FROM pending_unblock ORDER BY id")?;
let rows = stmt.query_map([], |row| {
Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?, row.get::<_, i64>(2)?))
Ok(PendingUnblock {
id: row.get::<_, i64>(0)?,
source_ip: row.get::<_, String>(1)?,
retry_count: row.get::<_, i64>(2)?,
})
})?;
let mut result = Vec::new();
for row in rows {
@ -179,6 +189,7 @@ impl DbAdminRepo for Database {
mod tests {
use super::super::tests::test_db;
use crate::interface::port::db_admin::DbAdminRepo;
use crate::interface::port::soar::SoarRepo;
/// Happy path. Verifies `commit_soar_block_to_db` writes both
/// `soar_block_rules` and `acl_rules` atomically.
@ -197,9 +208,9 @@ mod tests {
assert!(soar_block_id > 0);
// soar_block_rules has the row
let active = db.list_active_soar_blocks().unwrap();
let active = SoarRepo::list_active_soar_blocks(&db).unwrap();
assert_eq!(active.len(), 1);
assert_eq!(active[0].1, "10.0.0.99");
assert_eq!(active[0].source_ip, "10.0.0.99");
// acl_rules has the matching row
let rules = db.list_acl_rules().unwrap();
@ -224,6 +235,6 @@ mod tests {
// acl_rules row gone
assert!(db.list_acl_rules().unwrap().is_empty());
// soar_block_rules row no longer in "active" view (unblocked_at is set)
assert!(db.list_active_soar_blocks().unwrap().is_empty());
assert!(SoarRepo::list_active_soar_blocks(&db).unwrap().is_empty());
}
}

View File

@ -1,89 +1,13 @@
use actix_web::rt::spawn;
use actix_web::{HttpRequest, HttpResponse, Result, web};
use actix_ws::{Message, MessageStream, Session, handle};
use futures_util::StreamExt;
use macros::log;
use tokio::sync::broadcast;
use tokio::sync::broadcast::error::RecvError;
use actix_ws::handle;
use crate::adapter::websocket::ws_bridge;
use crate::core::inference::alert::MLAlert;
use crate::domain::common::error::http::HttpError;
use crate::domain::common::error::misc::MiscError;
use crate::domain::common::log::http::HttpLog;
use crate::domain::detection::ml_detection::AlertMessage;
pub async fn websocket_alert(req: HttpRequest, body: web::Payload, ai: web::Data<MLAlert>) -> Result<HttpResponse> {
let (response, session, msg_stream) = handle(&req, body)?;
let broadcast_rx = ai.subscribe_to_alerts();
spawn(async move {
handle_alert_connection(session, msg_stream, broadcast_rx).await;
});
let rx = ai.subscribe_to_alerts();
spawn(ws_bridge::broadcast_json(session, msg_stream, rx));
Ok(response)
}
async fn handle_alert_connection(
mut session: Session,
mut msg_stream: MessageStream,
mut broadcast_rx: broadcast::Receiver<AlertMessage>,
) {
loop {
tokio::select! {
msg_result = msg_stream.next() => {
if !handle_client_message(&mut session, msg_result).await {
break;
}
},
broadcast_result = broadcast_rx.recv() => {
match broadcast_result {
Ok(alert) => {
if !send_alert(&mut session, &alert).await {
break;
}
}
Err(RecvError::Lagged(skipped)) => {
log!(HttpLog::WebSocketLagged(skipped));
continue;
}
Err(RecvError::Closed) => {
break;
}
}
},
}
}
let _ = session.close(None).await;
}
async fn handle_client_message(
session: &mut Session,
msg_result: Option<Result<Message, actix_ws::ProtocolError>>,
) -> bool {
match msg_result {
Some(Ok(Message::Text(_))) => true,
Some(Ok(Message::Ping(bytes))) => session.pong(&bytes).await.is_ok(),
Some(Ok(Message::Close(reason))) => {
let _ = (session.clone()).close(reason).await;
false
}
Some(Err(err)) => {
log!(HttpError::WebSocketError(err));
false
}
None => false,
_ => true,
}
}
async fn send_alert(session: &mut Session, alert: &AlertMessage) -> bool {
match serde_json::to_string(alert) {
Ok(json) => session.text(json).await.is_ok(),
Err(err) => {
log!(MiscError::SerializeError(err));
false
}
}
}

View File

@ -1,16 +1,9 @@
use actix_web::rt::spawn;
use actix_web::{HttpRequest, HttpResponse, Result, web};
use actix_ws::{Message, MessageStream, Session, handle};
use futures_util::StreamExt;
use macros::log;
use tokio::sync::broadcast;
use tokio::sync::broadcast::error::RecvError;
use actix_ws::handle;
use super::ws_bridge;
use crate::adapter::ebpf::drop_monitor::DropMonitor;
use crate::domain::common::error::http::HttpError;
use crate::domain::common::error::misc::MiscError;
use crate::domain::common::log::http::HttpLog;
use crate::domain::data_plane::drop_event::DropEventMessage;
pub async fn websocket_drops(
req: HttpRequest,
@ -18,76 +11,7 @@ pub async fn websocket_drops(
monitor: web::Data<DropMonitor>,
) -> Result<HttpResponse> {
let (response, session, msg_stream) = handle(&req, body)?;
let broadcast_rx = monitor.subscribe();
spawn(async move {
handle_drop_connection(session, msg_stream, broadcast_rx).await;
});
let rx = monitor.subscribe();
spawn(ws_bridge::broadcast_json(session, msg_stream, rx));
Ok(response)
}
async fn handle_drop_connection(
mut session: Session,
mut msg_stream: MessageStream,
mut broadcast_rx: broadcast::Receiver<DropEventMessage>,
) {
loop {
tokio::select! {
msg_result = msg_stream.next() => {
if !handle_client_message(&mut session, msg_result).await {
break;
}
},
broadcast_result = broadcast_rx.recv() => {
match broadcast_result {
Ok(event) => {
if !send_drop_event(&mut session, &event).await {
break;
}
}
Err(RecvError::Lagged(skipped)) => {
log!(HttpLog::WebSocketLagged(skipped));
continue;
}
Err(RecvError::Closed) => {
break;
}
}
},
}
}
let _ = session.close(None).await;
}
async fn handle_client_message(
session: &mut Session,
msg_result: Option<Result<Message, actix_ws::ProtocolError>>,
) -> bool {
match msg_result {
Some(Ok(Message::Text(_))) => true,
Some(Ok(Message::Ping(bytes))) => session.pong(&bytes).await.is_ok(),
Some(Ok(Message::Close(reason))) => {
let _ = (session.clone()).close(reason).await;
false
}
Some(Err(err)) => {
log!(HttpError::WebSocketError(err));
false
}
None => false,
_ => true,
}
}
async fn send_drop_event(session: &mut Session, event: &DropEventMessage) -> bool {
match serde_json::to_string(event) {
Ok(json) => session.text(json).await.is_ok(),
Err(err) => {
log!(MiscError::SerializeError(err));
false
}
}
}

View File

@ -16,16 +16,13 @@ use std::time::{SystemTime, UNIX_EPOCH};
use actix_web::rt::spawn;
use actix_web::{HttpRequest, HttpResponse, Result, web};
use actix_ws::{Message, MessageStream, Session, handle};
use futures_util::StreamExt;
use actix_ws::handle;
use macros::log;
use tokio::sync::broadcast;
use tokio::sync::broadcast::error::RecvError;
use crate::domain::common::error::http::HttpError;
use super::ws_bridge;
use crate::domain::common::error::misc::MiscError;
use crate::domain::common::event::ThreatDetectedEvent;
use crate::domain::common::log::http::HttpLog;
pub async fn websocket_fusion(
req: HttpRequest,
@ -33,76 +30,12 @@ pub async fn websocket_fusion(
threat_tx: web::Data<broadcast::Sender<ThreatDetectedEvent>>,
) -> Result<HttpResponse> {
let (response, session, msg_stream) = handle(&req, body)?;
let broadcast_rx = threat_tx.subscribe();
spawn(async move {
handle_fusion_connection(session, msg_stream, broadcast_rx).await;
});
let rx = threat_tx.subscribe();
spawn(ws_bridge::broadcast_loop(session, msg_stream, rx, envelope_with_ts));
Ok(response)
}
async fn handle_fusion_connection(
mut session: Session,
mut msg_stream: MessageStream,
mut broadcast_rx: broadcast::Receiver<ThreatDetectedEvent>,
) {
loop {
tokio::select! {
msg_result = msg_stream.next() => {
if !handle_client_message(&mut session, msg_result).await {
break;
}
},
broadcast_result = broadcast_rx.recv() => {
match broadcast_result {
Ok(event) => {
if !send_event(&mut session, &event).await {
break;
}
}
Err(RecvError::Lagged(skipped)) => {
log!(HttpLog::WebSocketLagged(skipped));
continue;
}
Err(RecvError::Closed) => {
break;
}
}
},
}
}
let _ = session.close(None).await;
}
async fn handle_client_message(
session: &mut Session,
msg_result: Option<Result<Message, actix_ws::ProtocolError>>,
) -> bool {
match msg_result {
Some(Ok(Message::Text(_))) => true,
Some(Ok(Message::Ping(bytes))) => session.pong(&bytes).await.is_ok(),
Some(Ok(Message::Close(reason))) => {
let _ = (session.clone()).close(reason).await;
false
}
Some(Err(err)) => {
log!(HttpError::WebSocketError(err));
false
}
None => false,
_ => true,
}
}
/// Wrap each event in `{ts, ...event_fields}`. The `ts` is a server-stamped
/// unix-seconds value so the client can render "5s ago" without inferring
/// the time from the audit chain. All declared fields of
/// `ThreatDetectedEvent` flow through verbatim via the event's own
/// `Serialize` derive — no field whitelist to drift out of date.
async fn send_event(session: &mut Session, event: &ThreatDetectedEvent) -> bool {
fn envelope_with_ts(event: &ThreatDetectedEvent) -> Option<String> {
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
@ -113,21 +46,18 @@ async fn send_event(session: &mut Session, event: &ThreatDetectedEvent) -> bool
map.insert("ts".to_string(), serde_json::Value::from(ts));
serde_json::Value::Object(map)
}
// The derived Serialize on a struct always produces an Object —
// this branch only fires if the type changes shape in a future
// refactor. Falling back to the raw value keeps the stream alive.
Ok(other) => other,
Err(err) => {
log!(MiscError::SerializeError(err));
return false;
return None;
}
};
match serde_json::to_string(&payload) {
Ok(json) => session.text(json).await.is_ok(),
Ok(json) => Some(json),
Err(err) => {
log!(MiscError::SerializeError(err));
false
None
}
}
}
@ -170,14 +100,9 @@ mod tests {
#[test]
fn envelope_adds_ts_field_to_event_object() {
// The `send_event` wire path inserts `ts` into the event's own
// serde object; mirror that here without an actix session so the
// wrapping logic stays covered when the orchestrator schema evolves.
let event = sample_event();
let mut value = serde_json::to_value(&event).expect("serialize event");
let object = value.as_object_mut().expect("expected object shape");
object.insert("ts".to_string(), serde_json::Value::from(1_700_000_000_u64));
assert_eq!(value["ts"], 1_700_000_000_u64);
let json = envelope_with_ts(&sample_event()).expect("should serialize");
let value: serde_json::Value = serde_json::from_str(&json).expect("valid json");
assert!(value["ts"].is_u64());
assert_eq!(value["attack_type"], "brute_force");
}
}

View File

@ -1,15 +1,8 @@
use actix_web::rt::spawn;
use actix_web::{HttpRequest, HttpResponse, Result, web};
use actix_ws::{Message, MessageStream, Session, handle};
use futures_util::StreamExt;
use macros::log;
use tokio::sync::broadcast;
use tokio::sync::broadcast::error::RecvError;
use actix_ws::handle;
use crate::domain::common::error::http::HttpError;
use crate::domain::common::error::misc::MiscError;
use crate::domain::common::log::http::HttpLog;
use crate::domain::common::system::health::SystemHealthMetrics;
use super::ws_bridge;
use crate::infrastructure::health::SystemHealth;
pub async fn websocket_system_health(
@ -18,76 +11,7 @@ pub async fn websocket_system_health(
health: web::Data<SystemHealth>,
) -> Result<HttpResponse> {
let (response, session, msg_stream) = handle(&req, body)?;
let broadcast_rx = health.subscribe_to_metrics();
spawn(async move {
handle_health_connection(session, msg_stream, broadcast_rx).await;
});
let rx = health.subscribe_to_metrics();
spawn(ws_bridge::broadcast_json(session, msg_stream, rx));
Ok(response)
}
async fn handle_health_connection(
mut session: Session,
mut msg_stream: MessageStream,
mut broadcast_rx: broadcast::Receiver<SystemHealthMetrics>,
) {
loop {
tokio::select! {
msg_result = msg_stream.next() => {
if !handle_client_message(&mut session, msg_result).await {
break;
}
},
broadcast_result = broadcast_rx.recv() => {
match broadcast_result {
Ok(metrics) => {
if !send_metrics(&mut session, &metrics).await {
break;
}
}
Err(RecvError::Lagged(skipped)) => {
log!(HttpLog::WebSocketLagged(skipped));
continue;
}
Err(RecvError::Closed) => {
break;
}
}
},
}
}
let _ = session.close(None).await;
}
async fn handle_client_message(
session: &mut Session,
msg_result: Option<Result<Message, actix_ws::ProtocolError>>,
) -> bool {
match msg_result {
Some(Ok(Message::Text(_))) => true,
Some(Ok(Message::Ping(bytes))) => session.pong(&bytes).await.is_ok(),
Some(Ok(Message::Close(reason))) => {
let _ = (session.clone()).close(reason).await;
false
}
Some(Err(err)) => {
log!(HttpError::WebSocketError(err));
false
}
None => false,
_ => true,
}
}
async fn send_metrics(session: &mut Session, metrics: &SystemHealthMetrics) -> bool {
match serde_json::to_string(metrics) {
Ok(json) => session.text(json).await.is_ok(),
Err(err) => {
log!(MiscError::SerializeError(err));
false
}
}
}

View File

@ -4,3 +4,4 @@ pub mod flow_websocket;
pub mod fusion_websocket;
pub mod health_websocket;
pub mod routes;
pub mod ws_bridge;

View File

@ -0,0 +1,86 @@
use actix_ws::{Message, MessageStream, Session};
use futures_util::StreamExt;
use macros::log;
use serde::Serialize;
use tokio::sync::broadcast;
use tokio::sync::broadcast::error::RecvError;
use crate::domain::common::error::http::HttpError;
use crate::domain::common::error::misc::MiscError;
use crate::domain::common::log::http::HttpLog;
async fn handle_client_message(
session: &mut Session,
msg_result: Option<Result<Message, actix_ws::ProtocolError>>,
) -> bool {
match msg_result {
Some(Ok(Message::Text(_))) => true,
Some(Ok(Message::Ping(bytes))) => session.pong(&bytes).await.is_ok(),
Some(Ok(Message::Close(reason))) => {
let _ = (session.clone()).close(reason).await;
false
}
Some(Err(err)) => {
log!(HttpError::WebSocketError(err));
false
}
None => false,
_ => true,
}
}
fn serialize_json<T: Serialize>(value: &T) -> Option<String> {
match serde_json::to_string(value) {
Ok(json) => Some(json),
Err(err) => {
log!(MiscError::SerializeError(err));
None
}
}
}
pub async fn broadcast_json<T: Serialize + Clone + Send + 'static>(
session: Session,
msg_stream: MessageStream,
rx: broadcast::Receiver<T>,
) {
broadcast_loop(session, msg_stream, rx, |event| serialize_json(event)).await;
}
pub async fn broadcast_loop<T: Clone + Send + 'static>(
mut session: Session,
mut msg_stream: MessageStream,
mut rx: broadcast::Receiver<T>,
to_json: impl Fn(&T) -> Option<String>,
) {
loop {
tokio::select! {
msg_result = msg_stream.next() => {
if !handle_client_message(&mut session, msg_result).await {
break;
}
},
broadcast_result = rx.recv() => {
match broadcast_result {
Ok(event) => {
let Some(json) = to_json(&event) else {
break;
};
if session.text(json).await.is_err() {
break;
}
}
Err(RecvError::Lagged(skipped)) => {
log!(HttpLog::WebSocketLagged(skipped));
continue;
}
Err(RecvError::Closed) => {
break;
}
}
},
}
}
let _ = session.close(None).await;
}

View File

@ -14,7 +14,7 @@ use tokio::time::interval;
use super::alert::MLAlert;
use super::drift_detector::DriftDetectorHandle;
use super::inference::Inference;
use super::runner::Inference;
use super::traffic_logger::TrafficLogger;
use crate::domain::data_plane::user_packet::UserPacket;
use crate::domain::detection::aggregator::AttackAggregator;

View File

@ -2,8 +2,8 @@ pub mod alert;
pub mod config_loader;
pub mod drift_detector;
pub mod engine;
pub mod inference;
pub mod manifest;
pub mod model_loader;
pub mod model_watcher;
pub mod runner;
pub mod traffic_logger;

View File

@ -14,8 +14,8 @@ use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use tokio::sync::mpsc;
use tokio::time::sleep;
use super::inference::Inference;
use super::model_loader::build_adapter;
use super::runner::Inference;
use crate::domain::common::config::AppConfig;
use crate::domain::common::config::constants::{MANIFEST_FILENAME, MODELS_DIR, STAGING_SUBDIR};
use crate::domain::detection::error::MLError;

View File

@ -139,26 +139,7 @@ impl Inference {
/// Dispatch on adapter variant.
fn infer_batch_inner(&self, adapter: &MLModelAdapter, flows: &[FlowData]) -> Vec<DetectionResult> {
match adapter {
MLModelAdapter::MultiTask {
ae,
classifier,
batch_size,
n_ae,
n_cls,
labels,
normal_idx,
c2_idx,
} => self.infer_multitask(
ae,
classifier,
*batch_size,
*n_ae,
*n_cls,
labels,
*normal_idx,
*c2_idx,
flows,
),
MLModelAdapter::MultiTask { .. } => self.infer_multitask(adapter, flows),
MLModelAdapter::AutoencoderOnly {
model,
batch_size,
@ -177,18 +158,22 @@ impl Inference {
/// MultiTask path. Runs the AE batch → computes per-flow MSE → feeds the
/// classifier over (ae_features ++ ae_score) → fires on anomaly OR
/// non-Normal classifier agreement OR elevated C2 head.
fn infer_multitask(
&self,
ae: &RunnableModel,
classifier: &RunnableModel,
batch_size: usize,
n_ae: usize,
n_cls: usize,
labels: &BTreeMap<String, LabelSpec>,
normal_idx: Option<usize>,
c2_idx: Option<usize>,
flows: &[FlowData],
) -> Vec<DetectionResult> {
fn infer_multitask(&self, adapter: &MLModelAdapter, flows: &[FlowData]) -> Vec<DetectionResult> {
let MLModelAdapter::MultiTask {
ae,
classifier,
batch_size,
n_ae,
n_cls,
labels,
normal_idx,
c2_idx,
} = adapter
else {
unreachable!()
};
let (batch_size, n_ae, n_cls) = (*batch_size, *n_ae, *n_cls);
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();

View File

@ -4,7 +4,6 @@ use std::sync::atomic::{AtomicU8, Ordering};
use arc_swap::ArcSwap;
use macros::log;
use serde_json::Value;
use tokio::sync::Semaphore;
use tokio::sync::broadcast;
use tokio::sync::broadcast::error::RecvError;
@ -44,17 +43,29 @@ pub struct SoarEngine {
pub(super) secrets: Option<Arc<dyn SecretStorePort>>,
}
pub struct SoarEngineDeps {
pub db: Arc<dyn AppRepo>,
pub config: Arc<ArcSwap<AppConfig>>,
pub access_control: Arc<dyn AccessControlPort>,
pub alert_notifier: Option<Arc<dyn AlertNotifier>>,
pub geoip: Option<Arc<dyn GeoLookup>>,
pub rate_limit: Option<Arc<dyn RateLimitPort>>,
pub enforce_level_cache: Arc<AtomicU8>,
pub secrets: Option<Arc<dyn SecretStorePort>>,
}
impl SoarEngine {
pub fn new(
db: Arc<dyn AppRepo>,
config: Arc<ArcSwap<AppConfig>>,
access_control: Arc<dyn AccessControlPort>,
alert_notifier: Option<Arc<dyn AlertNotifier>>,
geoip: Option<Arc<dyn GeoLookup>>,
rate_limit: Option<Arc<dyn RateLimitPort>>,
enforce_level_cache: Arc<AtomicU8>,
secrets: Option<Arc<dyn SecretStorePort>>,
) -> Result<Self, Error> {
pub fn new(deps: SoarEngineDeps) -> Result<Self, Error> {
let SoarEngineDeps {
db,
config,
access_control,
alert_notifier,
geoip,
rate_limit,
enforce_level_cache,
secrets,
} = deps;
let soar_cfg = config.load();
let rate_limit_channel = soar_cfg.soar.rate_limit_cmd_channel_capacity;
let freq_max_keys = soar_cfg.soar.frequency_max_tracked_keys;
@ -77,92 +88,61 @@ impl SoarEngine {
/// Load playbooks and admin whitelist from DB into memory.
pub fn reload_cache(&self) -> Result<(), Error> {
// Load playbooks via single JOIN query (no N+1)
let rows = self.db.list_playbooks_with_actions()?;
let views = self.db.list_playbooks()?;
let mut playbooks: Vec<Playbook> = Vec::new();
for (
pb_id,
name,
enabled,
trigger_event,
threshold,
_count,
_window,
cooldown,
_action_id,
action_order,
action_type,
action_params,
) in rows
{
// Check if this row belongs to the same playbook as the last one
let needs_new = playbooks.last().is_none_or(|last| last.id != pb_id);
if needs_new {
let _ = threshold; // persisted for schema stability; runtime gating comes from the Condition rows
playbooks.push(Playbook {
id: pb_id,
name,
enabled,
trigger_event,
cooldown_secs: cooldown,
actions: Vec::new(),
conditions: Vec::new(),
for view in views {
let mut actions: Vec<PlaybookAction> = Vec::new();
for a in view.actions {
actions.push(PlaybookAction {
action_order: a.action_order,
action_type: a.action_type,
params: a.params,
});
}
// Safe: we just pushed if empty, and last() was Some otherwise
let Some(pb) = playbooks.last_mut() else {
continue;
};
if let (Some(order), Some(atype), Some(params_str)) = (action_order, action_type, action_params) {
pb.actions.push(PlaybookAction {
action_order: order,
action_type: atype,
params: serde_json::from_str(&params_str).unwrap_or_else(|e| {
log!(SoarLog::PlaybookError(
pb.name.clone(),
format!("Malformed action params JSON: {e}"),
));
Value::Object(Default::default())
}),
});
}
}
// Load conditions and attach to playbooks
let condition_rows = self.db.list_all_playbook_conditions()?;
for (_cid, pb_id, ctype_str, operator, value, value2) in condition_rows {
if let Ok(ctype) = ctype_str.parse::<ConditionType>()
&& let Some(pb) = playbooks.iter_mut().find(|p| p.id == pb_id)
{
let mut conditions: Vec<PlaybookCondition> = Vec::new();
for c in view.conditions {
let Ok(ctype) = c.condition_type.parse::<ConditionType>() else {
continue;
};
// Validate operator at load time to prevent silent fallback to defaults
let valid = match ctype {
ConditionType::Threshold => matches!(operator.as_str(), ">=" | "<="),
ConditionType::Threshold => matches!(c.operator.as_str(), ">=" | "<="),
ConditionType::SourceCountry | ConditionType::IpPattern => {
matches!(operator.as_str(), "in" | "not_in")
matches!(c.operator.as_str(), "in" | "not_in")
}
ConditionType::RepeatOffender => operator == "==",
ConditionType::Frequency => operator == ">=",
ConditionType::MultiSourceMin => operator == ">=",
ConditionType::SingleSourceHigh => operator == ">=",
ConditionType::FusedConfidenceAbove => matches!(operator.as_str(), ">=" | "<="),
ConditionType::RepeatOffender => c.operator == "==",
ConditionType::Frequency => c.operator == ">=",
ConditionType::MultiSourceMin => c.operator == ">=",
ConditionType::SingleSourceHigh => c.operator == ">=",
ConditionType::FusedConfidenceAbove => matches!(c.operator.as_str(), ">=" | "<="),
};
if !valid {
log!(SoarLog::InvalidConditionOperator(
pb.name.clone(),
ctype_str.clone(),
operator.clone(),
view.name.clone(),
c.condition_type.clone(),
c.operator.clone(),
));
continue;
}
pb.conditions.push(PlaybookCondition {
conditions.push(PlaybookCondition {
condition_type: ctype,
operator,
value,
value2,
operator: c.operator,
value: c.value,
value2: c.value2,
});
}
playbooks.push(Playbook {
id: view.id,
name: view.name,
enabled: view.enabled,
trigger_event: view.trigger_event,
cooldown_secs: view.cooldown_secs,
actions,
conditions,
});
}
// Warn on playbooks whose trigger_event isn't in the canonical
@ -270,10 +250,10 @@ impl SoarEngine {
let active_blocks = self.db.list_active_soar_blocks()?;
let count = active_blocks.len();
for (_id, source_ip, _playbook_id, _expires_at) in &active_blocks {
for block in &active_blocks {
// Preserve original error-swallowing behavior during recovery
if let Err(e) = self.access_control.block_ip(source_ip) {
log!(SoarLog::RecoveryFailed(source_ip.clone(), e.to_string()));
if let Err(e) = self.access_control.block_ip(&block.source_ip) {
log!(SoarLog::RecoveryFailed(block.source_ip.clone(), e.to_string()));
}
}
@ -297,32 +277,32 @@ impl SoarEngine {
}
};
for (id, source_ip, retry_count) in pending {
if retry_count >= self.matcher.config.load().soar.max_pending_unblock_retries {
for pu in pending {
if pu.retry_count >= self.matcher.config.load().soar.max_pending_unblock_retries {
log!(SoarLog::EventHandlingFailed(format!(
"Giving up on pending unblock for IP {} after {} retries",
source_ip, retry_count
pu.source_ip, pu.retry_count
)));
// Remove from queue to avoid infinite retries
let _ = self.db.delete_pending_unblock(id);
let _ = self.db.delete_pending_unblock(pu.id);
continue;
}
match self.access_control.unblock_ip(&source_ip) {
match self.access_control.unblock_ip(&pu.source_ip) {
Ok(()) => {
let _ = self.db.delete_pending_unblock(id);
let _ = self.db.delete_pending_unblock(pu.id);
log!(SoarLog::EventHandlingFailed(format!(
"Successfully unblocked orphan IP {} on retry #{}",
source_ip,
retry_count + 1
pu.source_ip,
pu.retry_count + 1
)));
}
Err(e) => {
let _ = self.db.increment_pending_unblock_retry(id);
let _ = self.db.increment_pending_unblock_retry(pu.id);
log!(SoarLog::EventHandlingFailed(format!(
"Retry #{} failed to unblock orphan IP {}: {}",
retry_count + 1,
source_ip,
pu.retry_count + 1,
pu.source_ip,
e
)));
}
@ -430,8 +410,17 @@ mod tests {
AppConfig::seed_defaults(&*db).expect("seed config defaults");
let cfg = AppConfig::from_settings(&*db).expect("load config");
let config = Arc::new(ArcSwap::from_pointee(cfg));
SoarEngine::new(db as Arc<dyn AppRepo>, config, ac, None, None, None, cache, None)
.expect("Failed to create SOAR engine")
SoarEngine::new(SoarEngineDeps {
db: db as Arc<dyn AppRepo>,
config,
access_control: ac,
alert_notifier: None,
geoip: None,
rate_limit: None,
enforce_level_cache: cache,
secrets: None,
})
.expect("Failed to create SOAR engine")
}
#[tokio::test]
@ -524,16 +513,16 @@ mod tests {
AppConfig::seed_defaults(&*db).expect("seed config defaults");
let cfg = AppConfig::from_settings(&*db).expect("load config");
let config = Arc::new(ArcSwap::from_pointee(cfg));
let engine = SoarEngine::new(
db as Arc<dyn AppRepo>,
let engine = SoarEngine::new(SoarEngineDeps {
db: db as Arc<dyn AppRepo>,
config,
mock.clone(),
None,
None,
None,
cache,
None,
)
access_control: mock.clone(),
alert_notifier: None,
geoip: None,
rate_limit: None,
enforce_level_cache: cache,
secrets: None,
})
.expect("Failed to create engine");
engine.recover_active_blocks().await.expect("Recovery should succeed");
@ -558,16 +547,16 @@ mod tests {
AppConfig::seed_defaults(&*db).expect("seed config defaults");
let cfg = AppConfig::from_settings(&*db).expect("load config");
let config = Arc::new(ArcSwap::from_pointee(cfg));
let engine = SoarEngine::new(
db as Arc<dyn AppRepo>,
let engine = SoarEngine::new(SoarEngineDeps {
db: db as Arc<dyn AppRepo>,
config,
mock.clone(),
None,
None,
None,
cache,
None,
)
access_control: mock.clone(),
alert_notifier: None,
geoip: None,
rate_limit: None,
enforce_level_cache: cache,
secrets: None,
})
.expect("Failed to create engine");
// Should not panic — errors are logged, not propagated
@ -672,16 +661,16 @@ mod tests {
AppConfig::seed_defaults(&*db).expect("seed config defaults");
let cfg = AppConfig::from_settings(&*db).expect("load config");
let config = Arc::new(ArcSwap::from_pointee(cfg));
let engine = SoarEngine::new(
db as Arc<dyn AppRepo>,
let engine = SoarEngine::new(SoarEngineDeps {
db: db as Arc<dyn AppRepo>,
config,
mock.clone(),
None,
None,
None,
cache,
None,
)
access_control: mock.clone(),
alert_notifier: None,
geoip: None,
rate_limit: None,
enforce_level_cache: cache,
secrets: None,
})
.expect("Failed to create engine");
let event = ThreatDetectedEvent {
@ -747,16 +736,16 @@ mod tests {
AppConfig::seed_defaults(&*db).expect("seed config defaults");
let cfg = AppConfig::from_settings(&*db).expect("load config");
let config = Arc::new(ArcSwap::from_pointee(cfg));
let engine = SoarEngine::new(
db.clone() as Arc<dyn AppRepo>,
let engine = SoarEngine::new(SoarEngineDeps {
db: db.clone() as Arc<dyn AppRepo>,
config,
mock,
None,
None,
None,
cache,
None,
)
access_control: mock,
alert_notifier: None,
geoip: None,
rate_limit: None,
enforce_level_cache: cache,
secrets: None,
})
.expect("Failed to create engine");
// Should have loaded default playbooks

View File

@ -1,14 +1,11 @@
use std::collections::HashMap;
use std::net::IpAddr;
use std::sync::Arc;
use serde_json::Value;
use crate::core::response::engine::SoarEngine;
use crate::domain::common::error::Error;
use crate::domain::response::error::SoarError;
use crate::domain::response::playbook_data::{
ActionView, ActiveBlockView, ConditionView, CreatePlaybookInput, ExecutionView, PlaybookView, UpdatePlaybookInput,
ActiveBlockView, CreatePlaybookInput, ExecutionView, PlaybookView, UpdatePlaybookInput,
};
use crate::interface::port::access_control::AccessControlPort;
use crate::interface::port::app_repo::AppRepo;
@ -31,93 +28,7 @@ impl PlaybookService {
}
pub fn list_playbooks(&self) -> Result<Vec<PlaybookView>, Error> {
let rows = self.db.list_playbooks_with_actions()?;
let mut result: Vec<PlaybookView> = Vec::new();
for (
pb_id,
name,
enabled,
trigger_event,
threshold,
count,
window,
cooldown,
action_id,
action_order,
action_type,
action_params,
) in rows
{
// Find or create the playbook entry
let pb = if let Some(last) = result.last_mut() {
if last.id == pb_id {
last
} else {
result.push(PlaybookView {
id: pb_id,
name,
enabled,
trigger_event,
condition_threshold: threshold,
condition_count: count,
condition_window_secs: window,
cooldown_secs: cooldown,
actions: Vec::new(),
conditions: Vec::new(),
});
// SAFETY: just pushed above, Vec cannot be empty
result.last_mut().unwrap_or_else(|| unreachable!())
}
} else {
result.push(PlaybookView {
id: pb_id,
name,
enabled,
trigger_event,
condition_threshold: threshold,
condition_count: count,
condition_window_secs: window,
cooldown_secs: cooldown,
actions: Vec::new(),
conditions: Vec::new(),
});
// SAFETY: just pushed above, Vec cannot be empty
result.last_mut().unwrap_or_else(|| unreachable!())
};
// Append action if present (LEFT JOIN may yield NULLs)
if let (Some(aid), Some(order), Some(atype), Some(params_str)) =
(action_id, action_order, action_type, action_params)
{
pb.actions.push(ActionView {
id: aid,
action_order: order,
action_type: atype,
params: serde_json::from_str(&params_str).unwrap_or(Value::Null),
});
}
}
// Load conditions and attach to playbooks
let cond_rows = self.db.list_all_playbook_conditions()?;
let mut cond_map: HashMap<i64, Vec<ConditionView>> = HashMap::new();
for (cid, pb_id, ctype, operator, value, value2) in cond_rows {
cond_map.entry(pb_id).or_default().push(ConditionView {
id: cid,
condition_type: ctype,
operator,
value,
value2,
});
}
for pb in &mut result {
if let Some(conds) = cond_map.remove(&pb.id) {
pb.conditions = conds;
}
}
Ok(result)
self.db.list_playbooks()
}
pub fn create_playbook(&self, input: &CreatePlaybookInput) -> Result<i64, Error> {
@ -140,16 +51,7 @@ impl PlaybookService {
)
})
.collect();
let playbook_id = self.db.insert_playbook_atomic(
&input.name,
&input.trigger_event,
input.condition_threshold,
input.condition_count,
input.condition_window_secs,
input.cooldown_secs,
&actions,
&conditions,
)?;
let playbook_id = self.db.insert_playbook_atomic(input, &actions, &conditions)?;
self.soar_engine.reload_cache()?;
Ok(playbook_id)
}
@ -207,16 +109,7 @@ impl PlaybookService {
}
pub fn list_active_blocks(&self) -> Result<Vec<ActiveBlockView>, Error> {
let blocks = self.db.list_active_soar_blocks()?;
Ok(blocks
.into_iter()
.map(|(id, ip, pb_id, expires)| ActiveBlockView {
id,
source_ip: ip,
playbook_id: pb_id,
expires_at: expires,
})
.collect())
self.db.list_active_soar_blocks()
}
/// Manually unblock an IP: remove from eBPF, atomically clear both DB
@ -228,7 +121,7 @@ impl PlaybookService {
.db
.find_soar_block_by_id(id)?
.ok_or_else(|| SoarError::UnblockRuleNotFound(id))?;
let source_ip = &block.1;
let source_ip = &block.source_ip;
// Remove from eBPF ACL
self.access_control.unblock_ip(source_ip)?;
@ -245,20 +138,7 @@ impl PlaybookService {
}
pub fn list_executions(&self, limit: i64) -> Result<Vec<ExecutionView>, Error> {
let rows = self.db.list_soar_executions(limit)?;
Ok(rows
.into_iter()
.map(
|(id, pb_id, source_ip, trigger_event, actions, created_at)| ExecutionView {
id,
playbook_id: pb_id,
source_ip,
trigger_event,
actions_executed: serde_json::from_str(&actions).unwrap_or(Value::Null),
created_at,
},
)
.collect())
self.db.list_soar_executions(limit)
}
pub fn list_whitelist(&self) -> Result<Vec<String>, Error> {

View File

@ -65,34 +65,35 @@ impl TtlScheduler {
let mut removed = 0u32;
let mut skipped = 0u32;
for (id, source_ip, _playbook_id) in &expired {
for block in &expired {
// Check if a manual ACL rule exists for this IP
let has_manual_rule = self.db.has_manual_acl_rule(source_ip)?;
let has_manual_rule = self.db.has_manual_acl_rule(&block.source_ip)?;
if has_manual_rule {
// Only mark as unblocked in SOAR records, don't remove from eBPF
self.db.mark_soar_block_unblocked(*id)?;
self.db.mark_soar_block_unblocked(block.id)?;
self.soar_engine.decrement_block_count();
skipped += 1;
log!(SoarLog::WhitelistSkipped(
source_ip.clone(),
block.source_ip.clone(),
"TTL expired but manual ACL exists".to_string()
));
continue;
}
// Remove from eBPF ACL via AccessControlPort
if let Err(e) = self.access_control.unblock_ip(source_ip) {
if let Err(e) = self.access_control.unblock_ip(&block.source_ip) {
log!(SoarLog::RecoveryFailed(
source_ip.clone(),
block.source_ip.clone(),
format!("unblock failed: {}", e)
));
}
// Atomically drop acl_rules entry AND mark soar_block_rules
// unblocked in one transaction.
let ip_version = ip_version_from_str(source_ip);
self.db.commit_soar_unblock_to_db(*id, ip_version, source_ip)?;
let ip_version = ip_version_from_str(&block.source_ip);
self.db
.commit_soar_unblock_to_db(block.id, ip_version, &block.source_ip)?;
self.soar_engine.decrement_block_count();
removed += 1;
}

View File

@ -31,5 +31,8 @@ pub const AUDIT_ACTOR_SECURITY_ADMIN_PREFIX: &str = "SecurityAdmin";
pub const FLOW_TRACE_FILE_MARKER: &str = "flow-trace-";
pub const FLOW_TRACE_FILE_EXT: &str = ".csv";
// ── Permissions ───────────────────────────────────────────────────
pub const PERMISSION_SYSTEM_ADMIN: &str = "system:admin";
// ── Event Channels ────────────────────────────────────────────────
pub const EVENT_CHANNEL_CAPACITY: usize = 256;

View File

@ -3,8 +3,6 @@ use std::str::FromStr;
use serde::Serialize;
use crate::interface::communication::event::Event;
// -- Detection Source ---------------------------------------------------------
/// Identifies which detection subsystem produced a detection.
@ -113,8 +111,6 @@ pub struct ThreatDetectedEvent {
pub c2_score: f32,
}
impl Event for ThreatDetectedEvent {}
/// Fired when the ML drift detector finds feature drift beyond 3 sigma.
#[derive(Debug, Clone)]
pub struct DriftDetectedEvent {
@ -122,8 +118,6 @@ pub struct DriftDetectedEvent {
pub max_deviation: f64,
}
impl Event for DriftDetectedEvent {}
// -- Audit Events -------------------------------------------------------------
/// Fired for auditable actions (enforce mode changes, playbook CRUD, etc.).
@ -137,5 +131,3 @@ pub struct AuditEvent {
/// JSON string with action-specific details
pub detail: String,
}
impl Event for AuditEvent {}

View File

@ -18,9 +18,3 @@ pub enum SuricataHealth {
reason: String,
},
}
impl SuricataHealth {
pub fn is_running(&self) -> bool {
matches!(self, SuricataHealth::Running { .. })
}
}

View File

@ -6,6 +6,8 @@ use macros::log;
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;
@ -62,12 +64,12 @@ impl BotnetDetector {
set.last_alert = alert.clone();
if set.sources.len() >= self.threshold {
if let Some(last) = set.last_alerted {
if now.duration_since(last) < self.window {
set.sources.clear();
set.window_start = now;
return None;
}
if let Some(last) = set.last_alerted
&& now.duration_since(last) < self.window
{
set.sources.clear();
set.window_start = now;
return None;
}
Some(set.sources.len())
} else {
@ -86,7 +88,7 @@ impl BotnetDetector {
// SOAR blocks source_ip, so we must NOT put the victim here.
let event = DetectionEvent {
source: DetectionSource::Correlation,
attack_type: "threat_detected".to_string(),
attack_type: CanonicalAttackType::BotActivity.as_str().to_string(),
confidence: 0.85,
source_ip: alert.src_ip.clone(),
dest_ip: key.clone(),
@ -110,25 +112,8 @@ impl BotnetDetector {
None
}
/// Remove expired entries. Returns number of entries removed.
pub fn cleanup(&self) -> usize {
let now = Instant::now();
let window = self.window;
let before = self.state.len();
self.state
.retain(|_, set| now.duration_since(set.window_start) < window);
// Enforce max capacity by removing oldest entries if over limit
if self.state.len() > self.max_tracked {
let excess = self.state.len() - self.max_tracked;
let keys_to_remove: Vec<String> = self.state.iter().take(excess).map(|e| e.key().clone()).collect();
for key in keys_to_remove {
self.state.remove(&key);
}
}
before.saturating_sub(self.state.len())
capped_cleanup(&self.state, self.window, self.max_tracked, |s| s.window_start)
}
}

View File

@ -0,0 +1,26 @@
use std::hash::Hash;
use std::time::{Duration, Instant};
use dashmap::DashMap;
pub fn capped_cleanup<K: Eq + Hash + Clone, V>(
map: &DashMap<K, V>,
window: Duration,
max_tracked: usize,
window_start: impl Fn(&V) -> Instant,
) -> usize {
let now = Instant::now();
let before = map.len();
map.retain(|_, v| now.duration_since(window_start(v)) < window);
if map.len() > max_tracked {
let excess = map.len() - max_tracked;
let keys_to_remove: Vec<K> = map.iter().take(excess).map(|e| e.key().clone()).collect();
for key in keys_to_remove {
map.remove(&key);
}
}
before.saturating_sub(map.len())
}

View File

@ -53,15 +53,7 @@ traceable! {
#[error("Failed to spawn Suricata subprocess")]
SpawnFailed => tracing::Level::ERROR,
#[no_source]
#[error("Suricata subprocess exited: {reason}")]
SubprocessExited { reason: String } => tracing::Level::WARN,
#[error("Failed to open eve.json stream at '{path}'")]
EveOpenFailed { path: String } => tracing::Level::ERROR,
#[no_source]
#[error("Failed to parse eve.json line: {reason}")]
EveParseFailed { reason: String } => tracing::Level::WARN,
}
}

View File

@ -302,9 +302,9 @@ impl FlowTracker {
entry.lock().add_packet(&packet, &self.limits);
}
/// Get all active flows (clone, no drain). Used by WebSocket.
pub fn get_flows(&self) -> Vec<FlowData> {
self.active.iter().map(|(_, entry)| entry.lock().clone()).collect()
/// Extract scalar stats from all active flows without cloning packet vectors.
pub fn get_flow_stats<T>(&self, convert: impl Fn(&FlowData) -> T) -> Vec<T> {
self.active.iter().map(|(_, entry)| convert(&entry.lock())).collect()
}
/// Get flows that received new packets since their last inference, and

View File

@ -7,6 +7,8 @@ use macros::log;
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;
@ -62,12 +64,12 @@ impl LateralMovementDetector {
set.dests.insert(alert.dst_ip.clone());
if set.dests.len() >= self.threshold {
if let Some(last) = set.last_alerted {
if now.duration_since(last) < self.window {
set.dests.clear();
set.window_start = now;
return None;
}
if let Some(last) = set.last_alerted
&& now.duration_since(last) < self.window
{
set.dests.clear();
set.window_start = now;
return None;
}
Some(set.dests.len())
} else {
@ -84,7 +86,7 @@ impl LateralMovementDetector {
let event = DetectionEvent {
source: DetectionSource::Correlation,
attack_type: "threat_detected".to_string(),
attack_type: CanonicalAttackType::LateralMovement.as_str().to_string(),
confidence: 0.75,
source_ip: key.clone(),
dest_ip: alert.dst_ip.clone(),
@ -108,24 +110,8 @@ impl LateralMovementDetector {
None
}
/// Remove expired entries. Returns number of entries removed.
pub fn cleanup(&self) -> usize {
let now = Instant::now();
let window = self.window;
let before = self.state.len();
self.state
.retain(|_, set| now.duration_since(set.window_start) < window);
if self.state.len() > self.max_tracked {
let excess = self.state.len() - self.max_tracked;
let keys_to_remove: Vec<String> = self.state.iter().take(excess).map(|e| e.key().clone()).collect();
for key in keys_to_remove {
self.state.remove(&key);
}
}
before.saturating_sub(self.state.len())
capped_cleanup(&self.state, self.window, self.max_tracked, |s| s.window_start)
}
}

View File

@ -2,6 +2,7 @@ pub mod aggregator;
pub mod attack_type;
pub mod beaconing;
pub mod botnet;
pub mod correlation_cleanup;
pub mod drift;
pub mod drift_detector;
pub mod error;

View File

@ -6,6 +6,7 @@ use macros::log;
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::log::DetectionLog;
use crate::domain::detection::ml_detection::AlertMessage;
@ -61,12 +62,12 @@ impl ScanDetector {
set.last_dst_ip = alert.dst_ip.clone();
if set.ports.len() >= self.threshold {
if let Some(last) = set.last_alerted {
if now.duration_since(last) < self.window {
set.ports.clear();
set.window_start = now;
return None;
}
if let Some(last) = set.last_alerted
&& now.duration_since(last) < self.window
{
set.ports.clear();
set.window_start = now;
return None;
}
Some((set.ports.len(), set.last_dst_ip.clone()))
} else {
@ -103,24 +104,8 @@ impl ScanDetector {
None
}
/// Remove expired entries. Returns number of entries removed.
pub fn cleanup(&self) -> usize {
let now = Instant::now();
let window = self.window;
let before = self.state.len();
self.state
.retain(|_, set| now.duration_since(set.window_start) < window);
if self.state.len() > self.max_tracked {
let excess = self.state.len() - self.max_tracked;
let keys_to_remove: Vec<String> = self.state.iter().take(excess).map(|e| e.key().clone()).collect();
for key in keys_to_remove {
self.state.remove(&key);
}
}
before.saturating_sub(self.state.len())
capped_cleanup(&self.state, self.window, self.max_tracked, |s| s.window_start)
}
}

View File

@ -1,5 +1,11 @@
use serde::{Deserialize, Serialize};
pub const ROLE_ADMIN: &str = "admin";
pub const ROLE_VIEWER: &str = "viewer";
pub const GROUP_ADMIN: &str = "Administrator";
pub const GROUP_VIEWER: &str = "Viewer";
pub const DEFAULT_ADMIN_USERNAME: &str = "admin";
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Claims {
pub sub: i64,

View File

@ -1,3 +1,5 @@
use serde::Serialize;
/// Input for updating a playbook row (without actions/conditions).
pub struct UpdatePlaybookInput {
pub name: String,
@ -50,6 +52,7 @@ pub struct CreatePlaybookInput {
}
/// Persisted condition row for API responses.
#[derive(Serialize)]
pub struct ConditionView {
pub id: i64,
pub condition_type: String,
@ -59,6 +62,7 @@ pub struct ConditionView {
}
/// Flattened playbook representation for API responses.
#[derive(Serialize)]
pub struct PlaybookView {
pub id: i64,
pub name: String,
@ -72,6 +76,7 @@ pub struct PlaybookView {
pub conditions: Vec<ConditionView>,
}
#[derive(Serialize)]
pub struct ActionView {
pub id: i64,
pub action_order: i64,
@ -80,6 +85,7 @@ pub struct ActionView {
}
/// Execution record from soar_executions table.
#[derive(Serialize)]
pub struct ExecutionView {
pub id: i64,
pub playbook_id: i64,
@ -90,9 +96,18 @@ pub struct ExecutionView {
}
/// Active block record from soar_block_rules table.
#[derive(Serialize)]
pub struct ActiveBlockView {
pub id: i64,
pub source_ip: String,
pub playbook_id: i64,
pub expires_at: String,
}
/// Pending unblock recovery record.
#[derive(Serialize)]
pub struct PendingUnblock {
pub id: i64,
pub source_ip: String,
pub retry_count: i64,
}

View File

@ -11,8 +11,8 @@ use tokio::sync::oneshot;
use crate::core::inference::alert::MLAlert;
use crate::core::inference::drift_detector::DriftDetectorHandle;
use crate::core::inference::engine::Engine;
use crate::core::inference::inference::Inference;
use crate::core::inference::model_loader::build_adapter;
use crate::core::inference::runner::Inference;
use crate::core::inference::traffic_logger::{RotationPolicy, TrafficLogger};
use crate::domain::common::config::AppConfig;
use crate::domain::common::config::constants::{MANIFEST_FILENAME, MODELS_DIR};

View File

@ -49,6 +49,8 @@ use crate::infrastructure::system::ShutdownHandle;
use crate::interface::port::api_key::ApiKeyRepo;
use crate::interface::port::app_repo::AppRepo;
use crate::interface::port::audit::AuditRepo;
use crate::interface::port::drop_stats::DropStatsPort;
use crate::interface::port::protocol_filter::ProtocolFilterPort;
/// Shared flag: true when all services (eBPF, ML, SOAR) are fully initialized.
pub type ReadyFlag = Arc<AtomicBool>;
@ -216,7 +218,7 @@ pub fn start_setup_server(
/// Run the full HTTP server with all services.
pub async fn run(params: HttpServerParams) -> Result<(), Error> {
let access_control = params.ebpf_services.access_control.clone();
let protocol_filter = params.ebpf_services.protocol_filter.clone();
let protocol_filter: Arc<dyn ProtocolFilterPort> = params.ebpf_services.protocol_filter.clone();
let dns_filter = params.ebpf_services.dns_filter.clone();
let geo_block = params.ebpf_services.geo_block.clone();
let rate_limit = params.ebpf_services.rate_limit.clone();
@ -227,6 +229,7 @@ pub async fn run(params: HttpServerParams) -> Result<(), Error> {
let fusion_metrics = params.app_services.fusion_metrics.clone();
let flow_statistics = params.app_services.flow_statistics.clone();
let drop_monitor = params.ebpf_services.drop_monitor.clone();
let drop_stats: Arc<dyn DropStatsPort> = drop_monitor.clone();
let app_config = params.app_config;
let inference_config = params.inference_config;
let db = params.db;
@ -278,6 +281,7 @@ pub async fn run(params: HttpServerParams) -> Result<(), Error> {
.app_data(web::Data::from(fusion_metrics.clone()))
.app_data(web::Data::from(flow_statistics.clone()))
.app_data(web::Data::from(drop_monitor.clone()))
.app_data(web::Data::from(drop_stats.clone()))
.app_data(web::Data::from(db.clone() as Arc<dyn AppRepo>))
.app_data(web::Data::from(db.clone() as Arc<dyn ApiKeyRepo>))
.app_data(web::Data::from(db.clone() as Arc<dyn AuditRepo>))

View File

@ -14,6 +14,7 @@ use crate::domain::common::error::Error;
use crate::domain::common::error::crypto::CryptoError;
use crate::domain::common::log::crypto::CryptoLog;
use crate::interface::port::secret_store::SecretStorePort;
use crate::interface::port::setting::SettingRepo;
/// AES-256-GCM envelope encryption for sensitive values stored in `app_secrets`.
pub struct SecretStore {
@ -126,7 +127,8 @@ impl SecretStore {
impl SecretStorePort for SecretStore {
fn get_secret(&self, key: &str) -> Result<Option<String>, Error> {
match self.db.get_app_secret(key)? {
let repo: &dyn SettingRepo = self.db.as_ref();
match repo.get_app_secret(key)? {
Some(envelope_json) => Ok(Some(self.decrypt(&envelope_json)?)),
None => Ok(None),
}
@ -134,7 +136,8 @@ impl SecretStorePort for SecretStore {
fn set_secret(&self, key: &str, plaintext: &str) -> Result<(), Error> {
let envelope = self.encrypt(plaintext)?;
self.db.set_app_secret(key, &envelope)
let repo: &dyn SettingRepo = self.db.as_ref();
repo.set_app_secret(key, &envelope)
}
fn encrypt_envelope(&self, plaintext: &str) -> Result<String, Error> {

View File

@ -26,7 +26,7 @@ use crate::core::data_plane::rate_limit_service::RateLimitService;
use crate::core::identity::jwt::JwtService;
use crate::core::inference::drift_detector::DriftDetectorHandle;
use crate::core::reporting::email_scheduler::ReportScheduler;
use crate::core::response::engine::SoarEngine;
use crate::core::response::engine::{SoarEngine, SoarEngineDeps};
use crate::core::response::playbook_service::PlaybookService;
use crate::core::response::scheduler::TtlScheduler;
use crate::domain::common::config::AppConfig;
@ -109,6 +109,13 @@ fn stage_registry() -> HashMap<&'static str, (&'static str, u32)> {
])
}
struct EbpfBuild {
ingress: Ebpf,
egress: Ebpf,
program_array: ProgramArray<MapData>,
services: EbpfServices,
}
/// Factory responsible for creating and wiring all application services.
pub struct ServiceFactory;
@ -153,7 +160,12 @@ impl ServiceFactory {
// the reason.
let (ingress_ebpf, egress_ebpf, ingress_program_array, ebpf_services) = match Self::try_build_ebpf(&app_config)
{
Ok((ingress, egress, pa, services)) => (Some(ingress), Some(egress), Some(pa), Arc::new(services)),
Ok(build) => (
Some(build.ingress),
Some(build.egress),
Some(build.program_array),
Arc::new(build.services),
),
Err((stage, err)) => {
let health = ebpf_preflight::classify(stage, &err, None);
log!(SystemLog::EbpfBringupFailed(format!("{:?}", health)));
@ -264,16 +276,16 @@ impl ServiceFactory {
// Create SOAR engine
let rate_limit_port: Arc<dyn RateLimitPort> = ebpf_services.rate_limit.clone();
let soar_engine = Arc::new(SoarEngine::new(
db.clone(),
app_config.clone(),
access_control_port.clone(),
alert_notifier.clone(),
geoip.clone(),
Some(rate_limit_port.clone()),
let soar_engine = Arc::new(SoarEngine::new(SoarEngineDeps {
db: db.clone(),
config: app_config.clone(),
access_control: access_control_port.clone(),
alert_notifier: alert_notifier.clone(),
geoip: geoip.clone(),
rate_limit: Some(rate_limit_port.clone()),
enforce_level_cache,
Some(secret_store_port.clone()),
)?);
secrets: Some(secret_store_port.clone()),
})?);
// Create TTL scheduler
let ttl_scheduler = TtlScheduler::new(db.clone(), access_control_port.clone(), soar_engine.clone());
@ -361,11 +373,7 @@ impl ServiceFactory {
/// ingress pipeline, write queue counts, and hand out map handles to the
/// services. Returns the original stage on the first failure so the
/// classifier can render targeted diagnostics.
fn try_build_ebpf(
app_config: &Arc<ArcSwap<AppConfig>>,
) -> Result<(Ebpf, Ebpf, ProgramArray<MapData>, EbpfServices), (EbpfFailStage, Error)> {
use crate::domain::common::system::health::EbpfFailStage;
fn try_build_ebpf(app_config: &Arc<ArcSwap<AppConfig>>) -> Result<EbpfBuild, (EbpfFailStage, Error)> {
let mut ingress = Self::load_ebpf("ingress").map_err(|e| (EbpfFailStage::Load, e))?;
let mut egress = Self::load_ebpf("egress").map_err(|e| (EbpfFailStage::Load, e))?;
@ -381,7 +389,12 @@ impl ServiceFactory {
let services = EbpfServices::new(app_config.clone(), &mut ingress, &mut egress)
.map_err(|e| (EbpfFailStage::MapsBind, e))?;
Ok((ingress, egress, pipeline, services))
Ok(EbpfBuild {
ingress,
egress,
program_array: pipeline,
services,
})
}
fn load_ebpf(name: &str) -> Result<Ebpf, Error> {

View File

@ -42,7 +42,7 @@ impl FlowStatistics {
pub fn get_all_flows(&self) -> Vec<FlowStatsEntry> {
let mut entries = Vec::new();
for tracker in self.engine.trackers() {
entries.extend(tracker.get_flows().iter().map(FlowStatsEntry::from));
entries.extend(tracker.get_flow_stats(|flow| FlowStatsEntry::from(flow)));
}
entries
}

View File

@ -1 +0,0 @@
pub trait Event: Send + Clone + 'static {}

View File

@ -1 +0,0 @@
// Types are available via crate::domain::event

View File

@ -1,2 +0,0 @@
pub mod event;
pub mod event_types;

View File

@ -1,3 +1,2 @@
pub mod communication;
pub mod port;
pub mod utils;

View File

@ -0,0 +1,5 @@
use crate::domain::data_plane::drop_event::DropCounters;
pub trait DropStatsPort: Send + Sync {
fn get_counters(&self) -> DropCounters;
}

View File

@ -7,12 +7,14 @@ pub mod audit;
pub mod db_admin;
pub mod dns_filter_api;
pub mod dns_query_filter;
pub mod drop_stats;
pub mod enforcement;
pub mod geo_block_api;
pub mod geo_lookup;
pub mod identity;
pub mod notification;
pub mod packet_sink;
pub mod protocol_filter;
pub mod rate_limit_api;
pub mod secret_store;
pub mod setting;

View File

@ -0,0 +1,40 @@
use std::collections::HashMap;
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddrV4, SocketAddrV6};
use common::model::http_method::HttpMethod;
use crate::domain::common::error::Error;
pub trait ProtocolFilterPort: Send + Sync {
fn get_ipv4_http_service(&self) -> HashMap<SocketAddrV4, Vec<HttpMethod>>;
fn get_ipv6_http_service(&self) -> HashMap<SocketAddrV6, Vec<HttpMethod>>;
fn add_ipv4_http_service(&self, address: SocketAddrV4, methods: Vec<HttpMethod>) -> Result<(), Error>;
fn add_ipv6_http_service(&self, address: SocketAddrV6, methods: Vec<HttpMethod>) -> Result<(), Error>;
fn remove_ipv4_http_service(&self, address: SocketAddrV4, methods: Vec<HttpMethod>) -> Result<(), Error>;
fn remove_ipv6_http_service(&self, address: SocketAddrV6, methods: Vec<HttpMethod>) -> Result<(), Error>;
fn is_ssh_white_list_enable(&self) -> bool;
fn enable_ssh_white_list(&self) -> Result<(), Error>;
fn disable_ssh_white_list(&self) -> Result<(), Error>;
fn get_ipv4_ssh_service(&self) -> Vec<SocketAddrV4>;
fn get_ipv6_ssh_service(&self) -> Vec<SocketAddrV6>;
fn add_ipv4_ssh_service(&self, address: SocketAddrV4) -> Result<(), Error>;
fn add_ipv6_ssh_service(&self, address: SocketAddrV6) -> Result<(), Error>;
fn remove_ipv4_ssh_service(&self, address: SocketAddrV4) -> Result<(), Error>;
fn remove_ipv6_ssh_service(&self, address: SocketAddrV6) -> Result<(), Error>;
fn get_ipv4_ssh_white_list(&self) -> Vec<Ipv4Addr>;
fn get_ipv6_ssh_white_list(&self) -> Vec<Ipv6Addr>;
fn add_ipv4_ssh_white_list(&self, ip: Ipv4Addr) -> Result<(), Error>;
fn add_ipv6_ssh_white_list(&self, ip: Ipv6Addr) -> Result<(), Error>;
fn remove_ipv4_ssh_white_list(&self, ip: Ipv4Addr) -> Result<(), Error>;
fn remove_ipv6_ssh_white_list(&self, ip: Ipv6Addr) -> Result<(), Error>;
fn get_ipv4_ssh_black_list(&self) -> Vec<Ipv4Addr>;
fn get_ipv6_ssh_black_list(&self) -> Vec<Ipv6Addr>;
fn add_ipv4_ssh_black_list(&self, ip: Ipv4Addr) -> Result<(), Error>;
fn add_ipv6_ssh_black_list(&self, ip: Ipv6Addr) -> Result<(), Error>;
fn remove_ipv4_ssh_black_list(&self, ip: Ipv4Addr) -> Result<(), Error>;
fn remove_ipv6_ssh_black_list(&self, ip: Ipv6Addr) -> Result<(), Error>;
}

View File

@ -1,26 +1,7 @@
use crate::domain::common::error::Error;
use crate::domain::response::playbook_data::UpdatePlaybookInput;
/// Type alias for playbook+action JOIN rows.
/// (id, name, enabled, trigger_event, threshold, count, window, cooldown, action_id, action_order, action_type, params)
pub type PlaybookRow = (
i64,
String,
bool,
String,
Option<f64>,
Option<i64>,
Option<i64>,
i64,
Option<i64>,
Option<i64>,
Option<String>,
Option<String>,
);
/// Type alias for SOAR execution log rows.
/// (id, playbook_id, source_ip, trigger_event, actions_executed, executed_at)
pub type SoarExecutionRow = (i64, i64, Option<String>, String, String, String);
use crate::domain::response::playbook_data::{
ActiveBlockView, CreatePlaybookInput, ExecutionView, PendingUnblock, PlaybookView, UpdatePlaybookInput,
};
/// Threat Response BC — SOAR aggregate repository.
///
@ -31,25 +12,21 @@ pub type SoarExecutionRow = (i64, i64, Option<String>, String, String, String);
/// `DbAdminRepo::with_transaction` + `TxRepos`.
pub trait SoarRepo: Send + Sync {
// --- Playbooks ---
fn list_playbooks_with_actions(&self) -> Result<Vec<PlaybookRow>, Error>;
fn list_playbooks(&self) -> Result<Vec<PlaybookView>, Error>;
fn update_playbook_enabled(&self, id: i64, enabled: bool) -> Result<bool, Error>;
fn delete_playbook(&self, id: i64) -> Result<bool, Error>;
fn seed_default_playbooks(&self) -> Result<(), Error>;
// --- Playbook Conditions ---
/// Returns: (condition_id, playbook_id, condition_type, operator, value, value2)
fn list_all_playbook_conditions(&self) -> Result<Vec<(i64, i64, String, String, String, Option<String>)>, Error>;
// --- Block Rules ---
fn count_active_soar_blocks(&self) -> Result<u32, Error>;
fn list_active_soar_blocks(&self) -> Result<Vec<(i64, String, i64, String)>, Error>;
fn find_soar_block_by_id(&self, id: i64) -> Result<Option<(i64, String, i64, String)>, Error>;
fn list_expired_soar_blocks(&self) -> Result<Vec<(i64, String, i64)>, Error>;
fn list_active_soar_blocks(&self) -> Result<Vec<ActiveBlockView>, Error>;
fn find_soar_block_by_id(&self, id: i64) -> Result<Option<ActiveBlockView>, Error>;
fn list_expired_soar_blocks(&self) -> Result<Vec<ActiveBlockView>, Error>;
fn mark_soar_block_unblocked(&self, id: i64) -> Result<(), Error>;
// --- Pending Unblock Recovery ---
fn insert_pending_unblock(&self, source_ip: &str) -> Result<i64, Error>;
fn list_pending_unblocks(&self) -> Result<Vec<(i64, String, i64)>, Error>;
fn list_pending_unblocks(&self) -> Result<Vec<PendingUnblock>, Error>;
fn delete_pending_unblock(&self, id: i64) -> Result<(), Error>;
fn increment_pending_unblock_retry(&self, id: i64) -> Result<(), Error>;
@ -61,7 +38,7 @@ pub trait SoarRepo: Send + Sync {
trigger_event: &str,
actions_json: &str,
) -> Result<i64, Error>;
fn list_soar_executions(&self, limit: i64) -> Result<Vec<SoarExecutionRow>, Error>;
fn list_soar_executions(&self, limit: i64) -> Result<Vec<ExecutionView>, Error>;
// --- Intra-aggregate atomic operations ---
@ -73,12 +50,7 @@ pub trait SoarRepo: Send + Sync {
/// `conditions` tuples: `(condition_type, operator, value, value2)`.
fn insert_playbook_atomic(
&self,
name: &str,
trigger_event: &str,
threshold: Option<f64>,
count: Option<i64>,
window: Option<i64>,
cooldown: i64,
input: &CreatePlaybookInput,
actions: &[(i64, String, String)],
conditions: &[(String, String, String, Option<String>)],
) -> Result<i64, Error>;

View File

@ -25,6 +25,7 @@ use crate::domain::common::config::observability::ObservabilityConfig;
use crate::domain::common::error::Error;
use crate::domain::common::error::system::SystemError;
use crate::domain::common::log::system::SystemLog;
use crate::domain::identity::auth::{DEFAULT_ADMIN_USERNAME, GROUP_ADMIN, ROLE_ADMIN};
use crate::domain::identity::password;
use crate::infrastructure::cli::{Cli, handle_subcommand};
use crate::infrastructure::http_server;
@ -37,10 +38,10 @@ fn seed_default_admin(database: &Arc<Database>) -> Result<(), Error> {
if database.user_count().unwrap_or(0) != 0 {
return Ok(());
}
let hash = password::hash_password("admin")?;
let admin_user_id = database.insert_user("admin", &hash, "admin", false)?;
let hash = password::hash_password(DEFAULT_ADMIN_USERNAME)?;
let admin_user_id = database.insert_user(DEFAULT_ADMIN_USERNAME, &hash, ROLE_ADMIN, false)?;
if let Ok(groups) = database.list_user_groups()
&& let Some((group_id, _, _, _, _)) = groups.into_iter().find(|(_, name, _, _, _)| name == "Administrator")
&& let Some((group_id, _, _, _, _)) = groups.into_iter().find(|(_, name, _, _, _)| name == GROUP_ADMIN)
&& let Err(err) = database.set_user_groups(admin_user_id, &[group_id])
{
log!(SystemError::SetUserGroupsFailed(err));