wip: Architecture adjustment

This commit is contained in:
DaLaw2 2026-04-25 21:19:54 +08:00
parent c2c4ed5b23
commit 7043ec0c43
126 changed files with 2358 additions and 2458 deletions

View File

@ -1,5 +1,4 @@
pub mod access_control;
pub mod dns_filter;
pub mod drop_monitor;
pub mod geo_block;
pub mod protocol_filter;
@ -17,12 +16,12 @@ use parking_lot::Mutex;
use tokio::sync::oneshot;
use crate::adapter::ebpf::access_control::AccessControl;
use crate::adapter::ebpf::dns_filter::DnsFilter;
use crate::adapter::ebpf::drop_monitor::DropMonitor;
use crate::adapter::ebpf::geo_block::GeoBlock;
use crate::adapter::ebpf::protocol_filter::ProtocolFilter;
use crate::adapter::ebpf::rate_limit::RateLimitConfig;
use crate::adapter::ebpf::xsk_manager::XskManager;
use crate::core::data_plane::dns_filter::DnsFilter;
use crate::domain::common::config::AppConfig;
use crate::domain::common::error::Error;
use crate::domain::common::error::system::SystemError;

View File

@ -1,7 +1,7 @@
use actix_web::{HttpResponse, Scope, web};
use serde::Deserialize;
use crate::core::identity::extractor::AuthClaims;
use crate::adapter::http::middleware::extractor::AuthClaims;
use crate::interface::port::api_key::ApiKeyRepo;
pub fn initialize() -> Scope {
@ -16,13 +16,13 @@ async fn list_keys(_auth: AuthClaims, db: web::Data<dyn ApiKeyRepo>) -> HttpResp
Ok(keys) => {
let responses: Vec<serde_json::Value> = keys
.into_iter()
.map(|(id, name, level, created, last_used)| {
.map(|k| {
serde_json::json!({
"id": id,
"name": name,
"permission_level": level,
"created_at": created,
"last_used_at": last_used,
"id": k.id,
"name": k.name,
"permission_level": k.permission_level,
"created_at": k.created_at,
"last_used_at": k.last_used_at,
})
})
.collect();

View File

@ -1,7 +1,7 @@
use actix_web::{HttpResponse, Scope, web};
use crate::adapter::http::middleware::extractor::AuthClaims;
use crate::adapter::persistence::Database;
use crate::core::identity::extractor::AuthClaims;
use crate::domain::common::error::Error;
use crate::domain::common::error::database::DatabaseError;
use crate::interface::port::audit::AuditRepo;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -9,10 +9,12 @@ pub mod flow_trace;
pub mod fusion;
pub mod health;
pub mod logs;
pub mod middleware;
pub mod ml;
pub mod model_upload;
pub mod notification;
pub mod rate_limit;
pub mod ready;
pub mod report;
pub mod response;
pub mod setup;

View File

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

View File

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

View File

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

View File

@ -5,11 +5,11 @@ use arc_swap::ArcSwap;
use chrono::Local;
use tokio::task::spawn_blocking;
use crate::adapter::http::middleware::extractor::AuthClaims;
use crate::adapter::http::response::ok_json_or_error;
use crate::adapter::notification::smtp::SmtpClient;
use crate::adapter::persistence::Database;
use crate::core::identity::extractor::AuthClaims;
use crate::core::reporting::email_report::generate_weekly_report;
use crate::core::reporting::email_scheduler::SmtpClient;
use crate::core::reporting::report_engine;
use crate::domain::common::config::AppConfig;
use crate::domain::common::error::misc::MiscError;

View File

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

View File

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

View File

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

View File

@ -1,10 +1,10 @@
use actix_web::{HttpResponse, Responder, Scope, web};
use serde::Deserialize;
use crate::adapter::http::middleware::extractor::AuthClaims;
use crate::core::common::config_service::ConfigService;
use crate::core::identity::extractor::AuthClaims;
use crate::core::common::enforce_mode_handler::EnforceModeHandler;
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};
use crate::interface::port::app_repo::AppRepo;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,9 +5,10 @@ use rusqlite::params;
use sha2::{Digest, Sha256};
use super::Database;
use crate::domain::common::audit::AuditLogEntry;
use crate::domain::common::error::Error;
use crate::domain::common::error::database::DatabaseError;
use crate::interface::port::audit::{AuditLogEntry, AuditRepo};
use crate::interface::port::audit::AuditRepo;
/// Compute the row hash for an audit_log entry.
/// Formula: sha256_hex(ts || 0x00 || actor || 0x00 || action || 0x00 || detail || 0x00 || prev_hash)

View File

@ -18,7 +18,19 @@ use rusqlite::{self, Connection, params};
use crate::domain::common::error::Error;
use crate::domain::common::error::database::DatabaseError;
use crate::domain::common::log::misc::MiscLog;
use crate::domain::identity::auth::{GROUP_ADMIN, GROUP_VIEWER};
impl From<rusqlite::Error> for DatabaseError {
fn from(e: rusqlite::Error) -> Self {
DatabaseError::QueryFailed(e)
}
}
impl From<rusqlite::Error> for Error {
fn from(e: rusqlite::Error) -> Self {
Self::Database(DatabaseError::from(e))
}
}
use crate::domain::identity::auth::{ADMIN_PERMISSIONS, GROUP_ADMIN, GROUP_VIEWER, VIEWER_PERMISSIONS};
/// Reads the SQLCipher encryption key from the environment variable `NETGUARDIA_DB_KEY`.
/// Returns `Some(key)` if set and non-empty, `None` otherwise (dev / unencrypted mode).
@ -334,51 +346,8 @@ impl Database {
// Seed default user groups on first install (empty table)
let group_count: i64 = conn_ref.query_row("SELECT COUNT(*) FROM user_groups", [], |row| row.get(0))?;
if group_count == 0 {
let all_permissions = serde_json::json!([
"dashboard:read",
"statistics:read",
"traffic_map:read",
"drops:read",
"ai_detection:read",
"ai_detection:write",
"access_control:read",
"access_control:write",
"geo_block:read",
"geo_block:write",
"dns_filter:read",
"dns_filter:write",
"rate_limit:read",
"rate_limit:write",
"protocol_filter:read",
"protocol_filter:write",
"system:read",
"system:write",
"system:admin",
"users:read",
"users:write",
"users:admin",
"fusion:read",
"fusion:write",
"flow_trace:read",
"flow_trace:write"
])
.to_string();
let viewer_permissions = serde_json::json!([
"dashboard:read",
"statistics:read",
"traffic_map:read",
"drops:read",
"ai_detection:read",
"access_control:read",
"geo_block:read",
"dns_filter:read",
"rate_limit:read",
"protocol_filter:read",
"system:read",
"fusion:read",
"flow_trace:read"
])
.to_string();
let all_permissions = serde_json::to_string(&ADMIN_PERMISSIONS).unwrap_or_else(|_| "[]".to_string());
let viewer_permissions = serde_json::to_string(&VIEWER_PERMISSIONS).unwrap_or_else(|_| "[]".to_string());
conn_ref.execute(
"INSERT INTO user_groups (name, description, permissions) VALUES (?1, ?2, ?3)",

View File

@ -5,6 +5,7 @@ use serde_json::Value;
use super::Database;
use crate::domain::common::error::Error;
use crate::domain::response::defaults::DEFAULT_PLAYBOOKS;
use crate::domain::response::playbook_data::{
ActionView, ActiveBlockView, ConditionView, CreatePlaybookInput, ExecutionView, PendingUnblock, PlaybookView,
UpdatePlaybookInput,
@ -198,40 +199,22 @@ impl Database {
}
drop(conn);
// 1. brute_force_block: brute_force, count 5 in 60s → block_ip(3600s) + send_telegram + log
let pb1 = self.insert_playbook("brute_force_block", "brute_force", None, Some(5), Some(60), 600)?;
self.insert_playbook_action(pb1, 1, "block_ip", r#"{"ttl_secs": 3600}"#)?;
self.insert_playbook_action(pb1, 2, "send_telegram", "{}")?;
self.insert_playbook_action(pb1, 3, "log", r#"{"level": "warn"}"#)?;
self.insert_playbook_condition(pb1, "frequency", ">=", "5", Some("60"))?;
// 2. port_scan_alert: port_scan, threshold 0.7 → send_telegram + log (no block)
let pb2 = self.insert_playbook("port_scan_alert", "port_scan", Some(0.7), None, None, 300)?;
self.insert_playbook_action(pb2, 1, "send_telegram", "{}")?;
self.insert_playbook_action(pb2, 2, "log", r#"{"level": "warn"}"#)?;
self.insert_playbook_condition(pb2, "threshold", ">=", "0.7", None)?;
// 3. fusion_c2_multi_source_block — C2 beacon observed by ≥2 sources
// (e.g. Suricata trojan-activity + Beaconing CV + ML c2 class) is
// the highest-precision fusion signal we ship. Block for 1h and
// notify, no solo-source threshold so single-source C2 hits still
// require the solo playbook below to act.
let pb3 = self.insert_playbook("fusion_c2_multi_source_block", "c2_beacon", None, None, None, 600)?;
self.insert_playbook_action(pb3, 1, "block_ip", r#"{"ttl_secs": 3600}"#)?;
self.insert_playbook_action(pb3, 2, "send_telegram", "{}")?;
self.insert_playbook_action(pb3, 3, "log", r#"{"level": "warn"}"#)?;
self.insert_playbook_condition(pb3, "multi_source_min", ">=", "2", None)?;
// 4. fusion_c2_suricata_solo_high_block — the escape hatch for
// Suricata signature hits with very high confidence (>=0.95).
// Lets known-good rules fire without waiting for agreement from a
// second source, matching how analysts intuitively treat a
// signature "dead-on" match.
let pb4 = self.insert_playbook("fusion_c2_suricata_solo_high_block", "c2_beacon", None, None, None, 600)?;
self.insert_playbook_action(pb4, 1, "block_ip", r#"{"ttl_secs": 3600}"#)?;
self.insert_playbook_action(pb4, 2, "send_telegram", "{}")?;
self.insert_playbook_action(pb4, 3, "log", r#"{"level": "warn"}"#)?;
self.insert_playbook_condition(pb4, "single_source_high", "==", "Suricata", Some("0.95"))?;
for def in DEFAULT_PLAYBOOKS {
let pb_id = self.insert_playbook(
def.name,
def.trigger_event,
def.threshold,
def.count,
def.window,
def.cooldown,
)?;
for action in def.actions {
self.insert_playbook_action(pb_id, action.order, action.action_type, action.params)?;
}
for cond in def.conditions {
self.insert_playbook_condition(pb_id, cond.condition_type, cond.operator, cond.value, cond.value2)?;
}
}
Ok(())
}

View File

@ -6,22 +6,23 @@ use rusqlite::{Error as RusqliteError, params};
use super::Database;
use crate::domain::common::error::Error;
use crate::domain::common::error::database::DatabaseError;
use crate::interface::port::identity::{IdentityRepo, UserGroupTuple, UserTuple, UserWithGroups};
use crate::domain::identity::auth::{LOGIN_LOCKOUT_SECS, LOGIN_MAX_FAILURES};
use crate::domain::identity::user::{UserGroupMembership, UserGroupView, UserView, UserWithGroupsView};
use crate::interface::port::identity::IdentityRepo;
impl Database {
pub fn find_user(&self, username: &str) -> Result<Option<UserTuple>, Error> {
pub fn find_user(&self, username: &str) -> Result<Option<UserView>, Error> {
let conn = self.conn()?;
let result = conn.query_row(
"SELECT id, username, password_hash, role, force_password_change FROM users WHERE username = ?1",
"SELECT id, username, password_hash, force_password_change FROM users WHERE username = ?1",
params![username],
|row| {
Ok((
row.get(0)?,
row.get(1)?,
row.get(2)?,
row.get(3)?,
row.get::<_, i64>(4)? != 0,
))
Ok(UserView {
id: row.get(0)?,
username: row.get(1)?,
password_hash: row.get(2)?,
force_password_change: row.get::<_, i64>(3)? != 0,
})
},
);
match result {
@ -67,10 +68,10 @@ impl Database {
Ok(conn.query_row("SELECT COUNT(*) FROM users", [], |row| row.get(0))?)
}
pub fn list_users_with_groups(&self) -> Result<Vec<UserWithGroups>, Error> {
pub fn list_users_with_groups(&self) -> Result<Vec<UserWithGroupsView>, Error> {
let conn = self.conn()?;
let mut stmt = conn.prepare(
"SELECT u.id, u.username, u.role, u.force_password_change, u.created_at, \
"SELECT u.id, u.username, u.force_password_change, u.created_at, \
g.id, g.name \
FROM users u \
LEFT JOIN user_group_members m ON u.id = m.user_id \
@ -81,25 +82,33 @@ impl Database {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, i64>(3)? != 0,
row.get::<_, String>(4)?,
row.get::<_, Option<i64>>(5)?,
row.get::<_, Option<String>>(6)?,
row.get::<_, i64>(2)? != 0,
row.get::<_, String>(3)?,
row.get::<_, Option<i64>>(4)?,
row.get::<_, Option<String>>(5)?,
))
})?;
let mut user_map: HashMap<i64, UserWithGroups> = HashMap::new();
let mut user_map: HashMap<i64, UserWithGroupsView> = HashMap::new();
let mut order: Vec<i64> = Vec::new();
for row in rows {
let (id, username, role, force_pw, created_at, group_id, group_name) = row?;
let (id, username, force_pw, created_at, group_id, group_name) = row?;
let entry = user_map.entry(id).or_insert_with(|| {
order.push(id);
(id, username, role, force_pw, created_at, Vec::new())
UserWithGroupsView {
id,
username,
force_password_change: force_pw,
created_at,
groups: Vec::new(),
}
});
if let (Some(gid), Some(gname)) = (group_id, group_name) {
entry.5.push((gid, gname));
entry.groups.push(UserGroupMembership {
group_id: gid,
group_name: gname,
});
}
}
@ -128,19 +137,18 @@ impl Database {
Ok(())
}
pub fn find_user_by_id(&self, user_id: i64) -> Result<Option<UserTuple>, Error> {
pub fn find_user_by_id(&self, user_id: i64) -> Result<Option<UserView>, Error> {
let conn = self.conn()?;
let result = conn.query_row(
"SELECT id, username, password_hash, role, force_password_change FROM users WHERE id = ?1",
"SELECT id, username, password_hash, force_password_change FROM users WHERE id = ?1",
params![user_id],
|row| {
Ok((
row.get(0)?,
row.get(1)?,
row.get(2)?,
row.get(3)?,
row.get::<_, i64>(4)? != 0,
))
Ok(UserView {
id: row.get(0)?,
username: row.get(1)?,
password_hash: row.get(2)?,
force_password_change: row.get::<_, i64>(3)? != 0,
})
},
);
match result {
@ -150,18 +158,18 @@ impl Database {
}
}
pub fn list_user_groups(&self) -> Result<Vec<UserGroupTuple>, Error> {
pub fn list_user_groups(&self) -> Result<Vec<UserGroupView>, Error> {
let conn = self.conn()?;
let mut stmt =
conn.prepare("SELECT id, name, description, permissions, created_at FROM user_groups ORDER BY id")?;
let rows = stmt.query_map([], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
row.get::<_, String>(4)?,
))
Ok(UserGroupView {
id: row.get(0)?,
name: row.get(1)?,
description: row.get(2)?,
permissions: row.get(3)?,
created_at: row.get(4)?,
})
})?;
let mut results = Vec::new();
for row in rows {
@ -202,19 +210,19 @@ impl Database {
Ok(affected > 0)
}
pub fn get_user_group(&self, id: i64) -> Result<Option<UserGroupTuple>, Error> {
pub fn get_user_group(&self, id: i64) -> Result<Option<UserGroupView>, Error> {
let conn = self.conn()?;
let result = conn.query_row(
"SELECT id, name, description, permissions, created_at FROM user_groups WHERE id = ?1",
params![id],
|row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
row.get::<_, String>(4)?,
))
Ok(UserGroupView {
id: row.get(0)?,
name: row.get(1)?,
description: row.get(2)?,
permissions: row.get(3)?,
created_at: row.get(4)?,
})
},
);
match result {
@ -315,12 +323,12 @@ impl Database {
self.set_setting(&key_count, &count.to_string())?;
if count >= 5 {
if count >= LOGIN_MAX_FAILURES {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or(Duration::ZERO)
.as_secs();
let locked_until = now + 900; // 15 minutes
let locked_until = now + LOGIN_LOCKOUT_SECS;
self.set_setting(&key_locked, &locked_until.to_string())?;
Ok((count, Some(locked_until)))
} else {
@ -361,11 +369,11 @@ impl Database {
}
impl IdentityRepo for Database {
fn find_user(&self, username: &str) -> Result<Option<UserTuple>, Error> {
fn find_user(&self, username: &str) -> Result<Option<UserView>, Error> {
self.find_user(username)
}
fn find_user_by_id(&self, user_id: i64) -> Result<Option<UserTuple>, Error> {
fn find_user_by_id(&self, user_id: i64) -> Result<Option<UserView>, Error> {
self.find_user_by_id(user_id)
}
@ -383,7 +391,7 @@ impl IdentityRepo for Database {
self.update_user_password(user_id, password_hash)
}
fn list_users_with_groups(&self) -> Result<Vec<UserWithGroups>, Error> {
fn list_users_with_groups(&self) -> Result<Vec<UserWithGroupsView>, Error> {
self.list_users_with_groups()
}
@ -399,7 +407,7 @@ impl IdentityRepo for Database {
self.reset_user_password(user_id, password_hash)
}
fn list_user_groups(&self) -> Result<Vec<UserGroupTuple>, Error> {
fn list_user_groups(&self) -> Result<Vec<UserGroupView>, Error> {
self.list_user_groups()
}
@ -415,7 +423,7 @@ impl IdentityRepo for Database {
self.delete_user_group(id)
}
fn get_user_group(&self, id: i64) -> Result<Option<UserGroupTuple>, Error> {
fn get_user_group(&self, id: i64) -> Result<Option<UserGroupView>, Error> {
self.get_user_group(id)
}
@ -465,11 +473,10 @@ mod tests {
assert_eq!(db.user_count().unwrap(), 1);
let user = db.find_user("admin").unwrap().unwrap();
assert_eq!(user.0, 1); // id
assert_eq!(user.1, "admin"); // username
assert_eq!(user.2, "hash123"); // password_hash
assert_eq!(user.3, "admin"); // role
assert!(user.4); // force_password_change
assert_eq!(user.id, 1);
assert_eq!(user.username, "admin");
assert_eq!(user.password_hash, "hash123");
assert!(user.force_password_change);
}
#[test]
@ -486,13 +493,13 @@ mod tests {
db.insert_user("admin", "old_hash", "admin", true).unwrap();
let user = db.find_user("admin").unwrap().unwrap();
assert!(user.4); // force_password_change = true
assert!(user.force_password_change);
db.update_user_password(user.0, "new_hash").unwrap();
db.update_user_password(user.id, "new_hash").unwrap();
let user = db.find_user("admin").unwrap().unwrap();
assert!(!user.4); // force_password_change = false
assert_eq!(user.2, "new_hash");
assert!(!user.force_password_change);
assert_eq!(user.password_hash, "new_hash");
}
#[test]

View File

@ -12,7 +12,8 @@ use crate::domain::common::config::AppConfig;
use crate::domain::common::error::Error;
use crate::domain::common::error::notification::NotificationError;
use crate::domain::common::log::system::SystemLog;
use crate::interface::port::notification::{AlertNotifier, AlertNotifierFactory, AlertPayload};
use crate::domain::common::notification::AlertPayload;
use crate::interface::port::notification::{AlertNotifier, AlertNotifierFactory};
use crate::interface::port::secret_store::SecretStorePort;
use crate::interface::port::setting::SettingRepo;

View File

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

View File

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

View File

@ -4,20 +4,12 @@ use std::sync::atomic::{AtomicU8, Ordering};
use macros::log;
use tokio::sync::broadcast;
use crate::domain::common::config::constants::enforce_mode_to_u8;
use crate::domain::common::error::Error;
use crate::domain::common::event::AuditEvent;
use crate::domain::common::log::system::SystemLog;
use crate::interface::port::app_repo::AppRepo;
/// Map enforce-mode string to u8: monitor=0, ml_only=1, enforce=2.
pub fn enforce_mode_to_u8(mode: &str) -> u8 {
match mode {
"enforce" => 2,
"ml_only" => 1,
_ => 0, // "monitor" or unknown → safest default
}
}
/// Handles enforce-mode commands and queries by delegating to the repository.
pub struct EnforceModeHandler {
db: Arc<dyn AppRepo>,

View File

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

View File

@ -3,7 +3,7 @@ use std::sync::Arc;
use arc_swap::ArcSwap;
use serde_json::Value;
use crate::core::reporting::email_scheduler::SmtpClient;
use crate::adapter::notification::smtp::SmtpClient;
use crate::domain::common::config::AppConfig;
use crate::domain::common::error::Error;
use crate::domain::common::error::misc::MiscError;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -36,8 +36,8 @@ impl AclService {
self.access_control.add_ipv4_list(direction, list_type, address)?;
if let Err(e) = self.db.insert_acl_rule(
4,
direction_str(direction),
list_type_str(list_type),
direction.as_str(),
list_type.as_str(),
&address.ip().to_string(),
address.port(),
) {
@ -53,8 +53,8 @@ impl AclService {
self.access_control.add_ipv6_list(direction, list_type, address)?;
if let Err(e) = self.db.insert_acl_rule(
6,
direction_str(direction),
list_type_str(list_type),
direction.as_str(),
list_type.as_str(),
&address.ip().to_string(),
address.port(),
) {
@ -75,8 +75,8 @@ impl AclService {
self.access_control.remove_ipv4_list(direction, list_type, address)?;
if let Err(e) = self.db.delete_acl_rule(
4,
direction_str(direction),
list_type_str(list_type),
direction.as_str(),
list_type.as_str(),
&address.ip().to_string(),
address.port(),
) {
@ -97,8 +97,8 @@ impl AclService {
self.access_control.remove_ipv6_list(direction, list_type, address)?;
if let Err(e) = self.db.delete_acl_rule(
6,
direction_str(direction),
list_type_str(list_type),
direction.as_str(),
list_type.as_str(),
&address.ip().to_string(),
address.port(),
) {
@ -136,17 +136,3 @@ impl AclService {
self.access_control.as_ref()
}
}
fn direction_str(d: FlowDirection) -> &'static str {
match d {
FlowDirection::Source => "source",
FlowDirection::Destination => "destination",
}
}
fn list_type_str(l: ListType) -> &'static str {
match l {
ListType::White => "whitelist",
ListType::Black => "blacklist",
}
}

View File

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

View File

@ -1,6 +1,7 @@
use std::sync::Arc;
use crate::domain::common::error::Error;
use crate::domain::common::system::rate_limit_settings::RateLimitSettings;
use crate::interface::port::app_repo::AppRepo;
use crate::interface::port::rate_limit_api::RateLimitPort;
@ -43,5 +44,3 @@ impl RateLimitService {
Ok(())
}
}
use crate::domain::common::system::rate_limit_settings::RateLimitSettings;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -20,14 +20,14 @@ use arc_swap::ArcSwap;
use macros::log;
use tract_onnx::prelude::*;
use crate::core::inference::model_adapter::{MLModelAdapter, ModelSourceState, RunnableModel};
use crate::domain::common::config::AppConfig;
use crate::domain::detection::flow_features::FlowFeatures;
use crate::domain::detection::flow_tracker::FlowData;
use crate::domain::detection::log::MLLog;
use crate::domain::detection::manifest::LabelSpec;
use crate::domain::detection::ml_detection::{DetectionResult, RunnableModel};
use crate::domain::detection::ml_detection::DetectionResult;
use crate::domain::detection::ml_inference_config::MLInferenceConfig;
use crate::domain::detection::model_adapter::{MLModelAdapter, ModelSourceState};
use crate::domain::detection::model_source::ModelSourceStatus;
/// (anomaly_scores, per_class_probs, c2_scores) — MultiTask batch output.
@ -80,7 +80,7 @@ impl Inference {
/// Current state snapshot for wire broadcast. Merges the in-memory
/// `qps_recent` atomic into the Active info so the UI sees live QPS.
pub fn current_status(&self) -> ModelSourceStatus {
pub fn model_source_status(&self) -> ModelSourceStatus {
let guard = self.state.load();
let mut status = guard.to_status();
if let ModelSourceStatus::Active { ref mut info } = status {

View File

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

View File

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

View File

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

View File

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

View File

@ -4,6 +4,7 @@ use std::path::PathBuf;
use chrono::Local;
use macros::log;
use super::report_data_builder::build_report_data;
use crate::domain::common::error::Error;
use crate::domain::common::error::io::IOError;
use crate::domain::common::error::misc::MiscError;
@ -14,7 +15,7 @@ use crate::interface::port::setting::SettingRepo;
/// Generate a self-contained HTML security report and write to disk.
/// Returns the path to the generated HTML file.
pub fn generate_html_report(db: &dyn SettingRepo, output_dir: &str) -> Result<PathBuf, Error> {
let data = ReportData::from_database(db)?;
let data = build_report_data(db)?;
let html = render_html_report(&data);
let html_path = PathBuf::from(output_dir).join(format!(
@ -203,6 +204,6 @@ fn html_escape(s: &str) -> String {
/// Generate report data and format as JSON (for API responses).
pub fn generate_report_json(db: &dyn SettingRepo) -> Result<serde_json::Value, Error> {
let data = ReportData::from_database(db)?;
let data = build_report_data(db)?;
serde_json::to_value(&data).map_err(|e| MiscError::SerializeError(e).into())
}

View File

@ -7,19 +7,19 @@ use tokio::time::{self, Duration};
use crate::domain::common::error::Error;
use crate::domain::common::log::system::SystemLog;
use crate::infrastructure::health::SystemHealth;
use crate::interface::port::setting::SettingRepo;
use crate::interface::port::stats::StatsRepo;
/// Background service that periodically aggregates statistics from SOAR/ML tables
/// and writes them to the settings table for the Report engine to consume.
pub struct StatsAggregator {
stats: Arc<dyn StatsRepo>,
repo: Arc<dyn SettingRepo + Send + Sync>,
health: Arc<SystemHealth>,
}
impl StatsAggregator {
pub fn new(stats: Arc<dyn StatsRepo>, repo: Arc<dyn SettingRepo + Send + Sync>) -> Self {
Self { stats, repo }
pub fn new(stats: Arc<dyn StatsRepo>, repo: Arc<dyn SettingRepo + Send + Sync>, health: Arc<SystemHealth>) -> Self {
Self { stats, repo, health }
}
/// Spawn a background task that runs aggregation every hour.
@ -93,34 +93,23 @@ impl StatsAggregator {
let active_rules = self.stats.count_acl_rules()?;
self.repo.set_setting("active_rules_count", &active_rules.to_string())?;
// System health snapshot using sysinfo
{
use sysinfo::System;
let mut sys = System::new();
sys.refresh_cpu_all();
sys.refresh_memory();
let cpu_usage = sys.global_cpu_usage() as f64;
let mem_total = sys.total_memory();
let mem_used = sys.used_memory();
let mem_percent = if mem_total > 0 {
(mem_used as f64 / mem_total as f64) * 100.0
} else {
0.0
};
let metrics = self.health.get_current_metrics();
let cpu_usage = metrics.cpu_details.cpu_usage as f64;
let mem_percent = metrics.memory_usage.usage_percent as f64;
let health_json = serde_json::json!({
"avg_cpu_percent": cpu_usage,
"avg_memory_percent": mem_percent,
"disk_usage_percent": 0.0,
"ebpf_status": "running",
"ebpf_status": format!("{:?}", metrics.ebpf),
});
self.repo.set_setting(
"weekly_system_health",
&serde_json::to_string(&health_json).unwrap_or_else(|_| "{}".to_string()),
)?;
// System uptime
let uptime_secs = System::uptime();
let uptime_secs = metrics.uptime_seconds;
let week_secs = (days as u64) * 86400;
let uptime_percent = if uptime_secs >= week_secs {
100.0
@ -151,6 +140,15 @@ impl StatsAggregator {
mod tests {
use super::*;
use crate::adapter::persistence::Database;
use crate::domain::common::config::AppConfig;
use crate::domain::common::system::health::EbpfHealth;
fn test_health(db: &Arc<Database>) -> Arc<SystemHealth> {
use arc_swap::ArcSwap;
let config = Arc::new(ArcSwap::from_pointee(AppConfig::from_settings(db.as_ref()).unwrap()));
let ebpf_health = Arc::new(ArcSwap::from_pointee(EbpfHealth::Healthy));
Arc::new(SystemHealth::new(config, ebpf_health).unwrap())
}
#[test]
fn aggregator_writes_weekly_stats() {
@ -163,13 +161,14 @@ mod tests {
db.insert_soar_execution(1, Some("5.6.7.8"), "brute_force", "[]").ok();
db.insert_soar_block_rule("1.2.3.4", 1, "2099-01-01 00:00:00").ok();
let health = test_health(&db);
let aggregator = StatsAggregator::new(
db.clone() as Arc<dyn StatsRepo>,
db.clone() as Arc<dyn SettingRepo + Send + Sync>,
health,
);
aggregator.aggregate().expect("aggregation should succeed");
// Verify settings were written
let threats = db.get_setting("weekly_threats_count").unwrap().unwrap();
assert_eq!(threats, "2");
@ -187,17 +186,19 @@ mod tests {
let uptime = db.get_setting("system_uptime_percent").unwrap().unwrap();
assert!(!uptime.is_empty());
let health = db.get_setting("weekly_system_health").unwrap().unwrap();
let health_val: serde_json::Value = serde_json::from_str(&health).unwrap();
assert!(health_val["ebpf_status"].as_str() == Some("running"));
let sys_health = db.get_setting("weekly_system_health").unwrap().unwrap();
let health_val: serde_json::Value = serde_json::from_str(&sys_health).unwrap();
assert!(health_val["avg_cpu_percent"].as_f64().is_some());
}
#[test]
fn aggregator_handles_empty_db() {
let db = Arc::new(Database::new(":memory:").expect("test db"));
let health = test_health(&db);
let aggregator = StatsAggregator::new(
db.clone() as Arc<dyn StatsRepo>,
db.clone() as Arc<dyn SettingRepo + Send + Sync>,
health,
);
aggregator
.aggregate()

View File

@ -17,17 +17,17 @@ use tokio::net::lookup_host;
use tokio::task::spawn_blocking;
use url::Url;
use crate::core::reporting::email_scheduler::SmtpClient;
use crate::adapter::notification::smtp::SmtpClient;
use crate::core::response::engine::SoarEngine;
use crate::core::response::playbook_service::ip_version_from_str;
use crate::domain::common::error::Error;
use crate::domain::common::event::ThreatDetectedEvent;
use crate::domain::common::notification::AlertPayload;
use crate::domain::response::error::SoarError;
use crate::domain::response::log::SoarLog;
use crate::domain::response::playbook::{Playbook, PlaybookAction};
use crate::interface::port::access_control::AccessControlPort;
use crate::interface::port::app_repo::AppRepo;
use crate::interface::port::notification::AlertPayload;
use crate::utils::ip_address::{ip_version_from_str, is_private_ip};
/// Lower bound on the rate-limit factor — anything below 1% of current
/// would brick traffic flow.
@ -361,7 +361,7 @@ impl SoarEngine {
}
for addr in &addrs {
if crate::domain::response::matcher::is_private_ip(&addr.ip()) {
if is_private_ip(&addr.ip()) {
log!(SoarLog::EventHandlingFailed(format!(
"SSRF blocked: webhook URL '{}' resolved to private IP {}",
url_str,

View File

@ -8,6 +8,7 @@ use tokio::sync::Semaphore;
use tokio::sync::broadcast;
use tokio::sync::broadcast::error::RecvError;
use crate::core::response::matcher::PlaybookMatcher;
use crate::core::response::rate_limit_owner::RateLimitOwnerHandle;
use crate::domain::common::config::AppConfig;
use crate::domain::common::error::Error;
@ -15,7 +16,6 @@ use crate::domain::common::event::ThreatDetectedEvent;
use crate::domain::detection::attack_type::canonical_from_str;
use crate::domain::response::condition::{ConditionType, PlaybookCondition};
use crate::domain::response::log::SoarLog;
use crate::domain::response::matcher::PlaybookMatcher;
use crate::domain::response::playbook::{Playbook, PlaybookAction};
use crate::interface::port::access_control::AccessControlPort;
use crate::interface::port::app_repo::AppRepo;

View File

@ -9,11 +9,11 @@ use arc_swap::ArcSwap;
use dashmap::DashMap;
use macros::log;
use crate::core::response::frequency::FrequencyTracker;
use crate::domain::common::config::AppConfig;
use crate::domain::common::event::{DetectionSource, ThreatDetectedEvent};
use crate::domain::response::condition::{ConditionType, PlaybookCondition};
use crate::domain::response::dry_run::{DryRunAction, DryRunConditionResult, DryRunMatch};
use crate::domain::response::frequency::FrequencyTracker;
use crate::domain::response::log::SoarLog;
use crate::domain::response::playbook::Playbook;
@ -290,20 +290,6 @@ impl PlaybookMatcher {
}
}
pub fn is_private_ip(ip: &IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => {
v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified() || v4.is_broadcast()
}
IpAddr::V6(v6) => {
v6.is_loopback()
|| v6.is_unspecified()
|| (v6.segments()[0] & 0xffc0) == 0xfe80
|| (v6.segments()[0] & 0xfe00) == 0xfc00
}
}
}
fn simulate_playbook(pb: &Playbook, event: &ThreatDetectedEvent, default_single_source_min_conf: f32) -> DryRunMatch {
let trigger_matches = pb.trigger_event == event.attack_type;
let mut has_frequency_condition = false;

View File

@ -1,5 +1,7 @@
pub mod actions;
pub mod engine;
pub mod frequency;
pub mod matcher;
pub mod playbook_service;
pub mod rate_limit_owner;
pub mod scheduler;

View File

@ -1,4 +1,3 @@
use std::net::IpAddr;
use std::sync::Arc;
use crate::core::response::engine::SoarEngine;
@ -9,6 +8,7 @@ use crate::domain::response::playbook_data::{
};
use crate::interface::port::access_control::AccessControlPort;
use crate::interface::port::app_repo::AppRepo;
use crate::utils::ip_address::ip_version_from_str;
/// Domain service for SOAR playbook CRUD operations.
/// Coordinates DB reads/writes, SOAR engine cache refresh, and eBPF unblock.
@ -157,41 +157,3 @@ impl PlaybookService {
Ok(())
}
}
/// Determine IP version from a string address using proper parsing.
pub fn ip_version_from_str(ip: &str) -> u8 {
match ip.parse::<IpAddr>() {
Ok(IpAddr::V4(_)) => 4,
Ok(IpAddr::V6(_)) => 6,
Err(_) => {
if ip.contains(':') {
6
} else {
4
}
} // fallback
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ip_version_from_str_ipv4() {
assert_eq!(ip_version_from_str("1.2.3.4"), 4);
assert_eq!(ip_version_from_str("192.168.1.1"), 4);
}
#[test]
fn ip_version_from_str_ipv6() {
assert_eq!(ip_version_from_str("::1"), 6);
assert_eq!(ip_version_from_str("2001:db8::1"), 6);
}
#[test]
fn ip_version_from_str_ipv4_mapped_ipv6() {
// ::ffff:1.2.3.4 should be recognized as IPv6 (it is an IPv6 address)
assert_eq!(ip_version_from_str("::ffff:1.2.3.4"), 6);
}
}

View File

@ -5,11 +5,11 @@ use tokio::task::JoinHandle;
use tokio::time::{self, Duration};
use crate::core::response::engine::SoarEngine;
use crate::core::response::playbook_service::ip_version_from_str;
use crate::domain::common::error::Error;
use crate::domain::response::log::SoarLog;
use crate::interface::port::access_control::AccessControlPort;
use crate::interface::port::app_repo::AppRepo;
use crate::utils::ip_address::ip_version_from_str;
/// TTL expiry scheduler: runs every 60 seconds, removes expired auto-block rules.
/// Before removing from eBPF, checks if a manual ACL rule exists for the same IP.

View File

@ -0,0 +1,10 @@
/// Audit log entry returned by `list_audit_logs` and
/// `verify_audit_log_chain` APIs.
#[derive(Debug, Clone)]
pub struct AuditLogEntry {
pub id: i64,
pub actor: String,
pub action: String,
pub detail: String,
pub created_at: String,
}

View File

@ -36,3 +36,12 @@ pub const PERMISSION_SYSTEM_ADMIN: &str = "system:admin";
// ── Event Channels ────────────────────────────────────────────────
pub const EVENT_CHANNEL_CAPACITY: usize = 256;
// ── Enforce Mode ─────────────────────────────────────────────────
pub fn enforce_mode_to_u8(mode: &str) -> u8 {
match mode {
"enforce" => 2,
"ml_only" => 1,
_ => 0,
}
}

View File

@ -0,0 +1,24 @@
use macros::config_settings;
#[config_settings(section = "health")]
#[derive(Debug, Clone)]
pub struct HealthConfig {
#[setting(key = "health_cpu_issue_percent", default = "90.0")]
pub cpu_issue_percent: f32,
#[setting(key = "health_cpu_warn_percent", default = "75.0")]
pub cpu_warn_percent: f32,
#[setting(key = "health_mem_issue_percent", default = "95.0")]
pub mem_issue_percent: f32,
#[setting(key = "health_mem_warn_percent", default = "80.0")]
pub mem_warn_percent: f32,
#[setting(key = "health_disk_issue_percent", default = "95.0")]
pub disk_issue_percent: f32,
#[setting(key = "health_disk_warn_percent", default = "90.0")]
pub disk_warn_percent: f32,
#[setting(key = "health_temp_issue_celsius", default = "80.0")]
pub temp_issue_celsius: f32,
#[setting(key = "health_temp_warn_celsius", default = "70.0")]
pub temp_warn_celsius: f32,
#[setting(key = "health_monitoring_interval_secs", default = "5")]
pub monitoring_interval_secs: u64,
}

View File

@ -4,6 +4,7 @@ pub mod correlation;
pub mod detection;
pub mod dns_filter;
pub mod ebpf;
pub mod health;
mod helpers;
pub mod http_server;
pub mod ml;
@ -20,6 +21,7 @@ use crate::domain::common::config::correlation::CorrelationConfig;
use crate::domain::common::config::detection::DetectionConfig;
use crate::domain::common::config::dns_filter::DnsFilterConfig;
use crate::domain::common::config::ebpf::EbpfConfig;
use crate::domain::common::config::health::HealthConfig;
use crate::domain::common::config::http_server::HttpServerConfig;
use crate::domain::common::config::ml::MlConfig;
use crate::domain::common::config::notification::NotificationConfig;
@ -39,6 +41,7 @@ pub struct AppConfig {
pub detection: DetectionConfig,
pub dns_filter: DnsFilterConfig,
pub ebpf: EbpfConfig,
pub health: HealthConfig,
pub http_server: HttpServerConfig,
pub ml: MlConfig,
pub notification: NotificationConfig,
@ -57,6 +60,7 @@ impl AppConfig {
detection: DetectionConfig::from_settings(repo)?,
dns_filter: DnsFilterConfig::from_settings(repo)?,
ebpf: EbpfConfig::from_settings(repo)?,
health: HealthConfig::from_settings(repo)?,
http_server: HttpServerConfig::from_settings(repo)?,
ml: MlConfig::from_settings(repo)?,
notification: NotificationConfig::from_settings(repo)?,
@ -76,6 +80,7 @@ impl AppConfig {
DetectionConfig::seed_defaults(repo)?;
DnsFilterConfig::seed_defaults(repo)?;
EbpfConfig::seed_defaults(repo)?;
HealthConfig::seed_defaults(repo)?;
HttpServerConfig::seed_defaults(repo)?;
MlConfig::seed_defaults(repo)?;
NotificationConfig::seed_defaults(repo)?;

View File

@ -37,15 +37,3 @@ traceable! {
AuditRowHashMismatch { id: i64, computed: String, stored: String } => tracing::Level::ERROR,
}
}
impl From<rusqlite::Error> for DatabaseError {
fn from(e: rusqlite::Error) -> Self {
DatabaseError::QueryFailed(e)
}
}
impl From<rusqlite::Error> for super::Error {
fn from(e: rusqlite::Error) -> Self {
Self::Database(DatabaseError::from(e))
}
}

View File

@ -1,5 +1,7 @@
pub mod audit;
pub mod config;
pub mod error;
pub mod event;
pub mod log;
pub mod notification;
pub mod system;

View File

@ -0,0 +1,11 @@
/// Alert notification data sent by SOAR engine.
#[derive(Debug, Clone)]
pub struct AlertPayload {
pub source_ip: String,
pub dest_ip: String,
pub country: Option<String>,
pub threat_type: String,
pub confidence: f32,
pub action_description: String,
pub timestamp: String,
}

View File

@ -1,7 +1,6 @@
use std::sync::atomic::AtomicBool;
use std::time::Instant;
/// Per-subsystem readiness state exposed by `/health/ready`.
pub struct ReadinessState {
pub db_connected: AtomicBool,
pub ml_model_loaded: AtomicBool,

View File

@ -0,0 +1,9 @@
/// Stored ACL rule view.
#[derive(Debug, Clone, PartialEq)]
pub struct AclRuleView {
pub ip_version: u8,
pub direction: String,
pub list_type: String,
pub ip_address: String,
pub port: u16,
}

View File

@ -24,3 +24,12 @@ pub enum FlowDirection {
Source,
Destination,
}
impl FlowDirection {
pub fn as_str(self) -> &'static str {
match self {
Self::Source => "source",
Self::Destination => "destination",
}
}
}

View File

@ -8,3 +8,12 @@ pub enum ListType {
#[serde(rename = "blacklist")]
Black,
}
impl ListType {
pub fn as_str(self) -> &'static str {
match self {
Self::White => "whitelist",
Self::Black => "blacklist",
}
}
}

View File

@ -1,3 +1,4 @@
pub mod acl_rule;
pub mod direction;
pub mod drop_event;
pub mod error;

View File

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

View File

@ -1,140 +0,0 @@
use std::collections::VecDeque;
use std::time::{Duration, Instant};
use crate::domain::detection::drift::{DriftReport, FeatureBaselines};
pub struct DriftDetector {
snapshots: VecDeque<(Instant, Vec<f64>)>,
num_features: usize,
baselines: Option<FeatureBaselines>,
drift_window: Duration,
max_snapshots: usize,
}
impl DriftDetector {
pub fn new(baselines: Option<FeatureBaselines>, drift_window: Duration, max_snapshots: usize) -> Self {
let num_features = baselines.as_ref().map_or(0, |b| b.names.len());
Self {
snapshots: VecDeque::new(),
num_features,
baselines,
drift_window,
max_snapshots,
}
}
pub fn update(&mut self, features: &[f64]) {
let now = Instant::now();
self.snapshots.push_back((now, features.to_vec()));
self.evict_stale(now);
while self.snapshots.len() > self.max_snapshots {
self.snapshots.pop_front();
}
}
pub fn check_drift(&self) -> Option<DriftReport> {
let baselines = self.baselines.as_ref()?;
if self.snapshots.is_empty() || self.num_features == 0 {
return None;
}
let n = self.snapshots.len() as f64;
let mut sums = vec![0.0_f64; self.num_features];
for (_, features) in &self.snapshots {
for (i, &val) in features.iter().enumerate().take(self.num_features) {
sums[i] += val;
}
}
let mut drifted_features = Vec::new();
let mut max_deviation = 0.0_f64;
for (i, (sum, (bl_mean, bl_std))) in sums
.iter()
.zip(baselines.means.iter().zip(baselines.stds.iter()))
.enumerate()
.take(self.num_features)
{
let current_mean = sum / n;
if *bl_std < 1e-12 {
continue;
}
let deviation = ((current_mean - bl_mean) / bl_std).abs();
if deviation > 3.0 {
drifted_features.push(baselines.names[i].clone());
if deviation > max_deviation {
max_deviation = deviation;
}
}
}
if drifted_features.is_empty() {
None
} else {
Some(DriftReport {
drifted_features,
max_deviation,
})
}
}
fn evict_stale(&mut self, now: Instant) {
while let Some((ts, _)) = self.snapshots.front() {
if now.duration_since(*ts) > self.drift_window {
self.snapshots.pop_front();
} else {
break;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_baselines(n: usize) -> FeatureBaselines {
FeatureBaselines {
names: (0..n).map(|i| format!("feature_{i}")).collect(),
means: vec![0.0; n],
stds: vec![1.0; n],
}
}
#[test]
fn no_drift_when_within_threshold() {
let baselines = make_baselines(3);
let mut detector = DriftDetector::new(Some(baselines), Duration::from_secs(3600), 10_000);
detector.update(&[1.0, -1.0, 2.0]);
detector.update(&[0.5, -0.5, 1.5]);
assert!(detector.check_drift().is_none());
}
#[test]
fn drift_detected_when_exceeds_threshold() {
let baselines = make_baselines(3);
let mut detector = DriftDetector::new(Some(baselines), Duration::from_secs(3600), 10_000);
detector.update(&[5.0, 0.0, 0.0]);
detector.update(&[5.0, 0.0, 0.0]);
let report = detector.check_drift().unwrap();
assert!(report.drifted_features.contains(&"feature_0".to_string()));
assert!(report.max_deviation > 3.0);
}
#[test]
fn no_baselines_means_no_drift() {
let mut detector = DriftDetector::new(None, Duration::from_secs(3600), 10_000);
detector.update(&[100.0, 200.0]);
assert!(detector.check_drift().is_none());
}
#[test]
fn empty_snapshots_no_drift() {
let baselines = make_baselines(3);
let detector = DriftDetector::new(Some(baselines), Duration::from_secs(3600), 10_000);
assert!(detector.check_drift().is_none());
}
}

View File

@ -1,8 +1,4 @@
use std::sync::Arc;
use common::define::tcp_flags::*;
use moka::sync::Cache;
use parking_lot::Mutex;
use crate::domain::data_plane::direction::Direction;
use crate::domain::data_plane::user_packet::UserPacket;
@ -49,7 +45,7 @@ pub struct FlowData {
pub fwd_bulk_state: BulkState,
pub bwd_bulk_state: BulkState,
pub act_data_pkt_fwd: u32,
is_first_packet: bool,
pub(crate) is_first_packet: bool,
/// Timestamp (us) when this flow was last sent to ML inference.
/// 0 means never inferred. Used to avoid re-inferring unchanged flows.
pub last_inferred_us: u64,
@ -214,253 +210,3 @@ impl FlowData {
self.fwd_packets.len() + self.bwd_packets.len()
}
}
/// Per-flow handle: an `Arc` so map operations stay copy-cheap, with an inner
/// `Mutex` because `add_packet` is a read-modify-write that needs exclusive
/// access. Same-flow packets land on the same XSK queue (symmetric eBPF
/// hash), so this mutex is effectively single-writer; the inference tick
/// briefly contends only when it clones the entry for a snapshot.
type FlowEntry = Arc<Mutex<FlowData>>;
/// Per-queue flow tracker backed by a sharded W-TinyLFU cache (`moka`).
///
/// The hot path (`process_packet`) acquires only the per-shard moka lock
/// and the per-flow entry mutex — never a global tracker lock — so the
/// inference loop's snapshot pass (`get_uninferred_flows`,
/// `cleanup_stale_flows`) can run in parallel without stalling AF_XDP rx.
/// W-TinyLFU's frequency sketch keeps high-rate attack flows resident
/// even when burst noise floods the cache, which a strict-LRU eviction
/// policy would mishandle.
pub struct FlowTracker {
active: Cache<FlowKey, FlowEntry>,
limits: FlowLimits,
}
impl FlowTracker {
pub fn new(max_flows: usize, limits: FlowLimits) -> Self {
let cap = max_flows.max(1) as u64;
Self {
active: Cache::builder().max_capacity(cap).build(),
limits,
}
}
pub fn process_packet(&self, mut packet: UserPacket, is_ingress: bool) {
let packet_key = FlowKey::from_packet(&packet);
let reversed_key = packet_key.reverse();
let (actual_key, is_forward) = if self.active.contains_key(&packet_key) {
(packet_key, true)
} else if self.active.contains_key(&reversed_key) {
(reversed_key, false)
} else {
// New flow: determine initiator using TCP flags, fall back to is_ingress.
let syn = packet.tcp_flags & TCP_SYN != 0;
let ack = packet.tcp_flags & TCP_ACK != 0;
if syn && ack {
// SYN+ACK: sender is the responder.
// Ingress: external server responding to internal client → reverse so
// canonical key has internal client as src.
// Egress: internal server responding to external client → keep as-is.
if is_ingress {
(reversed_key, false)
} else {
(packet_key, true)
}
} else if syn {
// SYN: sender is always the initiator.
(packet_key, true)
} else {
// Mid-stream / UDP / ICMP: use is_ingress as best-effort heuristic.
// Egress = we are the initiator (forward); ingress = remote initiated (backward).
if is_ingress {
(reversed_key, false)
} else {
(packet_key, true)
}
}
};
packet.is_forward = is_forward;
let initiator_direction = if is_forward {
if is_ingress {
Direction::Ingress
} else {
Direction::Egress
}
} else if is_ingress {
Direction::Egress
} else {
Direction::Ingress
};
let key_for_init = actual_key.clone();
let entry = self.active.get_with(actual_key, || {
Arc::new(Mutex::new(FlowData::new(key_for_init, &packet, initiator_direction)))
});
entry.lock().add_packet(&packet, &self.limits);
}
/// 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
/// mark them as inferred. Used by ML engine.
pub fn get_uninferred_flows(&self, limit: usize) -> Vec<FlowData> {
let mut result = Vec::new();
for (_, entry) in self.active.iter() {
if result.len() >= limit {
break;
}
let mut flow = entry.lock();
if flow.last_time_us > flow.last_inferred_us {
let snapshot = FlowData {
fwd_packets: std::mem::take(&mut flow.fwd_packets),
bwd_packets: std::mem::take(&mut flow.bwd_packets),
active_periods: std::mem::take(&mut flow.active_periods),
idle_periods: std::mem::take(&mut flow.idle_periods),
..flow.clone()
};
flow.last_inferred_us = flow.last_time_us;
result.push(snapshot);
}
}
result
}
pub fn flow_count(&self) -> usize {
self.active.entry_count() as usize
}
/// Remove flows that have been idle too long or are terminated (FIN/RST seen).
/// `now_us`: current timestamp in microseconds (same scale as packet timestamps).
/// Returns the number of flows removed.
pub fn cleanup_stale_flows(&self, now_us: u64) -> usize {
let mut keys_to_remove = Vec::new();
for (key, entry) in self.active.iter() {
let flow = entry.lock();
let idle = now_us.saturating_sub(flow.last_time_us);
let is_terminated = flow.fin_count > 0 || flow.rst_count > 0;
let stale = if is_terminated {
idle >= self.limits.terminated_timeout_us
} else {
idle >= self.limits.idle_timeout_us
};
if stale {
keys_to_remove.push((*key).clone());
}
}
let mut removed = 0;
for key in keys_to_remove {
self.active.invalidate(&key);
removed += 1;
}
removed
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_limits() -> FlowLimits {
FlowLimits {
max_packets_per_direction: 1000,
max_periods: 1000,
idle_threshold_us: 1_000_000,
bulk_min_packets: 4,
bulk_min_bytes: 1000,
idle_timeout_us: 120_000_000,
terminated_timeout_us: 5_000_000,
}
}
fn make_packet(timestamp_us: u64, tcp_flags: u8) -> UserPacket {
UserPacket {
ip_version: 4,
protocol: 6, // TCP
tcp_flags,
src_ip: [10, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
dst_ip: [10, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
src_port: 12345,
dst_port: 80,
packet_length: 100,
payload_length: 60,
header_length: 40,
tcp_window_size: 65535,
timestamp_us,
is_forward: true,
}
}
fn sync_count(tracker: &FlowTracker) -> usize {
tracker.active.run_pending_tasks();
tracker.flow_count()
}
#[test]
fn cleanup_removes_idle_flows() {
let tracker = FlowTracker::new(10000, test_limits());
let base_ts = 1_000_000_000u64; // 1000 seconds
let pkt = make_packet(base_ts, 0x02); // SYN
tracker.process_packet(pkt, false);
assert_eq!(sync_count(&tracker), 1);
let now = base_ts + 130_000_000;
let removed = tracker.cleanup_stale_flows(now);
assert_eq!(removed, 1);
assert_eq!(sync_count(&tracker), 0);
}
#[test]
fn cleanup_keeps_active_flows() {
let tracker = FlowTracker::new(10000, test_limits());
let base_ts = 1_000_000_000u64;
let pkt = make_packet(base_ts, 0x02);
tracker.process_packet(pkt, false);
let now = base_ts + 10_000_000;
let removed = tracker.cleanup_stale_flows(now);
assert_eq!(removed, 0);
assert_eq!(sync_count(&tracker), 1);
}
#[test]
fn cleanup_removes_terminated_flows_after_short_idle() {
let tracker = FlowTracker::new(10000, test_limits());
let base_ts = 1_000_000_000u64;
let pkt1 = make_packet(base_ts, 0x02);
tracker.process_packet(pkt1, false);
let pkt2 = make_packet(base_ts + 1_000_000, 0x01); // FIN
tracker.process_packet(pkt2, false);
let now = base_ts + 7_000_000;
let removed = tracker.cleanup_stale_flows(now);
assert_eq!(removed, 1);
assert_eq!(sync_count(&tracker), 0);
}
#[test]
fn cleanup_keeps_recently_terminated_flows() {
let tracker = FlowTracker::new(10000, test_limits());
let base_ts = 1_000_000_000u64;
let pkt1 = make_packet(base_ts, 0x02);
tracker.process_packet(pkt1, false);
let pkt2 = make_packet(base_ts + 1_000_000, 0x01);
tracker.process_packet(pkt2, false);
let now = base_ts + 3_000_000;
let removed = tracker.cleanup_stale_flows(now);
assert_eq!(removed, 0);
assert_eq!(sync_count(&tracker), 1);
}
}

View File

@ -2,23 +2,10 @@ use std::net::{Ipv4Addr, Ipv6Addr};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use tract_onnx::prelude::{Graph, SimplePlan, TypedFact, TypedOp};
use crate::domain::data_plane::direction::Direction;
use crate::domain::data_plane::user_packet::UserPacket;
pub type RunnableModel = SimplePlan<TypedFact, Box<dyn TypedOp>, Graph<TypedFact, Box<dyn TypedOp>>>;
pub struct EngineConfig {
pub max_flows: usize,
pub min_packets: usize,
pub min_packets_floor: usize,
pub batch_size: usize,
pub inference_interval_secs: u64,
pub aggregator_window_secs: u64,
pub confirmation_window_fraction: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClipParams {
pub lower: f64,

View File

@ -1,21 +1,12 @@
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;
pub mod feature_extractor;
pub mod flow_features;
pub mod flow_tracker;
pub mod fusion_math;
pub mod lateral;
pub mod log;
pub mod manifest;
pub mod metrics;
pub mod ml_detection;
pub mod ml_inference_config;
pub mod model_adapter;
pub mod model_source;
pub mod scan;

View File

@ -5,6 +5,81 @@ 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";
pub const LOGIN_MAX_FAILURES: u32 = 5;
pub const LOGIN_LOCKOUT_SECS: u64 = 900;
pub const ADMIN_PERMISSIONS: &[&str] = &[
"dashboard:read",
"statistics:read",
"traffic_map:read",
"drops:read",
"ai_detection:read",
"ai_detection:write",
"access_control:read",
"access_control:write",
"geo_block:read",
"geo_block:write",
"dns_filter:read",
"dns_filter:write",
"rate_limit:read",
"rate_limit:write",
"protocol_filter:read",
"protocol_filter:write",
"system:read",
"system:write",
"system:admin",
"users:read",
"users:write",
"users:admin",
"fusion:read",
"fusion:write",
"flow_trace:read",
"flow_trace:write",
];
pub const VIEWER_PERMISSIONS: &[&str] = &[
"dashboard:read",
"statistics:read",
"traffic_map:read",
"drops:read",
"ai_detection:read",
"access_control:read",
"geo_block:read",
"dns_filter:read",
"rate_limit:read",
"protocol_filter:read",
"system:read",
"fusion:read",
"flow_trace:read",
];
pub const API_KEY_READ_WRITE_PERMISSIONS: &[&str] = &[
"dashboard:read",
"statistics:read",
"ai_detection:read",
"ai_detection:write",
"access_control:read",
"access_control:write",
"geo_block:read",
"geo_block:write",
"dns_filter:read",
"dns_filter:write",
"rate_limit:read",
"rate_limit:write",
"system:read",
"system:write",
];
pub const API_KEY_READ_ONLY_PERMISSIONS: &[&str] = &[
"dashboard:read",
"statistics:read",
"ai_detection:read",
"access_control:read",
"geo_block:read",
"dns_filter:read",
"rate_limit:read",
"system:read",
];
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Claims {

View File

@ -1,3 +1,5 @@
pub mod auth;
pub mod error;
pub mod password;
pub mod user;
pub mod validation;

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