diff --git a/net-guardia/src/adapter/ebpf/mod.rs b/net-guardia/src/adapter/ebpf/mod.rs index 7d0c97e..d5c2b51 100644 --- a/net-guardia/src/adapter/ebpf/mod.rs +++ b/net-guardia/src/adapter/ebpf/mod.rs @@ -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; diff --git a/net-guardia/src/adapter/http/api_keys.rs b/net-guardia/src/adapter/http/api_keys.rs index 0968591..a29237c 100644 --- a/net-guardia/src/adapter/http/api_keys.rs +++ b/net-guardia/src/adapter/http/api_keys.rs @@ -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) -> HttpResp Ok(keys) => { let responses: Vec = 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(); diff --git a/net-guardia/src/adapter/http/audit.rs b/net-guardia/src/adapter/http/audit.rs index 104e277..4652e98 100644 --- a/net-guardia/src/adapter/http/audit.rs +++ b/net-guardia/src/adapter/http/audit.rs @@ -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; diff --git a/net-guardia/src/adapter/http/auth.rs b/net-guardia/src/adapter/http/auth.rs index ce2b385..d9b1f13 100644 --- a/net-guardia/src/adapter/http/auth.rs +++ b/net-guardia/src/adapter/http/auth.rs @@ -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, db: web::Data, jwt: web::Data) -> impl Responder { +async fn login(body: web::Json, auth_svc: web::Data) -> impl Responder { let req = body.into_inner(); - - // Check login lockout - match db.check_login_locked(&req.username) { - Ok(Some(remaining_secs)) => { - return HttpResponse::TooManyRequests().json(serde_json::json!({ - "error": "Account temporarily locked due to too many failed login attempts", - "retry_after_secs": remaining_secs, - })); - } - Err(_) => {} - Ok(None) => {} - } - - let user = match db.find_user(&req.username) { - Ok(Some(u)) => u, - _ => { - // Run dummy hash verification to prevent timing-based username enumeration - let _ = password::verify_password(&req.password, DUMMY_HASH); - if let Err(e) = db.record_login_failure(&req.username) { - log!(AuthError::LoginFailureTrackingError(e)); - } - return HttpResponse::Unauthorized().json(serde_json::json!({"error": "Invalid credentials"})); - } - }; - - let (id, username, hash, _db_role, force_password_change) = user; - - match password::verify_password(&req.password, &hash) { - Ok(true) => {} - _ => { - if let Err(e) = db.record_login_failure(&req.username) { - log!(AuthError::LoginFailureTrackingError(e)); - } - return HttpResponse::Unauthorized().json(serde_json::json!({"error": "Invalid credentials"})); - } - } - - // Clear login failures on success - if let Err(e) = db.clear_login_failures(&req.username) { - log!(AuthError::LoginClearError(e)); - } - - // Permissions come exclusively from groups — no role-based fallback - let permissions = db.list_user_permissions(id).unwrap_or_default(); - - let groups = db.list_groups_for_user(id).unwrap_or_default(); - let role = if groups.iter().any(|(_id, name, _desc, _perms)| name == GROUP_ADMIN) { - ROLE_ADMIN.to_string() - } else { - ROLE_VIEWER.to_string() - }; - - match jwt.create_token(id, &username, &role, permissions) { - Ok(token) => HttpResponse::Ok().json(serde_json::json!({ - "token": token, - "role": role, - "force_password_change": force_password_change, + match auth_svc.login(&req.username, &req.password) { + Ok(result) => HttpResponse::Ok().json(result), + Err(LoginError::Locked { retry_after_secs }) => HttpResponse::TooManyRequests().json(serde_json::json!({ + "error": "Account temporarily locked due to too many failed login attempts", + "retry_after_secs": retry_after_secs, })), - Err(_) => HttpResponse::InternalServerError().json(serde_json::json!({"error": "Failed to create token"})), + Err(LoginError::InvalidCredentials) => { + HttpResponse::Unauthorized().json(serde_json::json!({"error": "Invalid credentials"})) + } + Err(LoginError::InternalError) => { + HttpResponse::InternalServerError().json(serde_json::json!({"error": "Failed to create token"})) + } } } -async fn register(auth: AuthClaims, body: web::Json, db: web::Data) -> impl Responder { +async fn register( + auth: AuthClaims, + body: web::Json, + auth_svc: web::Data, +) -> impl Responder { let reg = body.into_inner(); - - // Validate input - if let Err(msg) = validate_username(®.username) { - return HttpResponse::BadRequest().json(serde_json::json!({"error": msg})); - } - if let Err(msg) = validate_password(®.password) { - return HttpResponse::BadRequest().json(serde_json::json!({"error": msg})); - } - - // Validate role - if reg.role != ROLE_ADMIN && reg.role != ROLE_VIEWER { - return HttpResponse::BadRequest().json(serde_json::json!({"error": "Role must be 'admin' or 'viewer'"})); - } - - // Only admins can create admin accounts - if reg.role == ROLE_ADMIN && auth.role != ROLE_ADMIN { - return HttpResponse::Forbidden() - .json(serde_json::json!({"error": "Only administrators can create admin accounts"})); - } - - let hash = match password::hash_password(®.password) { - Ok(h) => h, - Err(_) => { - return HttpResponse::InternalServerError().json(serde_json::json!({"error": "Failed to hash password"})); + match auth_svc.register(®.username, ®.password, ®.role, &auth.role) { + Ok(_) => HttpResponse::Created().json(serde_json::json!({"username": reg.username, "role": reg.role})), + Err(RegisterError::Validation(msg)) => HttpResponse::BadRequest().json(serde_json::json!({"error": msg})), + Err(RegisterError::InvalidRole) => { + HttpResponse::BadRequest().json(serde_json::json!({"error": "Role must be 'admin' or 'viewer'"})) } - }; - - match db.insert_user(®.username, &hash, ®.role, false) { - Ok(new_user_id) => { - // Auto-assign to default group based on role - let default_group_name = if reg.role == ROLE_ADMIN { - GROUP_ADMIN - } else { - GROUP_VIEWER - }; - if let Ok(groups) = db.list_user_groups() - && let Some((group_id, _, _, _, _)) = - groups.into_iter().find(|(_, name, _, _, _)| name == default_group_name) - && let Err(e) = db.set_user_groups(new_user_id, &[group_id]) - { - log!(AuthError::GroupAssignmentFailed(e)); - } - HttpResponse::Created().json(serde_json::json!({"username": reg.username, "role": reg.role})) + Err(RegisterError::Forbidden) => HttpResponse::Forbidden() + .json(serde_json::json!({"error": "Only administrators can create admin accounts"})), + Err(RegisterError::HashFailed) => { + HttpResponse::InternalServerError().json(serde_json::json!({"error": "Failed to hash password"})) } - Err(e) => HttpResponse::Conflict().json(serde_json::json!({"error": e.to_string()})), + Err(RegisterError::Conflict(e)) => HttpResponse::Conflict().json(serde_json::json!({"error": e.to_string()})), } } -async fn me(auth: AuthClaims, db: web::Data) -> impl Responder { - let user_groups = db.list_groups_for_user(auth.sub).unwrap_or_default(); - let group_names: Vec = 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) -> 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, db: web::Data, ) -> 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) -> impl Responder { match db.list_users_with_groups() { Ok(users) => { let result: Vec = users .into_iter() - .map(|(id, username, _role, force_pw, created_at, user_groups)| { - let groups: Vec = user_groups + .map(|u| { + let groups: Vec = 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) -> impl Responder { async fn delete_user(_auth: AuthClaims, path: web::Path, db: web::Data) -> 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) -> impl Responder { match db.list_user_groups() { Ok(groups) => { let result: Vec = 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 = 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, db: web::Data) 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, db: web::Data) -> 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()); + } } diff --git a/net-guardia/src/adapter/http/byo.rs b/net-guardia/src/adapter/http/byo.rs index 7523937..abdc534 100644 --- a/net-guardia/src/adapter/http/byo.rs +++ b/net-guardia/src/adapter/http/byo.rs @@ -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 { diff --git a/net-guardia/src/adapter/http/fusion.rs b/net-guardia/src/adapter/http/fusion.rs index e89afea..6244c13 100644 --- a/net-guardia/src/adapter/http/fusion.rs +++ b/net-guardia/src/adapter/http/fusion.rs @@ -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) -> impl Responder { async fn explain_ip( req: HttpRequest, audit: web::Data, - app_config: web::Data>>, + app_config: web::Data>, ) -> impl Responder { let src_ip = match req.match_info().get("src_ip") { Some(ip) => ip.to_string(), diff --git a/net-guardia/src/adapter/http/logs.rs b/net-guardia/src/adapter/http/logs.rs index a264d14..b693785 100644 --- a/net-guardia/src/adapter/http/logs.rs +++ b/net-guardia/src/adapter/http/logs.rs @@ -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, - app_config: web::Data>>, + app_config: web::Data>, buf: web::Data, ) -> 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, app_config: web::Data>>) -> HttpResponse { +async fn download_log(path: web::Path, app_config: web::Data>) -> HttpResponse { let max_download_size = app_config.load().observability.log_max_download_size; let filename = path.into_inner(); diff --git a/net-guardia/src/core/identity/middleware.rs b/net-guardia/src/adapter/http/middleware/auth.rs similarity index 97% rename from net-guardia/src/core/identity/middleware.rs rename to net-guardia/src/adapter/http/middleware/auth.rs index bca7500..f6715de 100644 --- a/net-guardia/src/core/identity/middleware.rs +++ b/net-guardia/src/adapter/http/middleware/auth.rs @@ -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 { fn required_permission(path: &str, method: &Method) -> Option { 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 { } 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/") diff --git a/net-guardia/src/core/identity/csrf.rs b/net-guardia/src/adapter/http/middleware/csrf.rs similarity index 100% rename from net-guardia/src/core/identity/csrf.rs rename to net-guardia/src/adapter/http/middleware/csrf.rs diff --git a/net-guardia/src/core/identity/extractor.rs b/net-guardia/src/adapter/http/middleware/extractor.rs similarity index 100% rename from net-guardia/src/core/identity/extractor.rs rename to net-guardia/src/adapter/http/middleware/extractor.rs diff --git a/net-guardia/src/core/identity/https_redirect.rs b/net-guardia/src/adapter/http/middleware/https_redirect.rs similarity index 95% rename from net-guardia/src/core/identity/https_redirect.rs rename to net-guardia/src/adapter/http/middleware/https_redirect.rs index 06ab469..2f92032 100644 --- a/net-guardia/src/core/identity/https_redirect.rs +++ b/net-guardia/src/adapter/http/middleware/https_redirect.rs @@ -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; +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::>() - .map(|flag| flag.load(Ordering::Relaxed)) + .map(|flag| flag.0.load(Ordering::Relaxed)) .unwrap_or(false); if !force { diff --git a/net-guardia/src/core/identity/jwt.rs b/net-guardia/src/adapter/http/middleware/jwt.rs similarity index 100% rename from net-guardia/src/core/identity/jwt.rs rename to net-guardia/src/adapter/http/middleware/jwt.rs diff --git a/net-guardia/src/adapter/http/middleware/mod.rs b/net-guardia/src/adapter/http/middleware/mod.rs new file mode 100644 index 0000000..fc0e14f --- /dev/null +++ b/net-guardia/src/adapter/http/middleware/mod.rs @@ -0,0 +1,6 @@ +pub mod auth; +pub mod csrf; +pub mod extractor; +pub mod https_redirect; +pub mod jwt; +pub mod setup_guard; diff --git a/net-guardia/src/core/identity/setup_guard.rs b/net-guardia/src/adapter/http/middleware/setup_guard.rs similarity index 89% rename from net-guardia/src/core/identity/setup_guard.rs rename to net-guardia/src/adapter/http/middleware/setup_guard.rs index e280823..808da77 100644 --- a/net-guardia/src/core/identity/setup_guard.rs +++ b/net-guardia/src/adapter/http/middleware/setup_guard.rs @@ -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; +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::>() - .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. diff --git a/net-guardia/src/adapter/http/ml.rs b/net-guardia/src/adapter/http/ml.rs index ca5c1a5..02ca075 100644 --- a/net-guardia/src/adapter/http/ml.rs +++ b/net-guardia/src/adapter/http/ml.rs @@ -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) -> 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) -> 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, diff --git a/net-guardia/src/adapter/http/mod.rs b/net-guardia/src/adapter/http/mod.rs index 7baf721..729dd9b 100644 --- a/net-guardia/src/adapter/http/mod.rs +++ b/net-guardia/src/adapter/http/mod.rs @@ -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; diff --git a/net-guardia/src/adapter/http/model_upload.rs b/net-guardia/src/adapter/http/model_upload.rs index 33bec23..ee5a6c9 100644 --- a/net-guardia/src/adapter/http/model_upload.rs +++ b/net-guardia/src/adapter/http/model_upload.rs @@ -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 io::Result { .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 { - 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() { diff --git a/net-guardia/src/adapter/http/notification.rs b/net-guardia/src/adapter/http/notification.rs index bc3e173..cf86c42 100644 --- a/net-guardia/src/adapter/http/notification.rs +++ b/net-guardia/src/adapter/http/notification.rs @@ -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") diff --git a/net-guardia/src/adapter/http/ready.rs b/net-guardia/src/adapter/http/ready.rs new file mode 100644 index 0000000..f6844f2 --- /dev/null +++ b/net-guardia/src/adapter/http/ready.rs @@ -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>, state: web::Data) -> 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, + })) +} diff --git a/net-guardia/src/adapter/http/report.rs b/net-guardia/src/adapter/http/report.rs index 783d7e0..9ae38ba 100644 --- a/net-guardia/src/adapter/http/report.rs +++ b/net-guardia/src/adapter/http/report.rs @@ -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; diff --git a/net-guardia/src/adapter/http/setup.rs b/net-guardia/src/adapter/http/setup.rs index 3406fb9..008621f 100644 --- a/net-guardia/src/adapter/http/setup.rs +++ b/net-guardia/src/adapter/http/setup.rs @@ -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) -> 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, body: web::Json, ) -> 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. diff --git a/net-guardia/src/adapter/http/soar.rs b/net-guardia/src/adapter/http/soar.rs index 8ea42e1..5cfec23 100644 --- a/net-guardia/src/adapter/http/soar.rs +++ b/net-guardia/src/adapter/http/soar.rs @@ -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; diff --git a/net-guardia/src/adapter/http/stats.rs b/net-guardia/src/adapter/http/stats.rs index 9e04add..08c39a5 100644 --- a/net-guardia/src/adapter/http/stats.rs +++ b/net-guardia/src/adapter/http/stats.rs @@ -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 { diff --git a/net-guardia/src/adapter/http/system.rs b/net-guardia/src/adapter/http/system.rs index 488ba6a..dd65347 100644 --- a/net-guardia/src/adapter/http/system.rs +++ b/net-guardia/src/adapter/http/system.rs @@ -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; diff --git a/net-guardia/src/adapter/mod.rs b/net-guardia/src/adapter/mod.rs index cd91183..cd68ff3 100644 --- a/net-guardia/src/adapter/mod.rs +++ b/net-guardia/src/adapter/mod.rs @@ -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; diff --git a/net-guardia/src/core/inference/config_loader.rs b/net-guardia/src/adapter/model_loading/config_loader.rs similarity index 100% rename from net-guardia/src/core/inference/config_loader.rs rename to net-guardia/src/adapter/model_loading/config_loader.rs diff --git a/net-guardia/src/core/inference/manifest.rs b/net-guardia/src/adapter/model_loading/manifest.rs similarity index 100% rename from net-guardia/src/core/inference/manifest.rs rename to net-guardia/src/adapter/model_loading/manifest.rs diff --git a/net-guardia/src/adapter/model_loading/mod.rs b/net-guardia/src/adapter/model_loading/mod.rs new file mode 100644 index 0000000..243f85b --- /dev/null +++ b/net-guardia/src/adapter/model_loading/mod.rs @@ -0,0 +1,2 @@ +pub mod config_loader; +pub mod manifest; diff --git a/net-guardia/src/adapter/notification/mod.rs b/net-guardia/src/adapter/notification/mod.rs new file mode 100644 index 0000000..bdcacaa --- /dev/null +++ b/net-guardia/src/adapter/notification/mod.rs @@ -0,0 +1 @@ +pub mod smtp; diff --git a/net-guardia/src/adapter/notification/smtp.rs b/net-guardia/src/adapter/notification/smtp.rs new file mode 100644 index 0000000..a7574c9 --- /dev/null +++ b/net-guardia/src/adapter/notification/smtp.rs @@ -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, 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(()) + } +} diff --git a/net-guardia/src/adapter/persistence/acl.rs b/net-guardia/src/adapter/persistence/acl.rs index 624e3d0..398fda3 100644 --- a/net-guardia/src/adapter/persistence/acl.rs +++ b/net-guardia/src/adapter/persistence/acl.rs @@ -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, Error> { + pub fn list_acl_rules(&self) -> Result, 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(); diff --git a/net-guardia/src/adapter/persistence/api_key.rs b/net-guardia/src/adapter/persistence/api_key.rs index 0097d5c..a8e2fcb 100644 --- a/net-guardia/src/adapter/persistence/api_key.rs +++ b/net-guardia/src/adapter/persistence/api_key.rs @@ -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; @@ -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 = 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, Error> { + pub fn list_api_keys(&self) -> Result, 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>(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, Error> { + fn list_api_keys(&self) -> Result, Error> { self.list_api_keys() } diff --git a/net-guardia/src/adapter/persistence/audit.rs b/net-guardia/src/adapter/persistence/audit.rs index 73b9080..b0f2ffa 100644 --- a/net-guardia/src/adapter/persistence/audit.rs +++ b/net-guardia/src/adapter/persistence/audit.rs @@ -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) diff --git a/net-guardia/src/adapter/persistence/mod.rs b/net-guardia/src/adapter/persistence/mod.rs index 6d29918..87b2a9c 100644 --- a/net-guardia/src/adapter/persistence/mod.rs +++ b/net-guardia/src/adapter/persistence/mod.rs @@ -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 for DatabaseError { + fn from(e: rusqlite::Error) -> Self { + DatabaseError::QueryFailed(e) + } +} + +impl From 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)", diff --git a/net-guardia/src/adapter/persistence/soar.rs b/net-guardia/src/adapter/persistence/soar.rs index 6686f80..7a74676 100644 --- a/net-guardia/src/adapter/persistence/soar.rs +++ b/net-guardia/src/adapter/persistence/soar.rs @@ -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(()) } diff --git a/net-guardia/src/adapter/persistence/user.rs b/net-guardia/src/adapter/persistence/user.rs index 2c1fd64..7b5553a 100644 --- a/net-guardia/src/adapter/persistence/user.rs +++ b/net-guardia/src/adapter/persistence/user.rs @@ -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, Error> { + pub fn find_user(&self, username: &str) -> Result, 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, Error> { + pub fn list_users_with_groups(&self) -> Result, 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>(5)?, - row.get::<_, Option>(6)?, + row.get::<_, i64>(2)? != 0, + row.get::<_, String>(3)?, + row.get::<_, Option>(4)?, + row.get::<_, Option>(5)?, )) })?; - let mut user_map: HashMap = HashMap::new(); + let mut user_map: HashMap = HashMap::new(); let mut order: Vec = 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, Error> { + pub fn find_user_by_id(&self, user_id: i64) -> Result, 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, Error> { + pub fn list_user_groups(&self) -> Result, 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, Error> { + pub fn get_user_group(&self, id: i64) -> Result, 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, Error> { + fn find_user(&self, username: &str) -> Result, Error> { self.find_user(username) } - fn find_user_by_id(&self, user_id: i64) -> Result, Error> { + fn find_user_by_id(&self, user_id: i64) -> Result, 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, Error> { + fn list_users_with_groups(&self) -> Result, 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, Error> { + fn list_user_groups(&self) -> Result, 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, Error> { + fn get_user_group(&self, id: i64) -> Result, 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] diff --git a/net-guardia/src/adapter/telegram.rs b/net-guardia/src/adapter/telegram.rs index 2ab48b9..0a494ca 100644 --- a/net-guardia/src/adapter/telegram.rs +++ b/net-guardia/src/adapter/telegram.rs @@ -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; diff --git a/net-guardia/src/adapter/websocket/flow_websocket.rs b/net-guardia/src/adapter/websocket/flow_websocket.rs index 97d0b0e..0bb57f2 100644 --- a/net-guardia/src/adapter/websocket/flow_websocket.rs +++ b/net-guardia/src/adapter/websocket/flow_websocket.rs @@ -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 { diff --git a/net-guardia/src/adapter/websocket/routes.rs b/net-guardia/src/adapter/websocket/routes.rs index be587fc..d57416d 100644 --- a/net-guardia/src/adapter/websocket/routes.rs +++ b/net-guardia/src/adapter/websocket/routes.rs @@ -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 { diff --git a/net-guardia/src/infrastructure/enforce_mode_handler.rs b/net-guardia/src/core/common/enforce_mode_handler.rs similarity index 93% rename from net-guardia/src/infrastructure/enforce_mode_handler.rs rename to net-guardia/src/core/common/enforce_mode_handler.rs index c24975e..f8eea95 100644 --- a/net-guardia/src/infrastructure/enforce_mode_handler.rs +++ b/net-guardia/src/core/common/enforce_mode_handler.rs @@ -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, diff --git a/net-guardia/src/core/common/mod.rs b/net-guardia/src/core/common/mod.rs index 2155da9..2235b56 100644 --- a/net-guardia/src/core/common/mod.rs +++ b/net-guardia/src/core/common/mod.rs @@ -1,3 +1,4 @@ pub mod config_service; -pub mod log_buffer; +pub mod enforce_mode_handler; pub mod notification_service; +pub mod statistics; diff --git a/net-guardia/src/core/common/notification_service.rs b/net-guardia/src/core/common/notification_service.rs index 0d86e1b..1d20a69 100644 --- a/net-guardia/src/core/common/notification_service.rs +++ b/net-guardia/src/core/common/notification_service.rs @@ -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; diff --git a/net-guardia/src/infrastructure/statistics.rs b/net-guardia/src/core/common/statistics.rs similarity index 100% rename from net-guardia/src/infrastructure/statistics.rs rename to net-guardia/src/core/common/statistics.rs diff --git a/net-guardia/src/domain/detection/botnet.rs b/net-guardia/src/core/correlation/botnet.rs similarity index 98% rename from net-guardia/src/domain/detection/botnet.rs rename to net-guardia/src/core/correlation/botnet.rs index c784d12..8356a11 100644 --- a/net-guardia/src/domain/detection/botnet.rs +++ b/net-guardia/src/core/correlation/botnet.rs @@ -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; diff --git a/net-guardia/src/domain/detection/correlation_cleanup.rs b/net-guardia/src/core/correlation/correlation_cleanup.rs similarity index 100% rename from net-guardia/src/domain/detection/correlation_cleanup.rs rename to net-guardia/src/core/correlation/correlation_cleanup.rs diff --git a/net-guardia/src/core/correlation/engine.rs b/net-guardia/src/core/correlation/engine.rs index 8660490..2b464b4 100644 --- a/net-guardia/src/core/correlation/engine.rs +++ b/net-guardia/src/core/correlation/engine.rs @@ -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 diff --git a/net-guardia/src/domain/detection/lateral.rs b/net-guardia/src/core/correlation/lateral.rs similarity index 85% rename from net-guardia/src/domain/detection/lateral.rs rename to net-guardia/src/core/correlation/lateral.rs index d66c595..f2c4957 100644 --- a/net-guardia/src/domain/detection/lateral.rs +++ b/net-guardia/src/core/correlation/lateral.rs @@ -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, @@ -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::() 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() { diff --git a/net-guardia/src/core/correlation/mod.rs b/net-guardia/src/core/correlation/mod.rs index 702e611..3799f9f 100644 --- a/net-guardia/src/core/correlation/mod.rs +++ b/net-guardia/src/core/correlation/mod.rs @@ -1 +1,5 @@ +pub mod botnet; +pub mod correlation_cleanup; pub mod engine; +pub mod lateral; +pub mod scan; diff --git a/net-guardia/src/domain/detection/scan.rs b/net-guardia/src/core/correlation/scan.rs similarity index 98% rename from net-guardia/src/domain/detection/scan.rs rename to net-guardia/src/core/correlation/scan.rs index 6b1be14..a6b0d03 100644 --- a/net-guardia/src/domain/detection/scan.rs +++ b/net-guardia/src/core/correlation/scan.rs @@ -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; diff --git a/net-guardia/src/core/data_plane/acl_service.rs b/net-guardia/src/core/data_plane/acl_service.rs index 464a480..432eb89 100644 --- a/net-guardia/src/core/data_plane/acl_service.rs +++ b/net-guardia/src/core/data_plane/acl_service.rs @@ -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", - } -} diff --git a/net-guardia/src/adapter/ebpf/dns_filter.rs b/net-guardia/src/core/data_plane/dns_filter.rs similarity index 100% rename from net-guardia/src/adapter/ebpf/dns_filter.rs rename to net-guardia/src/core/data_plane/dns_filter.rs diff --git a/net-guardia/src/core/data_plane/mod.rs b/net-guardia/src/core/data_plane/mod.rs index f5086cd..2a19bec 100644 --- a/net-guardia/src/core/data_plane/mod.rs +++ b/net-guardia/src/core/data_plane/mod.rs @@ -1,3 +1,4 @@ pub mod acl_service; +pub mod dns_filter; pub mod dns_filter_service; pub mod rate_limit_service; diff --git a/net-guardia/src/core/data_plane/rate_limit_service.rs b/net-guardia/src/core/data_plane/rate_limit_service.rs index b3822e0..ae0bea1 100644 --- a/net-guardia/src/core/data_plane/rate_limit_service.rs +++ b/net-guardia/src/core/data_plane/rate_limit_service.rs @@ -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; diff --git a/net-guardia/src/core/detection/beaconing.rs b/net-guardia/src/core/detection/beaconing.rs index b9b0814..2012015 100644 --- a/net-guardia/src/core/detection/beaconing.rs +++ b/net-guardia/src/core/detection/beaconing.rs @@ -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, + last_alerted: Option, +} + +pub struct BeaconingState { + flow_cache: DashMap, + 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 { + 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 = 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 = 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::() / n; + + if mean <= 0.0 { + return f64::MAX; + } + + let variance = intervals.iter().map(|x| (x - mean).powi(2)).sum::() / 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 = (0..10).map(|i| base + Duration::from_secs(i * 60)).collect(); + let cv = compute_cv(×tamps); + assert!(cv < 0.01, "Perfectly periodic CV should be ~0, got {cv}"); + } + + #[test] + fn cv_random_high() { + let base = Instant::now(); + let timestamps = vec![ + base, + base + Duration::from_secs(1), + base + Duration::from_secs(100), + base + Duration::from_secs(101), + base + Duration::from_secs(500), + base + Duration::from_secs(501), + ]; + let cv = compute_cv(×tamps); + assert!(cv > 0.5, "Random intervals CV should be high, got {cv}"); + } + + #[test] + fn cv_with_slight_jitter() { + let base = Instant::now(); + let timestamps = vec![ + base, + base + Duration::from_millis(60_000), + base + Duration::from_millis(121_000), + base + Duration::from_millis(179_000), + base + Duration::from_millis(240_000), + base + Duration::from_millis(299_000), + ]; + let cv = compute_cv(×tamps); + assert!(cv < 0.3, "Slight jitter CV should be < 0.3, got {cv}"); + } + + #[test] + fn cv_insufficient_data() { + let base = Instant::now(); + assert_eq!(compute_cv(&[base]), f64::MAX); + assert_eq!(compute_cv(&[]), f64::MAX); + } + + #[test] + fn beaconing_state_detects_periodic_flows() { + let state = BeaconingState::new(5, 0.3, 50_000, 3600, 120); + let base = Instant::now(); + let key = ("10.0.0.1".to_string(), "1.2.3.4".to_string(), 443_u16); + state.flow_cache.insert( + key, + CachedFlow { + timestamps: (0..10).map(|i| base + Duration::from_secs(i * 60)).collect(), + last_alerted: None, + }, + ); + let events = state.analyze(); + assert_eq!(events.len(), 1); + assert_eq!(events[0].source, DetectionSource::Beaconing); + assert_eq!(events[0].attack_type, "c2_communication"); + } +} diff --git a/net-guardia/src/domain/detection/metrics.rs b/net-guardia/src/core/detection/metrics.rs similarity index 100% rename from net-guardia/src/domain/detection/metrics.rs rename to net-guardia/src/core/detection/metrics.rs diff --git a/net-guardia/src/core/detection/mod.rs b/net-guardia/src/core/detection/mod.rs index 0faa443..e0729c4 100644 --- a/net-guardia/src/core/detection/mod.rs +++ b/net-guardia/src/core/detection/mod.rs @@ -1,2 +1,3 @@ pub mod beaconing; +pub mod metrics; pub mod orchestrator; diff --git a/net-guardia/src/core/detection/orchestrator.rs b/net-guardia/src/core/detection/orchestrator.rs index 2102a91..8cd00ec 100644 --- a/net-guardia/src/core/detection/orchestrator.rs +++ b/net-guardia/src/core/detection/orchestrator.rs @@ -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, tx: mpsc::Sender) { + 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. diff --git a/net-guardia/src/core/identity/auth_service.rs b/net-guardia/src/core/identity/auth_service.rs new file mode 100644 index 0000000..04f1090 --- /dev/null +++ b/net-guardia/src/core/identity/auth_service.rs @@ -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, + jwt: Arc, +} + +#[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, + pub groups: Vec, +} + +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, jwt: Arc) -> Self { + Self { db, jwt } + } + + pub fn login(&self, username: &str, raw_password: &str) -> Result { + 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 { + 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 = 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() + } + } +} diff --git a/net-guardia/src/core/identity/mod.rs b/net-guardia/src/core/identity/mod.rs index c308c82..3fe88a6 100644 --- a/net-guardia/src/core/identity/mod.rs +++ b/net-guardia/src/core/identity/mod.rs @@ -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; diff --git a/net-guardia/src/domain/detection/aggregator.rs b/net-guardia/src/core/inference/aggregator.rs similarity index 100% rename from net-guardia/src/domain/detection/aggregator.rs rename to net-guardia/src/core/inference/aggregator.rs diff --git a/net-guardia/src/core/inference/drift_detector.rs b/net-guardia/src/core/inference/drift_detector.rs index 11db492..69c760d 100644 --- a/net-guardia/src/core/inference/drift_detector.rs +++ b/net-guardia/src/core/inference/drift_detector.rs @@ -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), @@ -51,3 +55,156 @@ impl DriftDetectorHandle { reply_rx.await.unwrap_or(None) } } + +pub struct DriftDetector { + snapshots: VecDeque<(Instant, Vec)>, + num_features: usize, + baselines: Option, + drift_window: Duration, + max_snapshots: usize, +} + +impl DriftDetector { + pub fn new(baselines: Option, 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 { + 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) { + 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()); + } +} diff --git a/net-guardia/src/core/inference/engine.rs b/net-guardia/src/core/inference/engine.rs index 1c09926..b405dcc 100644 --- a/net-guardia/src/core/inference/engine.rs +++ b/net-guardia/src/core/inference/engine.rs @@ -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; +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, + trackers: Vec>, inference_pipeline: Arc, 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 = (0..num_threads) + let trackers: Vec> = (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 { &self.trackers[queue_id as usize % self.trackers.len()] } - pub fn trackers(&self) -> &[ThreadTracker] { + pub fn trackers(&self) -> &[Arc] { &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` (per AF_XDP queue) as a `PacketSink`. struct QueueTrackerSink { - tracker: ThreadTracker, + tracker: Arc, } impl PacketSink for QueueTrackerSink { diff --git a/net-guardia/src/core/inference/flow_tracker.rs b/net-guardia/src/core/inference/flow_tracker.rs new file mode 100644 index 0000000..7523465 --- /dev/null +++ b/net-guardia/src/core/inference/flow_tracker.rs @@ -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>; + +/// 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, + 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(&self, convert: impl Fn(&FlowData) -> T) -> Vec { + self.active.iter().map(|(_, entry)| convert(&entry.lock())).collect() + } + + pub fn get_uninferred_flows(&self, limit: usize) -> Vec { + 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); + } +} diff --git a/net-guardia/src/core/inference/mod.rs b/net-guardia/src/core/inference/mod.rs index 38efbf3..0e3f523 100644 --- a/net-guardia/src/core/inference/mod.rs +++ b/net-guardia/src/core/inference/mod.rs @@ -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; diff --git a/net-guardia/src/domain/detection/model_adapter.rs b/net-guardia/src/core/inference/model_adapter.rs similarity index 97% rename from net-guardia/src/domain/detection/model_adapter.rs rename to net-guardia/src/core/inference/model_adapter.rs index a7d7c2d..0b664f8 100644 --- a/net-guardia/src/domain/detection/model_adapter.rs +++ b/net-guardia/src/core/inference/model_adapter.rs @@ -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, Graph>>; + /// 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 diff --git a/net-guardia/src/core/inference/model_loader.rs b/net-guardia/src/core/inference/model_loader.rs index 1d8a815..23286ca 100644 --- a/net-guardia/src/core/inference/model_loader.rs +++ b/net-guardia/src/core/inference/model_loader.rs @@ -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 diff --git a/net-guardia/src/core/inference/model_watcher.rs b/net-guardia/src/core/inference/model_watcher.rs index 1f0a0b1..0217ad5 100644 --- a/net-guardia/src/core/inference/model_watcher.rs +++ b/net-guardia/src/core/inference/model_watcher.rs @@ -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 { diff --git a/net-guardia/src/core/inference/runner.rs b/net-guardia/src/core/inference/runner.rs index dd5be65..1c61a58 100644 --- a/net-guardia/src/core/inference/runner.rs +++ b/net-guardia/src/core/inference/runner.rs @@ -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 { diff --git a/net-guardia/src/core/inference/traffic_logger.rs b/net-guardia/src/core/inference/traffic_logger.rs index f66932b..a4086d2 100644 --- a/net-guardia/src/core/inference/traffic_logger.rs +++ b/net-guardia/src/core/inference/traffic_logger.rs @@ -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, diff --git a/net-guardia/src/core/reporting/email_scheduler.rs b/net-guardia/src/core/reporting/email_scheduler.rs index d9608c6..a4328b6 100644 --- a/net-guardia/src/core/reporting/email_scheduler.rs +++ b/net-guardia/src/core/reporting/email_scheduler.rs @@ -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, 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, config: Arc>, diff --git a/net-guardia/src/core/reporting/mod.rs b/net-guardia/src/core/reporting/mod.rs index 338c8bc..2fe41a9 100644 --- a/net-guardia/src/core/reporting/mod.rs +++ b/net-guardia/src/core/reporting/mod.rs @@ -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; diff --git a/net-guardia/src/core/reporting/report_data_builder.rs b/net-guardia/src/core/reporting/report_data_builder.rs new file mode 100644 index 0000000..3146bd1 --- /dev/null +++ b/net-guardia/src/core/reporting/report_data_builder.rs @@ -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 { + 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 = 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 = 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 = 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, + }) +} diff --git a/net-guardia/src/core/reporting/report_engine.rs b/net-guardia/src/core/reporting/report_engine.rs index f77d1c8..dec8dca 100644 --- a/net-guardia/src/core/reporting/report_engine.rs +++ b/net-guardia/src/core/reporting/report_engine.rs @@ -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 { - 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 { - let data = ReportData::from_database(db)?; + let data = build_report_data(db)?; serde_json::to_value(&data).map_err(|e| MiscError::SerializeError(e).into()) } diff --git a/net-guardia/src/core/reporting/stats_aggregator.rs b/net-guardia/src/core/reporting/stats_aggregator.rs index 3550ace..4cbce7d 100644 --- a/net-guardia/src/core/reporting/stats_aggregator.rs +++ b/net-guardia/src/core/reporting/stats_aggregator.rs @@ -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, repo: Arc, + health: Arc, } impl StatsAggregator { - pub fn new(stats: Arc, repo: Arc) -> Self { - Self { stats, repo } + pub fn new(stats: Arc, repo: Arc, health: Arc) -> 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) -> Arc { + 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, db.clone() as Arc, + 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, db.clone() as Arc, + health, ); aggregator .aggregate() diff --git a/net-guardia/src/core/response/actions.rs b/net-guardia/src/core/response/actions.rs index d004c2f..edb1c0c 100644 --- a/net-guardia/src/core/response/actions.rs +++ b/net-guardia/src/core/response/actions.rs @@ -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, diff --git a/net-guardia/src/core/response/engine.rs b/net-guardia/src/core/response/engine.rs index a213c5d..bc45aa1 100644 --- a/net-guardia/src/core/response/engine.rs +++ b/net-guardia/src/core/response/engine.rs @@ -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; diff --git a/net-guardia/src/domain/response/frequency.rs b/net-guardia/src/core/response/frequency.rs similarity index 100% rename from net-guardia/src/domain/response/frequency.rs rename to net-guardia/src/core/response/frequency.rs diff --git a/net-guardia/src/domain/response/matcher.rs b/net-guardia/src/core/response/matcher.rs similarity index 97% rename from net-guardia/src/domain/response/matcher.rs rename to net-guardia/src/core/response/matcher.rs index 7488be8..e37dcca 100644 --- a/net-guardia/src/domain/response/matcher.rs +++ b/net-guardia/src/core/response/matcher.rs @@ -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; diff --git a/net-guardia/src/core/response/mod.rs b/net-guardia/src/core/response/mod.rs index 7a012e4..1103b1d 100644 --- a/net-guardia/src/core/response/mod.rs +++ b/net-guardia/src/core/response/mod.rs @@ -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; diff --git a/net-guardia/src/core/response/playbook_service.rs b/net-guardia/src/core/response/playbook_service.rs index 2e48006..718aaab 100644 --- a/net-guardia/src/core/response/playbook_service.rs +++ b/net-guardia/src/core/response/playbook_service.rs @@ -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::() { - 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); - } -} diff --git a/net-guardia/src/core/response/scheduler.rs b/net-guardia/src/core/response/scheduler.rs index 71cb6a2..a5cee5c 100644 --- a/net-guardia/src/core/response/scheduler.rs +++ b/net-guardia/src/core/response/scheduler.rs @@ -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. diff --git a/net-guardia/src/domain/common/audit.rs b/net-guardia/src/domain/common/audit.rs new file mode 100644 index 0000000..53f5cf6 --- /dev/null +++ b/net-guardia/src/domain/common/audit.rs @@ -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, +} diff --git a/net-guardia/src/domain/common/config/constants.rs b/net-guardia/src/domain/common/config/constants.rs index 33996f8..fb1169f 100644 --- a/net-guardia/src/domain/common/config/constants.rs +++ b/net-guardia/src/domain/common/config/constants.rs @@ -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, + } +} diff --git a/net-guardia/src/domain/common/config/health.rs b/net-guardia/src/domain/common/config/health.rs new file mode 100644 index 0000000..5e217be --- /dev/null +++ b/net-guardia/src/domain/common/config/health.rs @@ -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, +} diff --git a/net-guardia/src/domain/common/config/mod.rs b/net-guardia/src/domain/common/config/mod.rs index e744526..ca4e1cd 100644 --- a/net-guardia/src/domain/common/config/mod.rs +++ b/net-guardia/src/domain/common/config/mod.rs @@ -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)?; diff --git a/net-guardia/src/domain/common/error/database.rs b/net-guardia/src/domain/common/error/database.rs index 060e8d6..db34e45 100644 --- a/net-guardia/src/domain/common/error/database.rs +++ b/net-guardia/src/domain/common/error/database.rs @@ -37,15 +37,3 @@ traceable! { AuditRowHashMismatch { id: i64, computed: String, stored: String } => tracing::Level::ERROR, } } - -impl From for DatabaseError { - fn from(e: rusqlite::Error) -> Self { - DatabaseError::QueryFailed(e) - } -} - -impl From for super::Error { - fn from(e: rusqlite::Error) -> Self { - Self::Database(DatabaseError::from(e)) - } -} diff --git a/net-guardia/src/domain/common/mod.rs b/net-guardia/src/domain/common/mod.rs index 3186ca5..065cbd0 100644 --- a/net-guardia/src/domain/common/mod.rs +++ b/net-guardia/src/domain/common/mod.rs @@ -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; diff --git a/net-guardia/src/domain/common/notification.rs b/net-guardia/src/domain/common/notification.rs new file mode 100644 index 0000000..9227dbc --- /dev/null +++ b/net-guardia/src/domain/common/notification.rs @@ -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, + pub threat_type: String, + pub confidence: f32, + pub action_description: String, + pub timestamp: String, +} diff --git a/net-guardia/src/domain/common/system/readiness.rs b/net-guardia/src/domain/common/system/readiness.rs index f5c801e..0bfe342 100644 --- a/net-guardia/src/domain/common/system/readiness.rs +++ b/net-guardia/src/domain/common/system/readiness.rs @@ -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, diff --git a/net-guardia/src/domain/data_plane/acl_rule.rs b/net-guardia/src/domain/data_plane/acl_rule.rs new file mode 100644 index 0000000..70cdbbc --- /dev/null +++ b/net-guardia/src/domain/data_plane/acl_rule.rs @@ -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, +} diff --git a/net-guardia/src/domain/data_plane/direction.rs b/net-guardia/src/domain/data_plane/direction.rs index e502401..907233a 100644 --- a/net-guardia/src/domain/data_plane/direction.rs +++ b/net-guardia/src/domain/data_plane/direction.rs @@ -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", + } + } +} diff --git a/net-guardia/src/domain/data_plane/list_type.rs b/net-guardia/src/domain/data_plane/list_type.rs index e93d36f..6a7faed 100644 --- a/net-guardia/src/domain/data_plane/list_type.rs +++ b/net-guardia/src/domain/data_plane/list_type.rs @@ -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", + } + } +} diff --git a/net-guardia/src/domain/data_plane/mod.rs b/net-guardia/src/domain/data_plane/mod.rs index fad3a08..6220d0d 100644 --- a/net-guardia/src/domain/data_plane/mod.rs +++ b/net-guardia/src/domain/data_plane/mod.rs @@ -1,3 +1,4 @@ +pub mod acl_rule; pub mod direction; pub mod drop_event; pub mod error; diff --git a/net-guardia/src/domain/detection/beaconing.rs b/net-guardia/src/domain/detection/beaconing.rs deleted file mode 100644 index fb33b2c..0000000 --- a/net-guardia/src/domain/detection/beaconing.rs +++ /dev/null @@ -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, - last_alerted: Option, -} - -pub struct BeaconingState { - flow_cache: DashMap, - 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 { - 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 = 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 = 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::() / n; - - if mean <= 0.0 { - return f64::MAX; - } - - let variance = intervals.iter().map(|x| (x - mean).powi(2)).sum::() / 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 = (0..10).map(|i| base + Duration::from_secs(i * 60)).collect(); - let cv = compute_cv(×tamps); - assert!(cv < 0.01, "Perfectly periodic CV should be ~0, got {cv}"); - } - - #[test] - fn cv_random_high() { - let base = Instant::now(); - let timestamps = vec![ - base, - base + Duration::from_secs(1), - base + Duration::from_secs(100), - base + Duration::from_secs(101), - base + Duration::from_secs(500), - base + Duration::from_secs(501), - ]; - let cv = compute_cv(×tamps); - assert!(cv > 0.5, "Random intervals CV should be high, got {cv}"); - } - - #[test] - fn cv_with_slight_jitter() { - let base = Instant::now(); - let timestamps = vec![ - base, - base + Duration::from_millis(60_000), - base + Duration::from_millis(121_000), - base + Duration::from_millis(179_000), - base + Duration::from_millis(240_000), - base + Duration::from_millis(299_000), - ]; - let cv = compute_cv(×tamps); - assert!(cv < 0.3, "Slight jitter CV should be < 0.3, got {cv}"); - } - - #[test] - fn cv_insufficient_data() { - let base = Instant::now(); - assert_eq!(compute_cv(&[base]), f64::MAX); - assert_eq!(compute_cv(&[]), f64::MAX); - } - - #[test] - fn beaconing_state_detects_periodic_flows() { - let state = BeaconingState::new(5, 0.3, 50_000, 3600, 120); - let base = Instant::now(); - let key = ("10.0.0.1".to_string(), "1.2.3.4".to_string(), 443_u16); - state.flow_cache.insert( - key, - CachedFlow { - timestamps: (0..10).map(|i| base + Duration::from_secs(i * 60)).collect(), - last_alerted: None, - }, - ); - let events = state.analyze(); - assert_eq!(events.len(), 1); - assert_eq!(events[0].source, DetectionSource::Beaconing); - assert_eq!(events[0].attack_type, "c2_communication"); - } -} diff --git a/net-guardia/src/domain/detection/drift_detector.rs b/net-guardia/src/domain/detection/drift_detector.rs deleted file mode 100644 index dc333bf..0000000 --- a/net-guardia/src/domain/detection/drift_detector.rs +++ /dev/null @@ -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)>, - num_features: usize, - baselines: Option, - drift_window: Duration, - max_snapshots: usize, -} - -impl DriftDetector { - pub fn new(baselines: Option, 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 { - 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()); - } -} diff --git a/net-guardia/src/domain/detection/flow_tracker.rs b/net-guardia/src/domain/detection/flow_tracker.rs index 65409f3..9fc3740 100644 --- a/net-guardia/src/domain/detection/flow_tracker.rs +++ b/net-guardia/src/domain/detection/flow_tracker.rs @@ -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>; - -/// 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, - 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(&self, convert: impl Fn(&FlowData) -> T) -> Vec { - 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 { - 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); - } -} diff --git a/net-guardia/src/domain/detection/ml_detection.rs b/net-guardia/src/domain/detection/ml_detection.rs index 89fe843..a9d9085 100644 --- a/net-guardia/src/domain/detection/ml_detection.rs +++ b/net-guardia/src/domain/detection/ml_detection.rs @@ -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, Graph>>; - -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, diff --git a/net-guardia/src/domain/detection/mod.rs b/net-guardia/src/domain/detection/mod.rs index d9685ae..8c34479 100644 --- a/net-guardia/src/domain/detection/mod.rs +++ b/net-guardia/src/domain/detection/mod.rs @@ -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; diff --git a/net-guardia/src/domain/identity/auth.rs b/net-guardia/src/domain/identity/auth.rs index 506d2ea..433bc87 100644 --- a/net-guardia/src/domain/identity/auth.rs +++ b/net-guardia/src/domain/identity/auth.rs @@ -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 { diff --git a/net-guardia/src/domain/identity/mod.rs b/net-guardia/src/domain/identity/mod.rs index ae594aa..a24b9f3 100644 --- a/net-guardia/src/domain/identity/mod.rs +++ b/net-guardia/src/domain/identity/mod.rs @@ -1,3 +1,5 @@ pub mod auth; pub mod error; pub mod password; +pub mod user; +pub mod validation; diff --git a/net-guardia/src/domain/identity/user.rs b/net-guardia/src/domain/identity/user.rs new file mode 100644 index 0000000..b633698 --- /dev/null +++ b/net-guardia/src/domain/identity/user.rs @@ -0,0 +1,45 @@ +/// Stored user record. +#[derive(Debug, Clone)] +pub struct UserView { + pub id: i64, + pub username: String, + pub password_hash: String, + pub force_password_change: bool, +} + +/// User with resolved group memberships. +#[derive(Debug, Clone)] +pub struct UserWithGroupsView { + pub id: i64, + pub username: String, + pub force_password_change: bool, + pub created_at: String, + pub groups: Vec, +} + +/// Minimal group membership info embedded in user views. +#[derive(Debug, Clone)] +pub struct UserGroupMembership { + pub group_id: i64, + pub group_name: String, +} + +/// Stored user group record. +#[derive(Debug, Clone)] +pub struct UserGroupView { + pub id: i64, + pub name: String, + pub description: String, + pub permissions: String, + pub created_at: String, +} + +/// API key list entry. +#[derive(Debug, Clone)] +pub struct ApiKeyView { + pub id: i64, + pub name: String, + pub permission_level: String, + pub created_at: String, + pub last_used_at: Option, +} diff --git a/net-guardia/src/domain/identity/validation.rs b/net-guardia/src/domain/identity/validation.rs new file mode 100644 index 0000000..f01250c --- /dev/null +++ b/net-guardia/src/domain/identity/validation.rs @@ -0,0 +1,58 @@ +pub 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(()) +} + +pub fn validate_password(password: &str) -> Result<(), &'static str> { + if password.len() < 8 { + return Err("Password must be at least 8 characters"); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn valid_usernames() { + assert!(validate_username("admin").is_ok()); + assert!(validate_username("user_123").is_ok()); + assert!(validate_username("a").is_ok()); + } + + #[test] + fn empty_username() { + assert!(validate_username("").is_err()); + } + + #[test] + fn too_long_username() { + let long = "a".repeat(33); + assert!(validate_username(&long).is_err()); + } + + #[test] + fn special_chars_blocked() { + assert!(validate_username("admin@host").is_err()); + assert!(validate_username("user name").is_err()); + assert!(validate_username("user-name").is_err()); + } + + #[test] + fn valid_passwords() { + assert!(validate_password("12345678").is_ok()); + assert!(validate_password("a very long password").is_ok()); + } + + #[test] + fn too_short_password() { + assert!(validate_password("").is_err()); + assert!(validate_password("1234567").is_err()); + } +} diff --git a/net-guardia/src/domain/report/data.rs b/net-guardia/src/domain/report/data.rs index 69192ea..01cdfc3 100644 --- a/net-guardia/src/domain/report/data.rs +++ b/net-guardia/src/domain/report/data.rs @@ -1,9 +1,5 @@ -use chrono::{Duration as ChronoDuration, Local}; use serde::{Deserialize, Serialize}; -use crate::domain::common::error::Error; -use crate::interface::port::setting::SettingRepo; - /// Shared report data structure used by both HTML email and PDF report. #[derive(Debug, Clone, Serialize)] pub struct ReportData { @@ -60,124 +56,3 @@ pub struct SystemHealthSummary { pub disk_usage_percent: f64, pub ebpf_status: String, } - -impl ReportData { - /// Build report data from database settings (aggregated by the ML pipeline). - pub fn from_database(db: &dyn SettingRepo) -> Result { - 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 = 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 = 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(), - }); - - // Generate recommendations based on data - 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 = 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, - }) - } -} diff --git a/net-guardia/src/domain/response/defaults.rs b/net-guardia/src/domain/response/defaults.rs new file mode 100644 index 0000000..07cf209 --- /dev/null +++ b/net-guardia/src/domain/response/defaults.rs @@ -0,0 +1,145 @@ +pub struct DefaultPlaybook { + pub name: &'static str, + pub trigger_event: &'static str, + pub threshold: Option, + pub count: Option, + pub window: Option, + pub cooldown: i64, + pub actions: &'static [DefaultAction], + pub conditions: &'static [DefaultCondition], +} + +pub struct DefaultAction { + pub order: i64, + pub action_type: &'static str, + pub params: &'static str, +} + +pub struct DefaultCondition { + pub condition_type: &'static str, + pub operator: &'static str, + pub value: &'static str, + pub value2: Option<&'static str>, +} + +pub const DEFAULT_PLAYBOOKS: &[DefaultPlaybook] = &[ + DefaultPlaybook { + name: "brute_force_block", + trigger_event: "brute_force", + threshold: None, + count: Some(5), + window: Some(60), + cooldown: 600, + actions: &[ + DefaultAction { + order: 1, + action_type: "block_ip", + params: r#"{"ttl_secs": 3600}"#, + }, + DefaultAction { + order: 2, + action_type: "send_telegram", + params: "{}", + }, + DefaultAction { + order: 3, + action_type: "log", + params: r#"{"level": "warn"}"#, + }, + ], + conditions: &[DefaultCondition { + condition_type: "frequency", + operator: ">=", + value: "5", + value2: Some("60"), + }], + }, + DefaultPlaybook { + name: "port_scan_alert", + trigger_event: "port_scan", + threshold: Some(0.7), + count: None, + window: None, + cooldown: 300, + actions: &[ + DefaultAction { + order: 1, + action_type: "send_telegram", + params: "{}", + }, + DefaultAction { + order: 2, + action_type: "log", + params: r#"{"level": "warn"}"#, + }, + ], + conditions: &[DefaultCondition { + condition_type: "threshold", + operator: ">=", + value: "0.7", + value2: None, + }], + }, + DefaultPlaybook { + name: "fusion_c2_multi_source_block", + trigger_event: "c2_beacon", + threshold: None, + count: None, + window: None, + cooldown: 600, + actions: &[ + DefaultAction { + order: 1, + action_type: "block_ip", + params: r#"{"ttl_secs": 3600}"#, + }, + DefaultAction { + order: 2, + action_type: "send_telegram", + params: "{}", + }, + DefaultAction { + order: 3, + action_type: "log", + params: r#"{"level": "warn"}"#, + }, + ], + conditions: &[DefaultCondition { + condition_type: "multi_source_min", + operator: ">=", + value: "2", + value2: None, + }], + }, + DefaultPlaybook { + name: "fusion_c2_suricata_solo_high_block", + trigger_event: "c2_beacon", + threshold: None, + count: None, + window: None, + cooldown: 600, + actions: &[ + DefaultAction { + order: 1, + action_type: "block_ip", + params: r#"{"ttl_secs": 3600}"#, + }, + DefaultAction { + order: 2, + action_type: "send_telegram", + params: "{}", + }, + DefaultAction { + order: 3, + action_type: "log", + params: r#"{"level": "warn"}"#, + }, + ], + conditions: &[DefaultCondition { + condition_type: "single_source_high", + operator: "==", + value: "Suricata", + value2: Some("0.95"), + }], + }, +]; diff --git a/net-guardia/src/domain/response/mod.rs b/net-guardia/src/domain/response/mod.rs index 5177c7d..aaeab19 100644 --- a/net-guardia/src/domain/response/mod.rs +++ b/net-guardia/src/domain/response/mod.rs @@ -1,8 +1,7 @@ pub mod condition; +pub mod defaults; pub mod dry_run; pub mod error; -pub mod frequency; pub mod log; -pub mod matcher; pub mod playbook; pub mod playbook_data; diff --git a/net-guardia/src/infrastructure/health.rs b/net-guardia/src/infrastructure/health.rs index 065283c..eea6d07 100644 --- a/net-guardia/src/infrastructure/health.rs +++ b/net-guardia/src/infrastructure/health.rs @@ -25,6 +25,7 @@ use crate::domain::common::system::health::{ /// (`get_current_metrics`, `is_system_healthy`, HTTP handlers) just /// `.load()` the `ArcSwap` — no locks crossed, no `await` needed. pub struct SystemHealth { + config: Arc>, metrics: Arc>, broadcast_tx: broadcast::Sender, ingress_interface: String, @@ -55,6 +56,7 @@ impl SystemHealth { ); Ok(SystemHealth { + config, metrics: Arc::new(ArcSwap::from_pointee(initial)), broadcast_tx, ingress_interface, @@ -272,6 +274,7 @@ impl SystemHealth { pub fn is_system_healthy(&self) -> SystemHealthStatus { let metrics = self.get_current_metrics(); + let h = &self.config.load().health; let mut status = SystemHealthStatus { overall_healthy: true, @@ -279,34 +282,34 @@ impl SystemHealth { warnings: Vec::new(), }; - if metrics.cpu_details.cpu_usage > 90.0 { + if metrics.cpu_details.cpu_usage > h.cpu_issue_percent { status.overall_healthy = false; status .issues .push(format!("High CPU usage: {:.1}%", metrics.cpu_details.cpu_usage)); - } else if metrics.cpu_details.cpu_usage > 75.0 { + } else if metrics.cpu_details.cpu_usage > h.cpu_warn_percent { status .warnings .push(format!("Moderate CPU usage: {:.1}%", metrics.cpu_details.cpu_usage)); } - if metrics.memory_usage.usage_percent > 95.0 { + if metrics.memory_usage.usage_percent > h.mem_issue_percent { status.overall_healthy = false; status.issues.push(format!( "Critical memory usage: {:.1}%", metrics.memory_usage.usage_percent )); - } else if metrics.memory_usage.usage_percent > 80.0 { + } else if metrics.memory_usage.usage_percent > h.mem_warn_percent { status .warnings .push(format!("High memory usage: {:.1}%", metrics.memory_usage.usage_percent)); } if let Some(temp) = metrics.temperature { - if temp > 80.0 { + if temp > h.temp_issue_celsius { status.overall_healthy = false; status.issues.push(format!("High CPU temperature: {:.1}°C", temp)); - } else if temp > 70.0 { + } else if temp > h.temp_warn_celsius { status.warnings.push(format!("Elevated CPU temperature: {:.1}°C", temp)); } } @@ -320,16 +323,15 @@ impl SystemHealth { status.issues.push("Egress interface not available".to_string()); } - // Disk usage check let disk_usage = Self::check_disk_usage(); if let Some((usage_percent, available_gb)) = disk_usage { - if usage_percent > 95.0 { + if usage_percent > h.disk_issue_percent { status.overall_healthy = false; status.issues.push(format!( "Critical disk usage: {:.1}% (only {:.1} GB free). Traffic logging paused.", usage_percent, available_gb )); - } else if usage_percent > 90.0 { + } else if usage_percent > h.disk_warn_percent { status.warnings.push(format!( "High disk usage: {:.1}% ({:.1} GB free)", usage_percent, available_gb diff --git a/net-guardia/src/infrastructure/http_server.rs b/net-guardia/src/infrastructure/http_server.rs index 0702886..d9b6ecf 100644 --- a/net-guardia/src/infrastructure/http_server.rs +++ b/net-guardia/src/infrastructure/http_server.rs @@ -5,31 +5,33 @@ use std::sync::atomic::AtomicBool; use actix_cors::Cors; use actix_web::dev::ServerHandle; use actix_web::web::route; -use actix_web::{App, HttpResponse, HttpServer, web}; +use actix_web::{App, HttpServer, web}; use arc_swap::ArcSwap; use macros::log; use tokio::sync::broadcast; use crate::adapter::ebpf::EbpfServices; +use crate::adapter::http::middleware::auth::AuthMiddleware; +use crate::adapter::http::middleware::csrf::CsrfMiddleware; +use crate::adapter::http::middleware::https_redirect::HttpsRedirect; +use crate::adapter::http::middleware::jwt::JwtService; +use crate::adapter::http::middleware::setup_guard::SetupGuard; use crate::adapter::http::model_upload::PromoteGate; use crate::adapter::http::{ acl, api_keys, audit as audit_api, auth, byo, default, filter, flow_trace, fusion, health as health_api, - logs as logs_api, ml, model_upload, notification as notification_api, rate_limit as rate_limit_api, + logs as logs_api, ml, model_upload, notification as notification_api, rate_limit as rate_limit_api, ready, report as report_api, setup as setup_api, soar, stats, system as system_api, }; use crate::adapter::persistence::Database; use crate::adapter::websocket::routes as ws; use crate::core::common::config_service::ConfigService; -use crate::core::common::log_buffer::LogBuffer; +use crate::core::common::enforce_mode_handler::EnforceModeHandler; use crate::core::common::notification_service::NotificationService; +use crate::core::common::statistics::FlowStatistics; use crate::core::data_plane::acl_service::AclService; use crate::core::data_plane::dns_filter_service::DnsFilterService; use crate::core::data_plane::rate_limit_service::RateLimitService; -use crate::core::identity::csrf::CsrfMiddleware; -use crate::core::identity::https_redirect::{ForceHttpsFlag, HttpsRedirect}; -use crate::core::identity::jwt::JwtService; -use crate::core::identity::middleware::AuthMiddleware; -use crate::core::identity::setup_guard::{SetupCompleteFlag, SetupGuard}; +use crate::core::identity::auth_service::AuthService; use crate::core::response::engine::SoarEngine; use crate::core::response::playbook_service::PlaybookService; use crate::domain::common::config::AppConfig; @@ -40,8 +42,9 @@ use crate::domain::common::event::{AuditEvent, ThreatDetectedEvent}; use crate::domain::common::log::http::HttpLog; use crate::domain::common::system::readiness::ReadinessState; use crate::domain::detection::ml_inference_config::MLInferenceConfig; -use crate::infrastructure::app_services::AppServices; -use crate::infrastructure::enforce_mode_handler::EnforceModeHandler; +use crate::infrastructure::health::SystemHealth; +use crate::infrastructure::inference_runtime::InferenceRuntime; +use crate::infrastructure::log_buffer::LogBuffer; use crate::infrastructure::logger::Logger; use crate::infrastructure::secret_store::SecretStore; use crate::infrastructure::suricata_manager::SuricataManager; @@ -52,47 +55,52 @@ use crate::interface::port::audit::AuditRepo; use crate::interface::port::drop_stats::DropStatsPort; use crate::interface::port::protocol_filter::ProtocolFilterPort; -/// Shared flag: true when all services (eBPF, ML, SOAR) are fully initialized. -pub type ReadyFlag = Arc; +#[derive(Clone)] +pub struct SetupCompleteFlag(pub Arc); + +#[derive(Clone)] +pub struct ReadyFlag(pub Arc); + +#[derive(Clone)] +pub struct ForceHttpsFlag(pub Arc); -/// Parameters for starting the HTTP server, avoiding `#[cfg]` on function params. pub struct HttpServerParams { pub app_config: Arc>, pub inference_config: Arc, - pub ebpf_services: Arc, - pub app_services: Arc, - pub db: Arc, + + pub database: Arc, pub secret_store: Arc, + pub logger: Arc, + pub log_buffer: Arc, + + pub ebpf_services: Arc, + pub inference_runtime: Arc, + pub health: Arc, + pub flow_statistics: Arc, + pub jwt_service: Arc, - pub threat_tx: broadcast::Sender, - pub audit_tx: broadcast::Sender, pub enforce_handler: Arc, - pub setup_complete: SetupCompleteFlag, - pub ready: ReadyFlag, - pub readiness_state: Arc, + pub acl_service: Arc, pub config_service: Arc, pub dns_filter_service: Arc, pub notification_service: Arc, pub playbook_service: Arc, pub rate_limit_service: Arc, + pub soar_engine: Arc, + pub suricata_manager: Arc, + + pub threat_tx: broadcast::Sender, + pub audit_tx: broadcast::Sender, + + pub setup_complete: SetupCompleteFlag, + pub ready: ReadyFlag, + pub readiness_state: Arc, pub force_https: ForceHttpsFlag, pub shutdown_handle: Arc, - pub logger: Arc, - pub log_buffer: Arc, - pub suricata_manager: Arc, - pub soar_engine: Arc, } -/// CORS configuration shared by both full and setup servers. -/// -/// When `allowed_origins` is non-empty, only those exact origins are permitted. -/// When empty, RFC 1918 private-network origins (localhost, 127.0.0.1, -/// 192.168.x.x, 10.x.x.x, 172.16-31.x.x) are allowed. -/// -/// The host is parsed as an IP address — domain names like "10.malware.net" -/// are rejected because they fail IP parsing. -fn cors(allowed_origins: Vec) -> actix_cors::Cors { +fn cors(allowed_origins: Vec) -> Cors { Cors::default() .allowed_origin_fn(move |origin, _req_head| { let origin_str = origin.to_str().unwrap_or(""); @@ -106,27 +114,18 @@ fn cors(allowed_origins: Vec) -> actix_cors::Cors { .max_age(3600) } -/// Extract the host portion from an origin string like "http://10.0.0.1:8080". -/// Returns the host without scheme or port. fn extract_origin_host(origin: &str) -> Option<&str> { - // Strip scheme let after_scheme = origin .strip_prefix("http://") .or_else(|| origin.strip_prefix("https://"))?; - // Strip port (if present) — find last colon that isn't part of IPv6 - // For IPv6 origins like http://[::1]:8080, strip brackets too if after_scheme.starts_with('[') { - // IPv6 bracket notation: [::1]:8080 let bracket_end = after_scheme.find(']')?; Some(&after_scheme[1..bracket_end]) } else { - // IPv4 or hostname: split at last colon for port Some(after_scheme.split(':').next().unwrap_or(after_scheme)) } } -/// Check if an origin URL points to a RFC 1918 private network address or localhost. -/// Only accepts actual IP addresses — domain names are rejected. fn is_private_origin(origin: &str) -> bool { let host = match extract_origin_host(origin) { Some(h) => h, @@ -137,43 +136,38 @@ fn is_private_origin(origin: &str) -> bool { return true; } - // Try parsing as IPv4 if let Ok(ipv4) = host.parse::() { let octets = ipv4.octets(); return octets[0] == 127 // 127.0.0.0/8 || octets[0] == 10 // 10.0.0.0/8 - || (octets[0] == 172 && (16..=31).contains(&octets[1])) // 172.16.0.0/12 + || (octets[0] == 172 && (16..=31).contains(&octets[1])) // 172.16.0.0/12 || (octets[0] == 192 && octets[1] == 168); // 192.168.0.0/16 } - // Try parsing as IPv6 if let Ok(ipv6) = host.parse::() { return ipv6.is_loopback(); } - // Not a valid IP address (e.g. "10.malware.net") — reject false } -/// Minimal HTTP server for setup wizard mode. -/// Only serves setup, auth, and health routes — no eBPF/ML dependencies. -/// Returns a ServerHandle so the caller can stop it after setup completes. pub fn start_setup_server( - db: Arc, + database: Arc, secret_store: Arc, jwt_service: Arc, setup_complete: SetupCompleteFlag, port: u16, ) -> Result { - let make_app = move || { + let app = move || { App::new() .wrap(cors(vec![])) - .app_data(web::Data::from(db.clone() as Arc)) - .app_data(web::Data::from(db.clone() as Arc)) - .app_data(web::Data::from(db.clone() as Arc)) - .app_data(web::Data::from(db.clone())) + .app_data(web::Data::from(database.clone() as Arc)) + .app_data(web::Data::from(database.clone() as Arc)) + .app_data(web::Data::from(database.clone() as Arc)) + .app_data(web::Data::from(database.clone())) .app_data(web::Data::from(secret_store.clone())) .app_data(web::Data::from(jwt_service.clone())) + .app_data(web::Data::new(AuthService::new(database.clone(), jwt_service.clone()))) .app_data(web::Data::new(setup_complete.clone())) .service( web::scope("/api") @@ -185,27 +179,26 @@ pub fn start_setup_server( .default_service(route().to(default::default_route)) }; - let server = match HttpServer::new(make_app.clone()) + let server = match HttpServer::new(app.clone()) .workers(1) - .shutdown_timeout(1) // Fast shutdown — no long-lived connections to drain + .shutdown_timeout(1) .bind(format!("0.0.0.0:{}", port)) { Ok(s) => s, - Err(e) if port != HTTP_FALLBACK_PORT => { - log!(HttpLog::SetupBindFallback(port, e.to_string(), HTTP_FALLBACK_PORT)); - HttpServer::new(make_app) + Err(err) if port != HTTP_FALLBACK_PORT => { + log!(HttpLog::SetupBindFallback(port, err.to_string(), HTTP_FALLBACK_PORT)); + HttpServer::new(app) .workers(1) .shutdown_timeout(1) .bind(format!("0.0.0.0:{}", HTTP_FALLBACK_PORT)) .map_err(HttpError::BindPortError)? } - Err(e) => Err(HttpError::BindPortError(e))?, + Err(err) => Err(HttpError::BindPortError(err))?, } .run(); let handle = server.handle(); - // Spawn the server in background (!Send future, use actix::spawn) actix::spawn(async move { if let Err(e) = server.await { log!(HttpLog::SetupServerError(e.to_string())); @@ -215,125 +208,126 @@ pub fn start_setup_server( Ok(handle) } -/// Run the full HTTP server with all services. pub async fn run(params: HttpServerParams) -> Result<(), Error> { + let app_config = params.app_config; + let inference_config = params.inference_config; + + let database = params.database; + let secret_store = params.secret_store; + let logger = params.logger; + let log_buffer = params.log_buffer; + let access_control = params.ebpf_services.access_control.clone(); let protocol_filter: Arc = params.ebpf_services.protocol_filter.clone(); let dns_filter = params.ebpf_services.dns_filter.clone(); let geo_block = params.ebpf_services.geo_block.clone(); let rate_limit = params.ebpf_services.rate_limit.clone(); - let health = params.app_services.health.clone(); - let ml_alert = params.app_services.ml_alert.clone(); - let ml_engine = params.app_services.ml_engine.clone(); - let ml_inference = params.app_services.ml_inference.clone(); - let fusion_metrics = params.app_services.fusion_metrics.clone(); - let flow_statistics = params.app_services.flow_statistics.clone(); let drop_monitor = params.ebpf_services.drop_monitor.clone(); let drop_stats: Arc = drop_monitor.clone(); - let app_config = params.app_config; - let inference_config = params.inference_config; - let db = params.db; - let secret_store = params.secret_store; + let ml_alert = params.inference_runtime.ml_alert.clone(); + let ml_engine = params.inference_runtime.ml_engine.clone(); + let ml_inference = params.inference_runtime.ml_inference.clone(); + let fusion_metrics = params.inference_runtime.fusion_metrics.clone(); + let health = params.health; + let flow_statistics = params.flow_statistics; + let jwt_service = params.jwt_service; - let threat_tx = params.threat_tx; - let audit_tx = params.audit_tx; let enforce_handler = params.enforce_handler; - let setup_complete = params.setup_complete; - let ready = params.ready; - let readiness_state = params.readiness_state; + let acl_service = params.acl_service; let config_service = params.config_service; let dns_filter_service = params.dns_filter_service; let notification_service = params.notification_service; let playbook_service = params.playbook_service; let rate_limit_service = params.rate_limit_service; + let soar_engine = params.soar_engine; + let suricata_manager = params.suricata_manager; + + let threat_tx = params.threat_tx; + let audit_tx = params.audit_tx; + + let setup_complete = params.setup_complete; + let ready = params.ready; + let readiness_state = params.readiness_state; let force_https = params.force_https; let shutdown_handle = params.shutdown_handle; - let logger = params.logger; - let log_buffer = params.log_buffer; - let suricata_manager = params.suricata_manager; - let soar_engine = params.soar_engine; + let port = app_config.load().http_server.port; - // Shared across every actix worker so concurrent model uploads - // serialize their rename-into-`models/` critical section. Built - // here rather than threaded through HttpServerParams because - // nothing outside the HTTP boundary needs to observe it. let promote_lock: Arc = Arc::new(PromoteGate::new()); HttpServer::new(move || { let app = App::new() .wrap(HttpsRedirect) .wrap(cors(app_config.load().http_server.cors_allowed_origins.clone())) - .app_data(web::Data::new(force_https.clone())) - .app_data(web::Data::from(shutdown_handle.clone())) .app_data(web::Data::from(app_config.clone())) .app_data(web::Data::from(inference_config.clone())) + .app_data(web::Data::from(database.clone() as Arc)) + .app_data(web::Data::from(database.clone() as Arc)) + .app_data(web::Data::from(database.clone() as Arc)) + .app_data(web::Data::from(database.clone())) + .app_data(web::Data::from(secret_store.clone())) + .app_data(web::Data::from(logger.clone())) + .app_data(web::Data::from(log_buffer.clone())) .app_data(web::Data::from(access_control.clone())) .app_data(web::Data::from(protocol_filter.clone())) .app_data(web::Data::from(dns_filter.clone())) .app_data(web::Data::from(geo_block.clone())) .app_data(web::Data::from(rate_limit.clone())) - .app_data(web::Data::from(health.clone())) + .app_data(web::Data::from(drop_monitor.clone())) + .app_data(web::Data::from(drop_stats.clone())) .app_data(web::Data::from(ml_alert.clone())) .app_data(web::Data::from(ml_engine.clone())) .app_data(web::Data::from(ml_inference.clone())) .app_data(web::Data::from(fusion_metrics.clone())) + .app_data(web::Data::from(health.clone())) .app_data(web::Data::from(flow_statistics.clone())) - .app_data(web::Data::from(drop_monitor.clone())) - .app_data(web::Data::from(drop_stats.clone())) - .app_data(web::Data::from(db.clone() as Arc)) - .app_data(web::Data::from(db.clone() as Arc)) - .app_data(web::Data::from(db.clone() as Arc)) - .app_data(web::Data::from(db.clone())) - .app_data(web::Data::from(secret_store.clone())) .app_data(web::Data::from(jwt_service.clone())) - .app_data(web::Data::new(threat_tx.clone())) - .app_data(web::Data::new(audit_tx.clone())) + .app_data(web::Data::new(AuthService::new(database.clone(), jwt_service.clone()))) .app_data(web::Data::from(enforce_handler.clone())) - .app_data(web::Data::new(setup_complete.clone())) - .app_data(web::Data::new(ready.clone())) - .app_data(web::Data::from(readiness_state.clone())) .app_data(web::Data::from(acl_service.clone())) .app_data(web::Data::from(config_service.clone())) .app_data(web::Data::from(dns_filter_service.clone())) .app_data(web::Data::from(notification_service.clone())) .app_data(web::Data::from(playbook_service.clone())) .app_data(web::Data::from(rate_limit_service.clone())) - .app_data(web::Data::from(logger.clone())) - .app_data(web::Data::from(log_buffer.clone())) - .app_data(web::Data::from(suricata_manager.clone())) .app_data(web::Data::from(soar_engine.clone())) + .app_data(web::Data::from(suricata_manager.clone())) + .app_data(web::Data::new(threat_tx.clone())) + .app_data(web::Data::new(audit_tx.clone())) + .app_data(web::Data::new(setup_complete.clone())) + .app_data(web::Data::new(ready.clone())) + .app_data(web::Data::from(readiness_state.clone())) + .app_data(web::Data::new(force_https.clone())) + .app_data(web::Data::from(shutdown_handle.clone())) .app_data(web::Data::from(promote_lock.clone())); app.wrap(SetupGuard) .service( web::scope("/api") .wrap(CsrfMiddleware) .wrap(AuthMiddleware) - .service(auth::initialize()) - .service(acl::initialize()) - .service(filter::initialize()) - .service(rate_limit_api::initialize()) - .service(stats::initialize()) .service(health_api::initialize()) .service(ml::initialize()) .service(model_upload::initialize()) .service(byo::initialize()) .service(fusion::initialize()) .service(flow_trace::initialize()) - .service(system_api::initialize()) + .service(stats::initialize()) + .service(auth::initialize()) + .service(acl::initialize()) + .service(filter::initialize()) + .service(rate_limit_api::initialize()) .service(soar::initialize()) .service(notification_api::initialize()) .service(report_api::initialize()) + .service(system_api::initialize()) + .service(setup_api::initialize()) .service(api_keys::initialize()) .service(logs_api::initialize()) - .service(audit_api::initialize()) - .service(setup_api::initialize()), + .service(audit_api::initialize()), ) .service(ws::initialize()) - // Health-ready endpoint outside /api scope — no auth, no SetupGuard. - // Path intentionally NOT under /api/ to avoid AuthMiddleware. - .route("/health/ready", web::get().to(health_ready)) + .route("/health/ready", web::get().to(ready::health_ready)) .default_service(route().to(default::default_route)) }) .bind(format!("0.0.0.0:{}", port)) @@ -344,24 +338,6 @@ pub async fn run(params: HttpServerParams) -> Result<(), Error> { Ok(()) } -async fn health_ready(ready: web::Data, state: web::Data) -> HttpResponse { - use std::sync::atomic::Ordering::SeqCst; - - 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, - })) -} - #[cfg(test)] mod tests { use super::*; diff --git a/net-guardia/src/infrastructure/app_services.rs b/net-guardia/src/infrastructure/inference_runtime.rs similarity index 86% rename from net-guardia/src/infrastructure/app_services.rs rename to net-guardia/src/infrastructure/inference_runtime.rs index 13c59b6..4c79a13 100644 --- a/net-guardia/src/infrastructure/app_services.rs +++ b/net-guardia/src/infrastructure/inference_runtime.rs @@ -8,9 +8,12 @@ use macros::log; use tokio::sync::broadcast; use tokio::sync::oneshot; +use crate::core::detection::metrics::FusionMetrics; use crate::core::inference::alert::MLAlert; use crate::core::inference::drift_detector::DriftDetectorHandle; use crate::core::inference::engine::Engine; +use crate::core::inference::engine::EngineConfig; +use crate::core::inference::model_adapter::ModelSourceState; use crate::core::inference::model_loader::build_adapter; use crate::core::inference::runner::Inference; use crate::core::inference::traffic_logger::{RotationPolicy, TrafficLogger}; @@ -21,43 +24,29 @@ use crate::domain::common::error::misc::MiscError; use crate::domain::common::error::system::SystemError; use crate::domain::common::event::AuditEvent; use crate::domain::common::log::system::SystemLog; -use crate::domain::common::system::health::EbpfHealth; use crate::domain::detection::flow_features::FlowFeatures; use crate::domain::detection::flow_tracker::FlowLimits; use crate::domain::detection::log::MLLog; use crate::domain::detection::manifest::ModelManifest; -use crate::domain::detection::metrics::FusionMetrics; -use crate::domain::detection::ml_detection::EngineConfig; use crate::domain::detection::ml_inference_config::MLInferenceConfig; -use crate::domain::detection::model_adapter::ModelSourceState; use crate::domain::detection::model_source::ModelInfo; -use crate::infrastructure::health::SystemHealth; -use crate::infrastructure::statistics::FlowStatistics; -/// Application-level service orchestrator. -/// Holds all runtime services (health monitoring, ML inference, flow statistics) -/// and manages their lifecycle (start/shutdown). -pub struct AppServices { - pub health: Arc, +pub struct InferenceRuntime { pub ml_alert: Arc, pub ml_inference: Arc, pub ml_engine: Arc, - pub flow_statistics: Arc, pub fusion_metrics: Arc, shutdowns: SegQueue>, } -impl AppServices { +impl InferenceRuntime { pub fn new( app_config: Arc>, inference_config: Arc, ml_manifest: Option, drift_detector: DriftDetectorHandle, - ebpf_health: Arc>, audit_tx: broadcast::Sender, ) -> Result { - let health = SystemHealth::new(app_config.clone(), ebpf_health)?; - let config = app_config.load(); let batch_size = config.ml.inference_batch_size; let onnx_load_timeout = Duration::from_secs(config.ml.onnx_load_timeout_secs); @@ -166,27 +155,20 @@ impl AppServices { config.ebpf.combined_queue_count, )); - let flow_statistics = Arc::new(FlowStatistics::new(ml_engine.clone())); let fusion_metrics = Arc::new(FusionMetrics::new()); Ok(Self { - health: Arc::new(health), ml_alert, ml_inference, ml_engine, - flow_statistics, fusion_metrics, shutdowns: SegQueue::new(), }) } pub async fn run(&self) -> Result<(), Error> { - let health = self.health.clone(); let ml_engine = self.ml_engine.clone(); - let health_shutdown = health.run(Duration::from_secs(3)).await; - self.shutdowns.push(health_shutdown); - let ml_shutdown = ml_engine.run().await; self.shutdowns.push(ml_shutdown); diff --git a/net-guardia/src/core/common/log_buffer.rs b/net-guardia/src/infrastructure/log_buffer.rs similarity index 100% rename from net-guardia/src/core/common/log_buffer.rs rename to net-guardia/src/infrastructure/log_buffer.rs diff --git a/net-guardia/src/infrastructure/logger.rs b/net-guardia/src/infrastructure/logger.rs index 4af9fbf..8e2db99 100644 --- a/net-guardia/src/infrastructure/logger.rs +++ b/net-guardia/src/infrastructure/logger.rs @@ -11,11 +11,14 @@ use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::util::SubscriberInitExt; use tracing_subscriber::{Layer, filter, reload}; -use crate::core::common::log_buffer::{LogBuffer, LogBufferLayer}; use crate::domain::common::config::observability::ObservabilityConfig; use crate::domain::common::error::Error; use crate::domain::common::error::io::IOError; -use crate::interface::utils::logging::FilterControl; +use crate::infrastructure::log_buffer::{LogBuffer, LogBufferLayer}; +pub trait FilterControl: Send + Sync { + fn reload_filter(&self, filter: EnvFilter) -> Result<(), String>; + fn current_filter(&self) -> String; +} pub struct Logger { filter_handle: Box, diff --git a/net-guardia/src/infrastructure/logging.rs b/net-guardia/src/infrastructure/logging.rs deleted file mode 100644 index 4eb4e4a..0000000 --- a/net-guardia/src/infrastructure/logging.rs +++ /dev/null @@ -1,200 +0,0 @@ -use std::fs; -use std::sync::OnceLock; -use std::{env, io}; - -use tracing::Level; -use tracing::level_filters::LevelFilter; -use tracing_appender::rolling::{RollingFileAppender, Rotation}; -use tracing_subscriber::filter::Directive; -use tracing_subscriber::filter::EnvFilter; -use tracing_subscriber::fmt::layer as fmt_layer; -use tracing_subscriber::layer::SubscriberExt; -use tracing_subscriber::util::SubscriberInitExt; -use tracing_subscriber::{Layer, filter, reload}; - -use crate::core::common::observability::log_buffer::LogBufferLayer; -use crate::domain::common::config::observability::ObservabilityConfig; -use crate::domain::common::error::Error; -use crate::domain::common::error::io::IOError; -use crate::interface::utils::logging::FilterControl; - -/// Type-erased reload handle stored as a trait object. -/// We erase the complex layered type by boxing the modify closure. -static FILTER_HANDLE: OnceLock> = OnceLock::new(); - -/// Snapshot of the per-target directives (e.g. `maxminddb=warn`) in effect -/// at `initialize()` time. `set_level` rebuilds the filter from scratch -/// around a new root level; reapplying these keeps any RUST_LOG overrides -/// the operator configured for specific crates from being silently lost. -static PRESERVED_DIRECTIVES: OnceLock> = OnceLock::new(); - -pub struct Logging; - -impl Logging { - pub fn initialize(config: &ObservabilityConfig) -> Result<(), Error> { - let log_directory = "logs"; - fs::create_dir_all(log_directory).map_err(|err| IOError::CreateDirectoryFailed(log_directory, err))?; - - let file_appender = RollingFileAppender::new(Rotation::DAILY, log_directory, "NetGuardia"); - - let stdout_layer = fmt_layer() - .with_file(true) - .with_line_number(true) - .with_thread_ids(true) - .with_target(false) - .with_ansi(true); - - let file_layer = fmt_layer() - .with_file(false) - .with_line_number(false) - .with_thread_ids(false) - .with_target(true) - .with_ansi(false) - .with_writer(file_appender); - - // RUST_LOG overrides the config value when present - let level: Level = env::var("RUST_LOG") - .ok() - .and_then(|s| s.parse().ok()) - .or_else(|| config.log_level.parse().ok()) - .unwrap_or(Level::INFO); - - let mut preserved: Vec = env::var("RUST_LOG") - .ok() - .map(|raw| { - raw.split(',') - .map(|s| s.trim().to_string()) - .filter(|d| !d.is_empty() && d.contains('=')) - .collect() - }) - .unwrap_or_default(); - if !preserved.iter().any(|d| d == "maxminddb=warn") { - preserved.push("maxminddb=warn".to_string()); - } - let _ = PRESERVED_DIRECTIVES.set(preserved); - - let mut filter = EnvFilter::new(level.to_string()); - if let Some(directives) = PRESERVED_DIRECTIVES.get() { - for d in directives { - if let Ok(parsed) = d.parse::() { - filter = filter.add_directive(parsed); - } - } - } - - let (filter_layer, reload_handle) = reload::Layer::new(filter); - - tracing_subscriber::registry() - .with(filter_layer) - .with(stdout_layer) - .with(file_layer) - .with(LogBufferLayer::new(config.log_buffer_capacity, config.log_buffer_max_message_bytes)) - .init(); - - let _ = FILTER_HANDLE.set(Box::new(reload_handle)); - - Ok(()) - } - - pub fn initialize_cli() -> Result<(), Error> { - let stdout_layer = fmt_layer() - .without_time() - .with_level(false) - .with_target(false) - .with_file(false) - .with_line_number(false) - .with_thread_ids(false) - .with_ansi(false) - .with_writer(io::stdout) - .with_filter(LevelFilter::INFO); - - let stderr_layer = fmt_layer() - .without_time() - .with_level(false) - .with_target(false) - .with_writer(io::stderr) - .with_filter(filter::filter_fn(|m| m.level() <= &Level::WARN)); - - tracing_subscriber::registry() - .with(stdout_layer) - .with(stderr_layer) - .init(); - Ok(()) - } - - pub fn set_level(level: &str) -> Result { - let handle = FILTER_HANDLE.get().ok_or("Logging not initialized")?; - - let parsed_level: Level = level.parse().map_err(|_| { - format!( - "Invalid log level '{}'. Valid levels: trace, debug, info, warn, error", - level - ) - })?; - - let mut new_filter = EnvFilter::new(parsed_level.to_string()); - if let Some(directives) = PRESERVED_DIRECTIVES.get() { - for d in directives { - if let Ok(parsed) = d.parse::() { - new_filter = new_filter.add_directive(parsed); - } - } - } - - handle.reload_filter(new_filter)?; - - Ok(parsed_level.to_string().to_lowercase()) - } - - /// Get the current global log level as a bare lowercase directive — - /// e.g. `"info"`, not the full `"maxminddb=warn,info"` EnvFilter string. - /// Per-target overrides (like `maxminddb=warn`) are internal tuning and - /// would break the frontend `