mirror of
https://github.com/DaLaw2/NetGuardia.git
synced 2026-08-24 22:20:28 +09:00
wip: Architecture adjustment
This commit is contained in:
parent
c2c4ed5b23
commit
7043ec0c43
@ -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;
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
use actix_web::{HttpResponse, Scope, web};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::core::identity::extractor::AuthClaims;
|
||||
use crate::adapter::http::middleware::extractor::AuthClaims;
|
||||
use crate::interface::port::api_key::ApiKeyRepo;
|
||||
|
||||
pub fn initialize() -> Scope {
|
||||
@ -16,13 +16,13 @@ async fn list_keys(_auth: AuthClaims, db: web::Data<dyn ApiKeyRepo>) -> HttpResp
|
||||
Ok(keys) => {
|
||||
let responses: Vec<serde_json::Value> = keys
|
||||
.into_iter()
|
||||
.map(|(id, name, level, created, last_used)| {
|
||||
.map(|k| {
|
||||
serde_json::json!({
|
||||
"id": id,
|
||||
"name": name,
|
||||
"permission_level": level,
|
||||
"created_at": created,
|
||||
"last_used_at": last_used,
|
||||
"id": k.id,
|
||||
"name": k.name,
|
||||
"permission_level": k.permission_level,
|
||||
"created_at": k.created_at,
|
||||
"last_used_at": k.last_used_at,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -1,13 +1,12 @@
|
||||
use actix_web::{HttpResponse, Responder, Scope, web};
|
||||
use macros::log;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::adapter::http::middleware::extractor::AuthClaims;
|
||||
use crate::adapter::http::response::ok_or_error;
|
||||
use crate::core::identity::extractor::AuthClaims;
|
||||
use crate::core::identity::jwt::JwtService;
|
||||
use crate::core::identity::auth_service::{AuthService, LoginError, RegisterError};
|
||||
use crate::domain::identity::auth::{DEFAULT_ADMIN_USERNAME, GROUP_ADMIN, GROUP_VIEWER, ROLE_ADMIN, ROLE_VIEWER};
|
||||
use crate::domain::identity::error::AuthError;
|
||||
use crate::domain::identity::password;
|
||||
use crate::domain::identity::validation::validate_password;
|
||||
use crate::interface::port::app_repo::AppRepo;
|
||||
|
||||
type Repo = dyn AppRepo;
|
||||
@ -49,161 +48,47 @@ pub fn initialize() -> Scope {
|
||||
.route("/groups/{id}", web::delete().to(delete_group))
|
||||
}
|
||||
|
||||
fn validate_username(username: &str) -> Result<(), &'static str> {
|
||||
if username.is_empty() || username.len() > 32 {
|
||||
return Err("Username must be 1-32 characters");
|
||||
}
|
||||
if !username.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
|
||||
return Err("Username must contain only alphanumeric characters and underscores");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_password(password: &str) -> Result<(), &'static str> {
|
||||
if password.len() < 8 {
|
||||
return Err("Password must be at least 8 characters");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Dummy Argon2 hash used to prevent timing-based username enumeration.
|
||||
/// When a user doesn't exist, we still run verify_password against this
|
||||
/// so the response time is indistinguishable from a real user lookup.
|
||||
const DUMMY_HASH: &str = "$argon2id$v=19$m=19456,t=2,p=1$dW5rbm93bg$QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUE";
|
||||
|
||||
async fn login(body: web::Json<LoginRequest>, db: web::Data<Repo>, jwt: web::Data<JwtService>) -> impl Responder {
|
||||
async fn login(body: web::Json<LoginRequest>, auth_svc: web::Data<AuthService>) -> impl Responder {
|
||||
let req = body.into_inner();
|
||||
|
||||
// Check login lockout
|
||||
match db.check_login_locked(&req.username) {
|
||||
Ok(Some(remaining_secs)) => {
|
||||
return HttpResponse::TooManyRequests().json(serde_json::json!({
|
||||
"error": "Account temporarily locked due to too many failed login attempts",
|
||||
"retry_after_secs": remaining_secs,
|
||||
}));
|
||||
}
|
||||
Err(_) => {}
|
||||
Ok(None) => {}
|
||||
}
|
||||
|
||||
let user = match db.find_user(&req.username) {
|
||||
Ok(Some(u)) => u,
|
||||
_ => {
|
||||
// Run dummy hash verification to prevent timing-based username enumeration
|
||||
let _ = password::verify_password(&req.password, DUMMY_HASH);
|
||||
if let Err(e) = db.record_login_failure(&req.username) {
|
||||
log!(AuthError::LoginFailureTrackingError(e));
|
||||
}
|
||||
return HttpResponse::Unauthorized().json(serde_json::json!({"error": "Invalid credentials"}));
|
||||
}
|
||||
};
|
||||
|
||||
let (id, username, hash, _db_role, force_password_change) = user;
|
||||
|
||||
match password::verify_password(&req.password, &hash) {
|
||||
Ok(true) => {}
|
||||
_ => {
|
||||
if let Err(e) = db.record_login_failure(&req.username) {
|
||||
log!(AuthError::LoginFailureTrackingError(e));
|
||||
}
|
||||
return HttpResponse::Unauthorized().json(serde_json::json!({"error": "Invalid credentials"}));
|
||||
}
|
||||
}
|
||||
|
||||
// Clear login failures on success
|
||||
if let Err(e) = db.clear_login_failures(&req.username) {
|
||||
log!(AuthError::LoginClearError(e));
|
||||
}
|
||||
|
||||
// Permissions come exclusively from groups — no role-based fallback
|
||||
let permissions = db.list_user_permissions(id).unwrap_or_default();
|
||||
|
||||
let groups = db.list_groups_for_user(id).unwrap_or_default();
|
||||
let role = if groups.iter().any(|(_id, name, _desc, _perms)| name == GROUP_ADMIN) {
|
||||
ROLE_ADMIN.to_string()
|
||||
} else {
|
||||
ROLE_VIEWER.to_string()
|
||||
};
|
||||
|
||||
match jwt.create_token(id, &username, &role, permissions) {
|
||||
Ok(token) => HttpResponse::Ok().json(serde_json::json!({
|
||||
"token": token,
|
||||
"role": role,
|
||||
"force_password_change": force_password_change,
|
||||
match auth_svc.login(&req.username, &req.password) {
|
||||
Ok(result) => HttpResponse::Ok().json(result),
|
||||
Err(LoginError::Locked { retry_after_secs }) => HttpResponse::TooManyRequests().json(serde_json::json!({
|
||||
"error": "Account temporarily locked due to too many failed login attempts",
|
||||
"retry_after_secs": retry_after_secs,
|
||||
})),
|
||||
Err(_) => HttpResponse::InternalServerError().json(serde_json::json!({"error": "Failed to create token"})),
|
||||
Err(LoginError::InvalidCredentials) => {
|
||||
HttpResponse::Unauthorized().json(serde_json::json!({"error": "Invalid credentials"}))
|
||||
}
|
||||
Err(LoginError::InternalError) => {
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Failed to create token"}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn register(auth: AuthClaims, body: web::Json<RegisterRequest>, db: web::Data<Repo>) -> impl Responder {
|
||||
async fn register(
|
||||
auth: AuthClaims,
|
||||
body: web::Json<RegisterRequest>,
|
||||
auth_svc: web::Data<AuthService>,
|
||||
) -> impl Responder {
|
||||
let reg = body.into_inner();
|
||||
|
||||
// Validate input
|
||||
if let Err(msg) = validate_username(®.username) {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({"error": msg}));
|
||||
}
|
||||
if let Err(msg) = validate_password(®.password) {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({"error": msg}));
|
||||
}
|
||||
|
||||
// Validate role
|
||||
if reg.role != ROLE_ADMIN && reg.role != ROLE_VIEWER {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({"error": "Role must be 'admin' or 'viewer'"}));
|
||||
}
|
||||
|
||||
// Only admins can create admin accounts
|
||||
if reg.role == ROLE_ADMIN && auth.role != ROLE_ADMIN {
|
||||
return HttpResponse::Forbidden()
|
||||
.json(serde_json::json!({"error": "Only administrators can create admin accounts"}));
|
||||
}
|
||||
|
||||
let hash = match password::hash_password(®.password) {
|
||||
Ok(h) => h,
|
||||
Err(_) => {
|
||||
return HttpResponse::InternalServerError().json(serde_json::json!({"error": "Failed to hash password"}));
|
||||
match auth_svc.register(®.username, ®.password, ®.role, &auth.role) {
|
||||
Ok(_) => HttpResponse::Created().json(serde_json::json!({"username": reg.username, "role": reg.role})),
|
||||
Err(RegisterError::Validation(msg)) => HttpResponse::BadRequest().json(serde_json::json!({"error": msg})),
|
||||
Err(RegisterError::InvalidRole) => {
|
||||
HttpResponse::BadRequest().json(serde_json::json!({"error": "Role must be 'admin' or 'viewer'"}))
|
||||
}
|
||||
};
|
||||
|
||||
match db.insert_user(®.username, &hash, ®.role, false) {
|
||||
Ok(new_user_id) => {
|
||||
// Auto-assign to default group based on role
|
||||
let default_group_name = if reg.role == ROLE_ADMIN {
|
||||
GROUP_ADMIN
|
||||
} else {
|
||||
GROUP_VIEWER
|
||||
};
|
||||
if let Ok(groups) = db.list_user_groups()
|
||||
&& let Some((group_id, _, _, _, _)) =
|
||||
groups.into_iter().find(|(_, name, _, _, _)| name == default_group_name)
|
||||
&& let Err(e) = db.set_user_groups(new_user_id, &[group_id])
|
||||
{
|
||||
log!(AuthError::GroupAssignmentFailed(e));
|
||||
}
|
||||
HttpResponse::Created().json(serde_json::json!({"username": reg.username, "role": reg.role}))
|
||||
Err(RegisterError::Forbidden) => HttpResponse::Forbidden()
|
||||
.json(serde_json::json!({"error": "Only administrators can create admin accounts"})),
|
||||
Err(RegisterError::HashFailed) => {
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Failed to hash password"}))
|
||||
}
|
||||
Err(e) => HttpResponse::Conflict().json(serde_json::json!({"error": e.to_string()})),
|
||||
Err(RegisterError::Conflict(e)) => HttpResponse::Conflict().json(serde_json::json!({"error": e.to_string()})),
|
||||
}
|
||||
}
|
||||
|
||||
async fn me(auth: AuthClaims, db: web::Data<Repo>) -> impl Responder {
|
||||
let user_groups = db.list_groups_for_user(auth.sub).unwrap_or_default();
|
||||
let group_names: Vec<String> = user_groups
|
||||
.iter()
|
||||
.map(|(_id, name, _desc, _perms)| name.clone())
|
||||
.collect();
|
||||
let role = if group_names.iter().any(|n| n == GROUP_ADMIN) {
|
||||
ROLE_ADMIN
|
||||
} else {
|
||||
ROLE_VIEWER
|
||||
};
|
||||
let permissions = db.list_user_permissions(auth.sub).unwrap_or_default();
|
||||
HttpResponse::Ok().json(serde_json::json!({
|
||||
"id": auth.sub,
|
||||
"username": auth.username,
|
||||
"role": role,
|
||||
"permissions": permissions,
|
||||
"groups": group_names,
|
||||
}))
|
||||
async fn me(auth: AuthClaims, auth_svc: web::Data<AuthService>) -> impl Responder {
|
||||
let profile = auth_svc.user_profile(auth.sub, &auth.username);
|
||||
HttpResponse::Ok().json(profile)
|
||||
}
|
||||
|
||||
async fn change_password(
|
||||
@ -211,32 +96,26 @@ async fn change_password(
|
||||
body: web::Json<ChangePasswordRequest>,
|
||||
db: web::Data<Repo>,
|
||||
) -> impl Responder {
|
||||
let claims = &*auth;
|
||||
let change_req = body.into_inner();
|
||||
|
||||
// Validate new password
|
||||
if let Err(msg) = validate_password(&change_req.new_password) {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({"error": msg}));
|
||||
}
|
||||
|
||||
// Verify current password
|
||||
let user = match db.find_user(&claims.username) {
|
||||
let user = match db.find_user(&auth.username) {
|
||||
Ok(Some(u)) => u,
|
||||
_ => {
|
||||
return HttpResponse::InternalServerError().json(serde_json::json!({"error": "User not found"}));
|
||||
}
|
||||
};
|
||||
|
||||
let (_id, _username, hash, _role, _force) = user;
|
||||
|
||||
match password::verify_password(&change_req.current_password, &hash) {
|
||||
match password::verify_password(&change_req.current_password, &user.password_hash) {
|
||||
Ok(true) => {}
|
||||
_ => {
|
||||
return HttpResponse::Unauthorized().json(serde_json::json!({"error": "Current password is incorrect"}));
|
||||
}
|
||||
}
|
||||
|
||||
// Hash and update
|
||||
let new_hash = match password::hash_password(&change_req.new_password) {
|
||||
Ok(h) => h,
|
||||
Err(_) => {
|
||||
@ -244,35 +123,34 @@ async fn change_password(
|
||||
}
|
||||
};
|
||||
|
||||
match db.update_user_password(claims.sub, &new_hash) {
|
||||
match db.update_user_password(auth.sub, &new_hash) {
|
||||
Ok(_) => HttpResponse::Ok().json(serde_json::json!({"message": "Password changed successfully"})),
|
||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
|
||||
}
|
||||
}
|
||||
|
||||
// --- User Management (admin only) ---
|
||||
|
||||
async fn list_users(_auth: AuthClaims, db: web::Data<Repo>) -> impl Responder {
|
||||
match db.list_users_with_groups() {
|
||||
Ok(users) => {
|
||||
let result: Vec<serde_json::Value> = users
|
||||
.into_iter()
|
||||
.map(|(id, username, _role, force_pw, created_at, user_groups)| {
|
||||
let groups: Vec<serde_json::Value> = user_groups
|
||||
.map(|u| {
|
||||
let groups: Vec<serde_json::Value> = u
|
||||
.groups
|
||||
.iter()
|
||||
.map(|(gid, name)| serde_json::json!({"id": gid, "name": name}))
|
||||
.map(|g| serde_json::json!({"id": g.group_id, "name": g.group_name}))
|
||||
.collect();
|
||||
let role = if user_groups.iter().any(|(_id, name)| name == GROUP_ADMIN) {
|
||||
let role = if u.groups.iter().any(|g| g.group_name == GROUP_ADMIN) {
|
||||
ROLE_ADMIN
|
||||
} else {
|
||||
ROLE_VIEWER
|
||||
};
|
||||
serde_json::json!({
|
||||
"id": id,
|
||||
"username": username,
|
||||
"id": u.id,
|
||||
"username": u.username,
|
||||
"role": role,
|
||||
"force_password_change": force_pw,
|
||||
"created_at": created_at,
|
||||
"force_password_change": u.force_password_change,
|
||||
"created_at": u.created_at,
|
||||
"groups": groups,
|
||||
})
|
||||
})
|
||||
@ -286,14 +164,12 @@ async fn list_users(_auth: AuthClaims, db: web::Data<Repo>) -> impl Responder {
|
||||
async fn delete_user(_auth: AuthClaims, path: web::Path<i64>, db: web::Data<Repo>) -> impl Responder {
|
||||
let user_id = path.into_inner();
|
||||
|
||||
// Can't delete self
|
||||
if _auth.sub == user_id {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({"error": "Cannot delete your own account"}));
|
||||
}
|
||||
|
||||
// Protect the built-in admin account
|
||||
match db.find_user_by_id(user_id) {
|
||||
Ok(Some((_, ref username, _, _, _))) if username == DEFAULT_ADMIN_USERNAME => {
|
||||
Ok(Some(ref u)) if u.username == DEFAULT_ADMIN_USERNAME => {
|
||||
return HttpResponse::Forbidden()
|
||||
.json(serde_json::json!({"error": "Cannot delete the built-in admin account"}));
|
||||
}
|
||||
@ -315,7 +191,6 @@ async fn update_role(
|
||||
) -> impl Responder {
|
||||
let user_id = path.into_inner();
|
||||
|
||||
// Can't change own role
|
||||
if _auth.sub == user_id {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({"error": "Cannot change your own role"}));
|
||||
}
|
||||
@ -327,7 +202,6 @@ async fn update_role(
|
||||
}
|
||||
};
|
||||
|
||||
// Check target user exists
|
||||
match db.find_user_by_id(user_id) {
|
||||
Ok(Some(_)) => {}
|
||||
Ok(None) => {
|
||||
@ -367,7 +241,6 @@ async fn reset_password(
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({"error": msg}));
|
||||
}
|
||||
|
||||
// Check target user exists
|
||||
match db.find_user_by_id(user_id) {
|
||||
Ok(Some(_)) => {}
|
||||
Ok(None) => {
|
||||
@ -388,27 +261,26 @@ async fn reset_password(
|
||||
ok_or_error(db.reset_user_password(user_id, &hash))
|
||||
}
|
||||
|
||||
// --- User Group Management (users:admin required) ---
|
||||
|
||||
async fn list_groups(_auth: AuthClaims, db: web::Data<Repo>) -> impl Responder {
|
||||
match db.list_user_groups() {
|
||||
Ok(groups) => {
|
||||
let result: Vec<serde_json::Value> = groups
|
||||
.into_iter()
|
||||
.map(|(id, name, description, permissions, created_at)| {
|
||||
let perms: serde_json::Value = serde_json::from_str(&permissions).unwrap_or(serde_json::json!([]));
|
||||
.map(|g| {
|
||||
let perms: serde_json::Value =
|
||||
serde_json::from_str(&g.permissions).unwrap_or(serde_json::json!([]));
|
||||
let members: Vec<serde_json::Value> = db
|
||||
.list_group_members(id)
|
||||
.list_group_members(g.id)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|(uid, username)| serde_json::json!({"id": uid, "username": username}))
|
||||
.collect();
|
||||
serde_json::json!({
|
||||
"id": id,
|
||||
"name": name,
|
||||
"description": description,
|
||||
"id": g.id,
|
||||
"name": g.name,
|
||||
"description": g.description,
|
||||
"permissions": perms,
|
||||
"created_at": created_at,
|
||||
"created_at": g.created_at,
|
||||
"members": members,
|
||||
})
|
||||
})
|
||||
@ -448,15 +320,15 @@ async fn get_group(_auth: AuthClaims, path: web::Path<i64>, db: web::Data<Repo>)
|
||||
let group_id = path.into_inner();
|
||||
|
||||
match db.get_user_group(group_id) {
|
||||
Ok(Some((id, name, description, permissions, created_at))) => {
|
||||
let perms: serde_json::Value = serde_json::from_str(&permissions).unwrap_or(serde_json::json!([]));
|
||||
Ok(Some(g)) => {
|
||||
let perms: serde_json::Value = serde_json::from_str(&g.permissions).unwrap_or(serde_json::json!([]));
|
||||
let members = db.list_group_member_ids(group_id).unwrap_or_default();
|
||||
HttpResponse::Ok().json(serde_json::json!({
|
||||
"id": id,
|
||||
"name": name,
|
||||
"description": description,
|
||||
"id": g.id,
|
||||
"name": g.name,
|
||||
"description": g.description,
|
||||
"permissions": perms,
|
||||
"created_at": created_at,
|
||||
"created_at": g.created_at,
|
||||
"members": members,
|
||||
}))
|
||||
}
|
||||
@ -473,11 +345,9 @@ async fn update_group(
|
||||
) -> impl Responder {
|
||||
let group_id = path.into_inner();
|
||||
|
||||
// Check group exists
|
||||
let existing = match db.get_user_group(group_id) {
|
||||
Ok(Some(g)) => {
|
||||
// Protect built-in groups
|
||||
if g.1 == GROUP_ADMIN || g.1 == GROUP_VIEWER {
|
||||
if g.name == GROUP_ADMIN || g.name == GROUP_VIEWER {
|
||||
return HttpResponse::Forbidden().json(serde_json::json!({"error": "Cannot modify built-in groups"}));
|
||||
}
|
||||
g
|
||||
@ -490,11 +360,14 @@ async fn update_group(
|
||||
}
|
||||
};
|
||||
|
||||
let name = body.get("name").and_then(|v| v.as_str()).unwrap_or(&existing.1);
|
||||
let description = body.get("description").and_then(|v| v.as_str()).unwrap_or(&existing.2);
|
||||
let name = body.get("name").and_then(|v| v.as_str()).unwrap_or(&existing.name);
|
||||
let description = body
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(&existing.description);
|
||||
let permissions = match body.get("permissions") {
|
||||
Some(p) if p.is_array() => p.to_string(),
|
||||
_ => existing.3.clone(),
|
||||
_ => existing.permissions.clone(),
|
||||
};
|
||||
|
||||
match db.update_user_group(group_id, name, description, &permissions) {
|
||||
@ -511,9 +384,8 @@ async fn update_group(
|
||||
async fn delete_group(_auth: AuthClaims, path: web::Path<i64>, db: web::Data<Repo>) -> impl Responder {
|
||||
let group_id = path.into_inner();
|
||||
|
||||
// Protect built-in groups
|
||||
match db.get_user_group(group_id) {
|
||||
Ok(Some(g)) if g.1 == GROUP_ADMIN || g.1 == GROUP_VIEWER => {
|
||||
Ok(Some(ref g)) if g.name == GROUP_ADMIN || g.name == GROUP_VIEWER => {
|
||||
return HttpResponse::Forbidden().json(serde_json::json!({"error": "Cannot delete built-in groups"}));
|
||||
}
|
||||
_ => {}
|
||||
@ -534,9 +406,8 @@ async fn set_user_groups(
|
||||
) -> impl Responder {
|
||||
let user_id = path.into_inner();
|
||||
|
||||
// Protect the default admin account
|
||||
match db.find_user_by_id(user_id) {
|
||||
Ok(Some((_, ref username, _, _, _))) if username == DEFAULT_ADMIN_USERNAME => {
|
||||
Ok(Some(ref u)) if u.username == DEFAULT_ADMIN_USERNAME => {
|
||||
return HttpResponse::Forbidden()
|
||||
.json(serde_json::json!({"error": "Cannot modify groups for the built-in admin account"}));
|
||||
}
|
||||
@ -565,52 +436,12 @@ async fn set_user_groups(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_validate_username_valid() {
|
||||
assert!(validate_username("admin").is_ok());
|
||||
assert!(validate_username("user_123").is_ok());
|
||||
assert!(validate_username("a").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_username_empty() {
|
||||
assert!(validate_username("").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_username_too_long() {
|
||||
let long = "a".repeat(33);
|
||||
assert!(validate_username(&long).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_username_special_chars() {
|
||||
assert!(validate_username("admin@host").is_err());
|
||||
assert!(validate_username("user name").is_err());
|
||||
assert!(validate_username("user-name").is_err());
|
||||
assert!(validate_username("用戶").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_password_valid() {
|
||||
assert!(validate_password("12345678").is_ok());
|
||||
assert!(validate_password("a very long password").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_password_too_short() {
|
||||
assert!(validate_password("").is_err());
|
||||
assert!(validate_password("1234567").is_err());
|
||||
assert!(validate_password("a").is_err());
|
||||
}
|
||||
use crate::domain::identity::validation::{validate_password, validate_username};
|
||||
|
||||
#[test]
|
||||
fn test_dummy_hash_is_valid_argon2() {
|
||||
use crate::core::identity::auth_service::DUMMY_HASH;
|
||||
use argon2::password_hash::PasswordHash;
|
||||
// DUMMY_HASH must be parseable as a valid Argon2 hash structure
|
||||
// so that timing-based username enumeration is prevented
|
||||
let parsed = PasswordHash::new(DUMMY_HASH);
|
||||
assert!(
|
||||
parsed.is_ok(),
|
||||
@ -618,4 +449,15 @@ mod tests {
|
||||
parsed.err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_username_valid() {
|
||||
assert!(validate_username("admin").is_ok());
|
||||
assert!(validate_username("user_123").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_password_valid() {
|
||||
assert!(validate_password("12345678").is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -7,15 +7,14 @@
|
||||
//! analysts can answer "why was this IP blocked?" without parsing
|
||||
//! logs by hand.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::{HttpRequest, HttpResponse, Responder, Scope, web};
|
||||
use arc_swap::ArcSwap;
|
||||
|
||||
use crate::core::detection::metrics::FusionMetrics;
|
||||
use crate::domain::common::audit::AuditLogEntry;
|
||||
use crate::domain::common::config::AppConfig;
|
||||
use crate::domain::common::config::constants::FUSION_AUDIT_ACTION;
|
||||
use crate::domain::detection::metrics::FusionMetrics;
|
||||
use crate::interface::port::audit::{AuditLogEntry, AuditRepo};
|
||||
use crate::interface::port::audit::AuditRepo;
|
||||
|
||||
pub fn initialize() -> Scope {
|
||||
web::scope("/fusion")
|
||||
@ -37,7 +36,7 @@ async fn get_metrics(metrics: web::Data<FusionMetrics>) -> impl Responder {
|
||||
async fn explain_ip(
|
||||
req: HttpRequest,
|
||||
audit: web::Data<dyn AuditRepo>,
|
||||
app_config: web::Data<Arc<ArcSwap<AppConfig>>>,
|
||||
app_config: web::Data<ArcSwap<AppConfig>>,
|
||||
) -> impl Responder {
|
||||
let src_ip = match req.match_info().get("src_ip") {
|
||||
Some(ip) => ip.to_string(),
|
||||
|
||||
@ -1,15 +1,14 @@
|
||||
use std::fs;
|
||||
use std::io::ErrorKind;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
use actix_web::{HttpResponse, Scope, web};
|
||||
use arc_swap::ArcSwap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::core::common::log_buffer::{self, LogBuffer, LogEntry};
|
||||
use crate::domain::common::config::AppConfig;
|
||||
use crate::infrastructure::log_buffer::{self, LogBuffer, LogEntry};
|
||||
|
||||
/// Hardcoded log directory — not configurable via API to prevent directory traversal.
|
||||
const LOG_DIR: &str = "logs";
|
||||
@ -51,7 +50,7 @@ struct LiveResponse {
|
||||
|
||||
async fn live_logs(
|
||||
query: web::Query<LiveQuery>,
|
||||
app_config: web::Data<Arc<ArcSwap<AppConfig>>>,
|
||||
app_config: web::Data<ArcSwap<AppConfig>>,
|
||||
buf: web::Data<LogBuffer>,
|
||||
) -> HttpResponse {
|
||||
let since_id = query.since_id.unwrap_or(0);
|
||||
@ -117,7 +116,7 @@ async fn list_logs() -> HttpResponse {
|
||||
HttpResponse::Ok().json(serde_json::json!({ "files": entries }))
|
||||
}
|
||||
|
||||
async fn download_log(path: web::Path<String>, app_config: web::Data<Arc<ArcSwap<AppConfig>>>) -> HttpResponse {
|
||||
async fn download_log(path: web::Path<String>, app_config: web::Data<ArcSwap<AppConfig>>) -> HttpResponse {
|
||||
let max_download_size = app_config.load().observability.log_max_download_size;
|
||||
let filename = path.into_inner();
|
||||
|
||||
|
||||
@ -9,7 +9,7 @@ use actix_web::http::Method;
|
||||
use actix_web::{Error as ActixError, HttpMessage, HttpResponse, web};
|
||||
use macros::log;
|
||||
|
||||
use crate::core::identity::jwt::JwtService;
|
||||
use crate::adapter::http::middleware::jwt::JwtService;
|
||||
use crate::domain::identity::error::AuthError;
|
||||
use crate::interface::port::api_key::ApiKeyRepo;
|
||||
use crate::interface::port::app_repo::AppRepo;
|
||||
@ -40,9 +40,8 @@ pub struct AuthMiddlewareService<S> {
|
||||
|
||||
fn required_permission(path: &str, method: &Method) -> Option<String> {
|
||||
let resource = if path == "/api/auth/login" || path == "/api/auth/me" || path == "/api/auth/change-password" {
|
||||
return None; // Public auth endpoints: login (no auth), me/change-password (auth-only, no RBAC)
|
||||
return None;
|
||||
} else if path.starts_with("/api/auth/") {
|
||||
// User/group management requires users:admin
|
||||
return Some("users:admin".to_string());
|
||||
} else if path.starts_with("/api/health/") || path.starts_with("/api/stats/") {
|
||||
"dashboard"
|
||||
@ -65,7 +64,6 @@ fn required_permission(path: &str, method: &Method) -> Option<String> {
|
||||
} else if path.starts_with("/api/system/") {
|
||||
"system"
|
||||
} else if path.contains("/soar/blocks/") && path.ends_with("/unblock") {
|
||||
// manual_unblock needs access_control:write (always POST)
|
||||
return Some("access_control:write".to_string());
|
||||
} else if path.starts_with("/api/soar/")
|
||||
|| path.starts_with("/api/notifications/")
|
||||
@ -2,8 +2,7 @@ use std::future::{Future, Ready, ready};
|
||||
use std::net::IpAddr;
|
||||
use std::pin::Pin;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
use actix_web::body::EitherBody;
|
||||
@ -11,8 +10,7 @@ use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform};
|
||||
use actix_web::http::header;
|
||||
use actix_web::{Error as ActixError, HttpResponse, web};
|
||||
|
||||
/// Shared flag: when true, non-HTTPS requests are redirected.
|
||||
pub type ForceHttpsFlag = Arc<AtomicBool>;
|
||||
use crate::infrastructure::http_server::ForceHttpsFlag;
|
||||
|
||||
/// Validate that the host is safe to use in a redirect Location header.
|
||||
/// Only allows: private IPs (RFC 1918), loopback, .local hostnames, and bare hostnames
|
||||
@ -100,7 +98,7 @@ where
|
||||
// Check if force_https is enabled
|
||||
let force = req
|
||||
.app_data::<web::Data<ForceHttpsFlag>>()
|
||||
.map(|flag| flag.load(Ordering::Relaxed))
|
||||
.map(|flag| flag.0.load(Ordering::Relaxed))
|
||||
.unwrap_or(false);
|
||||
|
||||
if !force {
|
||||
6
net-guardia/src/adapter/http/middleware/mod.rs
Normal file
6
net-guardia/src/adapter/http/middleware/mod.rs
Normal file
@ -0,0 +1,6 @@
|
||||
pub mod auth;
|
||||
pub mod csrf;
|
||||
pub mod extractor;
|
||||
pub mod https_redirect;
|
||||
pub mod jwt;
|
||||
pub mod setup_guard;
|
||||
@ -1,17 +1,14 @@
|
||||
use std::future::{Future, Ready, ready};
|
||||
use std::pin::Pin;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
use actix_web::body::EitherBody;
|
||||
use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform};
|
||||
use actix_web::{Error as ActixError, HttpResponse, web};
|
||||
|
||||
/// Shared flag indicating whether setup has completed.
|
||||
/// When false, only setup wizard routes are allowed; all others get 503.
|
||||
pub type SetupCompleteFlag = Arc<AtomicBool>;
|
||||
use crate::infrastructure::http_server::SetupCompleteFlag;
|
||||
|
||||
pub struct SetupGuard;
|
||||
|
||||
@ -59,8 +56,8 @@ where
|
||||
// Check setup_complete flag from app data
|
||||
let setup_complete = req
|
||||
.app_data::<web::Data<SetupCompleteFlag>>()
|
||||
.map(|flag| flag.load(Ordering::SeqCst))
|
||||
.unwrap_or(true); // Default to true if flag not found
|
||||
.map(|flag| flag.0.load(Ordering::SeqCst))
|
||||
.unwrap_or(true);
|
||||
|
||||
if setup_complete {
|
||||
// Normal mode: pass through, but block setup mutation endpoints.
|
||||
@ -1,12 +1,12 @@
|
||||
use actix_web::{HttpResponse, Responder, Scope, web};
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use crate::core::identity::extractor::AuthClaims;
|
||||
use crate::adapter::http::middleware::extractor::AuthClaims;
|
||||
use crate::core::inference::engine::Engine;
|
||||
use crate::core::inference::model_adapter::ModelSourceState;
|
||||
use crate::core::inference::runner::Inference;
|
||||
use crate::domain::common::config::constants::AUDIT_ACTOR_SECURITY_ADMIN_PREFIX;
|
||||
use crate::domain::common::event::AuditEvent;
|
||||
use crate::domain::detection::model_adapter::ModelSourceState;
|
||||
|
||||
/// Permission required to forcibly revert the active ML source to dormant.
|
||||
/// Mirrors the upload handler's gate so swap-out and revert are symmetric:
|
||||
@ -44,7 +44,7 @@ async fn get_status(engine: web::Data<Engine>) -> impl Responder {
|
||||
/// `GET /api/ml/models/current` — wire-format snapshot of the ML source
|
||||
/// state the dashboard's ML Status panel renders.
|
||||
async fn get_current_model(inference: web::Data<Inference>) -> impl Responder {
|
||||
let status = inference.current_status();
|
||||
let status = inference.model_source_status();
|
||||
let label = if status.is_active() {
|
||||
"active"
|
||||
} else if status.is_dormant() {
|
||||
@ -74,7 +74,7 @@ async fn delete_current_model(
|
||||
}));
|
||||
}
|
||||
|
||||
let before_status = inference.current_status();
|
||||
let before_status = inference.model_source_status();
|
||||
if before_status.is_dormant() {
|
||||
return HttpResponse::Ok().json(serde_json::json!({
|
||||
"already_dormant": true,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -15,13 +15,12 @@
|
||||
//! down on any error path so failed uploads don't pile up in
|
||||
//! `models/.staging/`.
|
||||
|
||||
use std::fs as std_fs;
|
||||
use std::fs::File as StdFile;
|
||||
use std::io;
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Duration, SystemTime};
|
||||
use std::time::Duration;
|
||||
|
||||
use actix_multipart::Multipart;
|
||||
use actix_web::{HttpResponse, Responder, Scope, web};
|
||||
@ -35,7 +34,7 @@ use tokio::sync::broadcast;
|
||||
use tokio::task;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::core::identity::extractor::AuthClaims;
|
||||
use crate::adapter::http::middleware::extractor::AuthClaims;
|
||||
use crate::core::inference::model_loader::build_adapter;
|
||||
use crate::core::inference::runner::Inference;
|
||||
use crate::domain::common::config::AppConfig;
|
||||
@ -583,7 +582,7 @@ async fn validate_and_promote(ctx: &PromoteContext<'_>) -> Result<PromoteReport,
|
||||
.await
|
||||
.map_err(|e| PromoteError::StagingIo(format!("sha256 onnx: {e}")))?;
|
||||
|
||||
let before_status = inference.current_status();
|
||||
let before_status = inference.model_source_status();
|
||||
|
||||
let _guard = promote_lock.try_acquire().ok_or(PromoteError::ConcurrentPromote)?;
|
||||
let models_dir = PathBuf::from(MODELS_DIR);
|
||||
@ -705,36 +704,12 @@ async fn sha256_file(path: &Path) -> io::Result<String> {
|
||||
.unwrap_or_else(|e| Err(io::Error::other(format!("sha256 join: {e}"))))
|
||||
}
|
||||
|
||||
/// Remove staging subdirectories older than `max_age`. Runs on startup
|
||||
/// and on a periodic timer so failed uploads don't accumulate.
|
||||
pub fn clean_staging_orphans(staging_root: &Path, max_age: Duration) -> io::Result<usize> {
|
||||
if !staging_root.exists() {
|
||||
return Ok(0);
|
||||
}
|
||||
let now = SystemTime::now();
|
||||
let mut cleaned = 0usize;
|
||||
for entry in std_fs::read_dir(staging_root)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let metadata = entry.metadata()?;
|
||||
let mtime = metadata.modified()?;
|
||||
let age = now.duration_since(mtime).unwrap_or_default();
|
||||
if age >= max_age {
|
||||
std_fs::remove_dir_all(&path)?;
|
||||
cleaned += 1;
|
||||
}
|
||||
}
|
||||
Ok(cleaned)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::time::Duration;
|
||||
|
||||
use super::*;
|
||||
use crate::utils::staging::clean_staging_orphans;
|
||||
|
||||
#[test]
|
||||
fn onnx_sniff_rejects_empty() {
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
use actix_web::{HttpResponse, Scope, web};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::adapter::http::middleware::extractor::AuthClaims;
|
||||
use crate::adapter::http::response::{ok_json_or_error, ok_or_error};
|
||||
use crate::core::common::notification_service::NotificationService;
|
||||
use crate::core::identity::extractor::AuthClaims;
|
||||
|
||||
pub fn initialize() -> Scope {
|
||||
web::scope("/notifications")
|
||||
|
||||
23
net-guardia/src/adapter/http/ready.rs
Normal file
23
net-guardia/src/adapter/http/ready.rs
Normal file
@ -0,0 +1,23 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::Ordering::SeqCst;
|
||||
|
||||
use actix_web::{HttpResponse, web};
|
||||
|
||||
use crate::domain::common::system::readiness::ReadinessState;
|
||||
|
||||
pub async fn health_ready(ready: web::Data<Arc<AtomicBool>>, state: web::Data<ReadinessState>) -> HttpResponse {
|
||||
let is_ready = ready.load(SeqCst);
|
||||
let uptime_secs = state.started_at.elapsed().as_secs();
|
||||
|
||||
HttpResponse::Ok().json(serde_json::json!({
|
||||
"ready": is_ready,
|
||||
"subsystems": {
|
||||
"db_connected": state.db_connected.load(SeqCst),
|
||||
"ml_model_loaded": state.ml_model_loaded.load(SeqCst),
|
||||
"soar_engine_running": state.soar_engine_running.load(SeqCst),
|
||||
"ebpf_attached": state.ebpf_attached.load(SeqCst),
|
||||
},
|
||||
"uptime_secs": uptime_secs,
|
||||
}))
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -8,11 +8,11 @@ use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::adapter::persistence::Database;
|
||||
use crate::core::identity::setup_guard::SetupCompleteFlag;
|
||||
use crate::domain::common::error::Error;
|
||||
use crate::domain::common::error::system::SystemError;
|
||||
use crate::domain::identity::auth::DEFAULT_ADMIN_USERNAME;
|
||||
use crate::domain::identity::password;
|
||||
use crate::infrastructure::http_server::SetupCompleteFlag;
|
||||
use crate::infrastructure::secret_store::SecretStore;
|
||||
use crate::interface::port::secret_store::SecretStorePort;
|
||||
|
||||
@ -24,7 +24,7 @@ pub fn initialize() -> Scope {
|
||||
}
|
||||
|
||||
async fn setup_status(setup_flag: web::Data<SetupCompleteFlag>) -> HttpResponse {
|
||||
let complete = setup_flag.load(Ordering::SeqCst);
|
||||
let complete = setup_flag.0.load(Ordering::SeqCst);
|
||||
HttpResponse::Ok().json(serde_json::json!({
|
||||
"setup_complete": complete,
|
||||
}))
|
||||
@ -88,8 +88,7 @@ async fn complete_setup(
|
||||
setup_flag: web::Data<SetupCompleteFlag>,
|
||||
body: web::Json<SetupRequest>,
|
||||
) -> HttpResponse {
|
||||
// Check if already completed (concurrent access protection)
|
||||
if setup_flag.load(Ordering::SeqCst) {
|
||||
if setup_flag.0.load(Ordering::SeqCst) {
|
||||
return HttpResponse::Conflict().json(serde_json::json!({
|
||||
"error": "Setup already completed"
|
||||
}));
|
||||
@ -145,11 +144,11 @@ async fn complete_setup(
|
||||
Ok(hash) => {
|
||||
// Find admin user and update password
|
||||
if let Ok(Some(user)) = db.find_user(DEFAULT_ADMIN_USERNAME) {
|
||||
if let Err(e) = db.update_user_password(user.0, &hash) {
|
||||
if let Err(e) = db.update_user_password(user.id, &hash) {
|
||||
log!(SystemError::SetupPasswordUpdateFailed(e));
|
||||
}
|
||||
// Clear force_password_change since setup wizard set the password
|
||||
if let Err(e) = db.reset_user_password(user.0, &hash) {
|
||||
if let Err(e) = db.reset_user_password(user.id, &hash) {
|
||||
log!(SystemError::SetupPasswordUpdateFailed(e));
|
||||
}
|
||||
}
|
||||
@ -165,7 +164,7 @@ async fn complete_setup(
|
||||
if let Err(e) = db.set_setting("setup_complete", "true") {
|
||||
log!(SystemError::SetupCompleteFlagFailed(e));
|
||||
}
|
||||
setup_flag.store(true, Ordering::SeqCst);
|
||||
setup_flag.0.store(true, Ordering::SeqCst);
|
||||
|
||||
// System::run() polls the setup_complete flag and will automatically
|
||||
// start eBPF, ML, and SOAR services once this flag becomes true.
|
||||
|
||||
@ -4,8 +4,8 @@ use actix_web::{HttpResponse, Scope, web};
|
||||
use arc_swap::ArcSwap;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::adapter::http::middleware::extractor::AuthClaims;
|
||||
use crate::adapter::http::response::{ok_json_or_error, ok_or_error};
|
||||
use crate::core::identity::extractor::AuthClaims;
|
||||
use crate::core::response::engine::SoarEngine;
|
||||
use crate::core::response::playbook_service::PlaybookService;
|
||||
use crate::domain::common::config::AppConfig;
|
||||
|
||||
@ -1,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 {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
pub mod access_control;
|
||||
pub mod ebpf;
|
||||
pub mod http;
|
||||
pub mod model_loading;
|
||||
pub mod notification;
|
||||
pub mod persistence;
|
||||
pub mod telegram;
|
||||
pub mod websocket;
|
||||
|
||||
2
net-guardia/src/adapter/model_loading/mod.rs
Normal file
2
net-guardia/src/adapter/model_loading/mod.rs
Normal file
@ -0,0 +1,2 @@
|
||||
pub mod config_loader;
|
||||
pub mod manifest;
|
||||
1
net-guardia/src/adapter/notification/mod.rs
Normal file
1
net-guardia/src/adapter/notification/mod.rs
Normal file
@ -0,0 +1 @@
|
||||
pub mod smtp;
|
||||
86
net-guardia/src/adapter/notification/smtp.rs
Normal file
86
net-guardia/src/adapter/notification/smtp.rs
Normal file
@ -0,0 +1,86 @@
|
||||
use lettre::message::header::ContentType;
|
||||
use lettre::transport::smtp::authentication::Credentials;
|
||||
use lettre::{Message, SmtpTransport, Transport};
|
||||
|
||||
use crate::domain::common::config::notification::SmtpConfig;
|
||||
use crate::domain::common::error::Error;
|
||||
use crate::domain::common::error::notification::NotificationError;
|
||||
use crate::interface::port::secret_store::SecretStorePort;
|
||||
|
||||
pub struct SmtpClient {
|
||||
host: String,
|
||||
port: u16,
|
||||
username: String,
|
||||
password: String,
|
||||
sender: String,
|
||||
}
|
||||
|
||||
impl SmtpClient {
|
||||
pub fn from_config(cfg: &SmtpConfig, secrets: Option<&dyn SecretStorePort>) -> Result<Option<Self>, Error> {
|
||||
if cfg.host.is_empty() || cfg.username.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let password = match secrets.and_then(|ss| ss.get_secret("smtp_password").ok().flatten()) {
|
||||
Some(pw) if !pw.is_empty() => pw,
|
||||
_ => return Ok(None),
|
||||
};
|
||||
|
||||
let sender = if cfg.sender.is_empty() {
|
||||
cfg.username.clone()
|
||||
} else {
|
||||
cfg.sender.clone()
|
||||
};
|
||||
|
||||
if !sender.contains('@') {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(Some(Self {
|
||||
host: cfg.host.clone(),
|
||||
port: cfg.port,
|
||||
username: cfg.username.clone(),
|
||||
password,
|
||||
sender,
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn send(&self, to: &str, subject: &str, html_body: &str) -> Result<(), Error> {
|
||||
let from_addr = self
|
||||
.sender
|
||||
.parse()
|
||||
.map_err(|e| NotificationError::InvalidAddress("from", e))?;
|
||||
let to_addr = to.parse().map_err(|e| NotificationError::InvalidAddress("to", e))?;
|
||||
|
||||
let email = Message::builder()
|
||||
.from(from_addr)
|
||||
.to(to_addr)
|
||||
.subject(subject)
|
||||
.header(ContentType::TEXT_HTML)
|
||||
.body(html_body.to_string())
|
||||
.map_err(NotificationError::MessageBuildFailed)?;
|
||||
|
||||
let creds = Credentials::new(self.username.clone(), self.password.clone());
|
||||
|
||||
let mailer = match self.port {
|
||||
465 => SmtpTransport::relay(&self.host)
|
||||
.map_err(NotificationError::SmtpConnectionFailed)?
|
||||
.port(self.port)
|
||||
.credentials(creds)
|
||||
.build(),
|
||||
25 | 587 => SmtpTransport::starttls_relay(&self.host)
|
||||
.map_err(NotificationError::SmtpConnectionFailed)?
|
||||
.port(self.port)
|
||||
.credentials(creds)
|
||||
.build(),
|
||||
_ => SmtpTransport::builder_dangerous(&self.host)
|
||||
.port(self.port)
|
||||
.credentials(creds)
|
||||
.build(),
|
||||
};
|
||||
|
||||
mailer.send(&email).map_err(NotificationError::SmtpSendFailed)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@ -2,7 +2,8 @@ use rusqlite::params;
|
||||
|
||||
use super::Database;
|
||||
use crate::domain::common::error::Error;
|
||||
use crate::interface::port::acl::{AclRepo, AclRuleTuple};
|
||||
use crate::domain::data_plane::acl_rule::AclRuleView;
|
||||
use crate::interface::port::acl::AclRepo;
|
||||
|
||||
impl Database {
|
||||
pub fn insert_acl_rule(
|
||||
@ -37,17 +38,17 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_acl_rules(&self) -> Result<Vec<AclRuleTuple>, Error> {
|
||||
pub fn list_acl_rules(&self) -> Result<Vec<AclRuleView>, Error> {
|
||||
let conn = self.conn()?;
|
||||
let mut stmt = conn.prepare("SELECT ip_version, direction, list_type, ip_address, port FROM acl_rules")?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, u8>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
row.get::<_, i64>(4)? as u16,
|
||||
))
|
||||
Ok(AclRuleView {
|
||||
ip_version: row.get(0)?,
|
||||
direction: row.get(1)?,
|
||||
list_type: row.get(2)?,
|
||||
ip_address: row.get(3)?,
|
||||
port: row.get::<_, i64>(4)? as u16,
|
||||
})
|
||||
})?;
|
||||
let mut results = Vec::new();
|
||||
for row in rows {
|
||||
@ -146,16 +147,11 @@ mod tests {
|
||||
db.insert_acl_rule(4, "source", "blacklist", "192.168.1.1", 80).unwrap();
|
||||
let rules = db.list_acl_rules().unwrap();
|
||||
assert_eq!(rules.len(), 1);
|
||||
assert_eq!(
|
||||
rules[0],
|
||||
(
|
||||
4,
|
||||
"source".to_string(),
|
||||
"blacklist".to_string(),
|
||||
"192.168.1.1".to_string(),
|
||||
80
|
||||
)
|
||||
);
|
||||
assert_eq!(rules[0].ip_version, 4);
|
||||
assert_eq!(rules[0].direction, "source");
|
||||
assert_eq!(rules[0].list_type, "blacklist");
|
||||
assert_eq!(rules[0].ip_address, "192.168.1.1");
|
||||
assert_eq!(rules[0].port, 80);
|
||||
|
||||
db.delete_acl_rule(4, "source", "blacklist", "192.168.1.1", 80).unwrap();
|
||||
let rules = db.list_acl_rules().unwrap();
|
||||
|
||||
@ -6,8 +6,9 @@ use sha2::Sha256;
|
||||
|
||||
use super::Database;
|
||||
use crate::domain::common::error::Error;
|
||||
use crate::domain::identity::auth::Claims;
|
||||
use crate::interface::port::api_key::{ApiKeyListItem, ApiKeyRepo};
|
||||
use crate::domain::identity::auth::{API_KEY_READ_ONLY_PERMISSIONS, API_KEY_READ_WRITE_PERMISSIONS, Claims};
|
||||
use crate::domain::identity::user::ApiKeyView;
|
||||
use crate::interface::port::api_key::ApiKeyRepo;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
@ -55,34 +56,11 @@ impl Database {
|
||||
);
|
||||
|
||||
// Build permissions based on permission level
|
||||
let permissions = match level.as_str() {
|
||||
"read_write" | "full_access" => vec![
|
||||
"dashboard:read".into(),
|
||||
"statistics:read".into(),
|
||||
"ai_detection:read".into(),
|
||||
"ai_detection:write".into(),
|
||||
"access_control:read".into(),
|
||||
"access_control:write".into(),
|
||||
"geo_block:read".into(),
|
||||
"geo_block:write".into(),
|
||||
"dns_filter:read".into(),
|
||||
"dns_filter:write".into(),
|
||||
"rate_limit:read".into(),
|
||||
"rate_limit:write".into(),
|
||||
"system:read".into(),
|
||||
"system:write".into(),
|
||||
],
|
||||
_ => vec![
|
||||
"dashboard:read".into(),
|
||||
"statistics:read".into(),
|
||||
"ai_detection:read".into(),
|
||||
"access_control:read".into(),
|
||||
"geo_block:read".into(),
|
||||
"dns_filter:read".into(),
|
||||
"rate_limit:read".into(),
|
||||
"system:read".into(),
|
||||
],
|
||||
let source = match level.as_str() {
|
||||
"read_write" | "full_access" => API_KEY_READ_WRITE_PERMISSIONS,
|
||||
_ => API_KEY_READ_ONLY_PERMISSIONS,
|
||||
};
|
||||
let permissions: Vec<String> = source.iter().map(|s| (*s).to_string()).collect();
|
||||
|
||||
Ok(Some(Claims {
|
||||
sub: -id, // negative ID to distinguish from user IDs
|
||||
@ -106,17 +84,17 @@ impl Database {
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
pub fn list_api_keys(&self) -> Result<Vec<ApiKeyListItem>, Error> {
|
||||
pub fn list_api_keys(&self) -> Result<Vec<ApiKeyView>, Error> {
|
||||
let conn = self.conn()?;
|
||||
let mut stmt = conn.prepare("SELECT id, name, permission_level, created_at, last_used_at FROM api_keys")?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
row.get::<_, Option<String>>(4)?,
|
||||
))
|
||||
Ok(ApiKeyView {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
permission_level: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
last_used_at: row.get(4)?,
|
||||
})
|
||||
})?;
|
||||
let mut result = Vec::new();
|
||||
for row in rows {
|
||||
@ -145,7 +123,7 @@ impl ApiKeyRepo for Database {
|
||||
self.insert_api_key(key_hash, name, permission_level)
|
||||
}
|
||||
|
||||
fn list_api_keys(&self) -> Result<Vec<ApiKeyListItem>, Error> {
|
||||
fn list_api_keys(&self) -> Result<Vec<ApiKeyView>, Error> {
|
||||
self.list_api_keys()
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -18,7 +18,19 @@ use rusqlite::{self, Connection, params};
|
||||
use crate::domain::common::error::Error;
|
||||
use crate::domain::common::error::database::DatabaseError;
|
||||
use crate::domain::common::log::misc::MiscLog;
|
||||
use crate::domain::identity::auth::{GROUP_ADMIN, GROUP_VIEWER};
|
||||
|
||||
impl From<rusqlite::Error> for DatabaseError {
|
||||
fn from(e: rusqlite::Error) -> Self {
|
||||
DatabaseError::QueryFailed(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<rusqlite::Error> for Error {
|
||||
fn from(e: rusqlite::Error) -> Self {
|
||||
Self::Database(DatabaseError::from(e))
|
||||
}
|
||||
}
|
||||
use crate::domain::identity::auth::{ADMIN_PERMISSIONS, GROUP_ADMIN, GROUP_VIEWER, VIEWER_PERMISSIONS};
|
||||
|
||||
/// Reads the SQLCipher encryption key from the environment variable `NETGUARDIA_DB_KEY`.
|
||||
/// Returns `Some(key)` if set and non-empty, `None` otherwise (dev / unencrypted mode).
|
||||
@ -334,51 +346,8 @@ impl Database {
|
||||
// Seed default user groups on first install (empty table)
|
||||
let group_count: i64 = conn_ref.query_row("SELECT COUNT(*) FROM user_groups", [], |row| row.get(0))?;
|
||||
if group_count == 0 {
|
||||
let all_permissions = serde_json::json!([
|
||||
"dashboard:read",
|
||||
"statistics:read",
|
||||
"traffic_map:read",
|
||||
"drops:read",
|
||||
"ai_detection:read",
|
||||
"ai_detection:write",
|
||||
"access_control:read",
|
||||
"access_control:write",
|
||||
"geo_block:read",
|
||||
"geo_block:write",
|
||||
"dns_filter:read",
|
||||
"dns_filter:write",
|
||||
"rate_limit:read",
|
||||
"rate_limit:write",
|
||||
"protocol_filter:read",
|
||||
"protocol_filter:write",
|
||||
"system:read",
|
||||
"system:write",
|
||||
"system:admin",
|
||||
"users:read",
|
||||
"users:write",
|
||||
"users:admin",
|
||||
"fusion:read",
|
||||
"fusion:write",
|
||||
"flow_trace:read",
|
||||
"flow_trace:write"
|
||||
])
|
||||
.to_string();
|
||||
let viewer_permissions = serde_json::json!([
|
||||
"dashboard:read",
|
||||
"statistics:read",
|
||||
"traffic_map:read",
|
||||
"drops:read",
|
||||
"ai_detection:read",
|
||||
"access_control:read",
|
||||
"geo_block:read",
|
||||
"dns_filter:read",
|
||||
"rate_limit:read",
|
||||
"protocol_filter:read",
|
||||
"system:read",
|
||||
"fusion:read",
|
||||
"flow_trace:read"
|
||||
])
|
||||
.to_string();
|
||||
let all_permissions = serde_json::to_string(&ADMIN_PERMISSIONS).unwrap_or_else(|_| "[]".to_string());
|
||||
let viewer_permissions = serde_json::to_string(&VIEWER_PERMISSIONS).unwrap_or_else(|_| "[]".to_string());
|
||||
|
||||
conn_ref.execute(
|
||||
"INSERT INTO user_groups (name, description, permissions) VALUES (?1, ?2, ?3)",
|
||||
|
||||
@ -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(())
|
||||
}
|
||||
|
||||
@ -6,22 +6,23 @@ use rusqlite::{Error as RusqliteError, params};
|
||||
use super::Database;
|
||||
use crate::domain::common::error::Error;
|
||||
use crate::domain::common::error::database::DatabaseError;
|
||||
use crate::interface::port::identity::{IdentityRepo, UserGroupTuple, UserTuple, UserWithGroups};
|
||||
use crate::domain::identity::auth::{LOGIN_LOCKOUT_SECS, LOGIN_MAX_FAILURES};
|
||||
use crate::domain::identity::user::{UserGroupMembership, UserGroupView, UserView, UserWithGroupsView};
|
||||
use crate::interface::port::identity::IdentityRepo;
|
||||
|
||||
impl Database {
|
||||
pub fn find_user(&self, username: &str) -> Result<Option<UserTuple>, Error> {
|
||||
pub fn find_user(&self, username: &str) -> Result<Option<UserView>, Error> {
|
||||
let conn = self.conn()?;
|
||||
let result = conn.query_row(
|
||||
"SELECT id, username, password_hash, role, force_password_change FROM users WHERE username = ?1",
|
||||
"SELECT id, username, password_hash, force_password_change FROM users WHERE username = ?1",
|
||||
params![username],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get(0)?,
|
||||
row.get(1)?,
|
||||
row.get(2)?,
|
||||
row.get(3)?,
|
||||
row.get::<_, i64>(4)? != 0,
|
||||
))
|
||||
Ok(UserView {
|
||||
id: row.get(0)?,
|
||||
username: row.get(1)?,
|
||||
password_hash: row.get(2)?,
|
||||
force_password_change: row.get::<_, i64>(3)? != 0,
|
||||
})
|
||||
},
|
||||
);
|
||||
match result {
|
||||
@ -67,10 +68,10 @@ impl Database {
|
||||
Ok(conn.query_row("SELECT COUNT(*) FROM users", [], |row| row.get(0))?)
|
||||
}
|
||||
|
||||
pub fn list_users_with_groups(&self) -> Result<Vec<UserWithGroups>, Error> {
|
||||
pub fn list_users_with_groups(&self) -> Result<Vec<UserWithGroupsView>, Error> {
|
||||
let conn = self.conn()?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT u.id, u.username, u.role, u.force_password_change, u.created_at, \
|
||||
"SELECT u.id, u.username, u.force_password_change, u.created_at, \
|
||||
g.id, g.name \
|
||||
FROM users u \
|
||||
LEFT JOIN user_group_members m ON u.id = m.user_id \
|
||||
@ -81,25 +82,33 @@ impl Database {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, i64>(3)? != 0,
|
||||
row.get::<_, String>(4)?,
|
||||
row.get::<_, Option<i64>>(5)?,
|
||||
row.get::<_, Option<String>>(6)?,
|
||||
row.get::<_, i64>(2)? != 0,
|
||||
row.get::<_, String>(3)?,
|
||||
row.get::<_, Option<i64>>(4)?,
|
||||
row.get::<_, Option<String>>(5)?,
|
||||
))
|
||||
})?;
|
||||
|
||||
let mut user_map: HashMap<i64, UserWithGroups> = HashMap::new();
|
||||
let mut user_map: HashMap<i64, UserWithGroupsView> = HashMap::new();
|
||||
let mut order: Vec<i64> = Vec::new();
|
||||
|
||||
for row in rows {
|
||||
let (id, username, role, force_pw, created_at, group_id, group_name) = row?;
|
||||
let (id, username, force_pw, created_at, group_id, group_name) = row?;
|
||||
let entry = user_map.entry(id).or_insert_with(|| {
|
||||
order.push(id);
|
||||
(id, username, role, force_pw, created_at, Vec::new())
|
||||
UserWithGroupsView {
|
||||
id,
|
||||
username,
|
||||
force_password_change: force_pw,
|
||||
created_at,
|
||||
groups: Vec::new(),
|
||||
}
|
||||
});
|
||||
if let (Some(gid), Some(gname)) = (group_id, group_name) {
|
||||
entry.5.push((gid, gname));
|
||||
entry.groups.push(UserGroupMembership {
|
||||
group_id: gid,
|
||||
group_name: gname,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -128,19 +137,18 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn find_user_by_id(&self, user_id: i64) -> Result<Option<UserTuple>, Error> {
|
||||
pub fn find_user_by_id(&self, user_id: i64) -> Result<Option<UserView>, Error> {
|
||||
let conn = self.conn()?;
|
||||
let result = conn.query_row(
|
||||
"SELECT id, username, password_hash, role, force_password_change FROM users WHERE id = ?1",
|
||||
"SELECT id, username, password_hash, force_password_change FROM users WHERE id = ?1",
|
||||
params![user_id],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get(0)?,
|
||||
row.get(1)?,
|
||||
row.get(2)?,
|
||||
row.get(3)?,
|
||||
row.get::<_, i64>(4)? != 0,
|
||||
))
|
||||
Ok(UserView {
|
||||
id: row.get(0)?,
|
||||
username: row.get(1)?,
|
||||
password_hash: row.get(2)?,
|
||||
force_password_change: row.get::<_, i64>(3)? != 0,
|
||||
})
|
||||
},
|
||||
);
|
||||
match result {
|
||||
@ -150,18 +158,18 @@ impl Database {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_user_groups(&self) -> Result<Vec<UserGroupTuple>, Error> {
|
||||
pub fn list_user_groups(&self) -> Result<Vec<UserGroupView>, Error> {
|
||||
let conn = self.conn()?;
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT id, name, description, permissions, created_at FROM user_groups ORDER BY id")?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
row.get::<_, String>(4)?,
|
||||
))
|
||||
Ok(UserGroupView {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
description: row.get(2)?,
|
||||
permissions: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
})
|
||||
})?;
|
||||
let mut results = Vec::new();
|
||||
for row in rows {
|
||||
@ -202,19 +210,19 @@ impl Database {
|
||||
Ok(affected > 0)
|
||||
}
|
||||
|
||||
pub fn get_user_group(&self, id: i64) -> Result<Option<UserGroupTuple>, Error> {
|
||||
pub fn get_user_group(&self, id: i64) -> Result<Option<UserGroupView>, Error> {
|
||||
let conn = self.conn()?;
|
||||
let result = conn.query_row(
|
||||
"SELECT id, name, description, permissions, created_at FROM user_groups WHERE id = ?1",
|
||||
params![id],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
row.get::<_, String>(4)?,
|
||||
))
|
||||
Ok(UserGroupView {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
description: row.get(2)?,
|
||||
permissions: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
})
|
||||
},
|
||||
);
|
||||
match result {
|
||||
@ -315,12 +323,12 @@ impl Database {
|
||||
|
||||
self.set_setting(&key_count, &count.to_string())?;
|
||||
|
||||
if count >= 5 {
|
||||
if count >= LOGIN_MAX_FAILURES {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or(Duration::ZERO)
|
||||
.as_secs();
|
||||
let locked_until = now + 900; // 15 minutes
|
||||
let locked_until = now + LOGIN_LOCKOUT_SECS;
|
||||
self.set_setting(&key_locked, &locked_until.to_string())?;
|
||||
Ok((count, Some(locked_until)))
|
||||
} else {
|
||||
@ -361,11 +369,11 @@ impl Database {
|
||||
}
|
||||
|
||||
impl IdentityRepo for Database {
|
||||
fn find_user(&self, username: &str) -> Result<Option<UserTuple>, Error> {
|
||||
fn find_user(&self, username: &str) -> Result<Option<UserView>, Error> {
|
||||
self.find_user(username)
|
||||
}
|
||||
|
||||
fn find_user_by_id(&self, user_id: i64) -> Result<Option<UserTuple>, Error> {
|
||||
fn find_user_by_id(&self, user_id: i64) -> Result<Option<UserView>, Error> {
|
||||
self.find_user_by_id(user_id)
|
||||
}
|
||||
|
||||
@ -383,7 +391,7 @@ impl IdentityRepo for Database {
|
||||
self.update_user_password(user_id, password_hash)
|
||||
}
|
||||
|
||||
fn list_users_with_groups(&self) -> Result<Vec<UserWithGroups>, Error> {
|
||||
fn list_users_with_groups(&self) -> Result<Vec<UserWithGroupsView>, Error> {
|
||||
self.list_users_with_groups()
|
||||
}
|
||||
|
||||
@ -399,7 +407,7 @@ impl IdentityRepo for Database {
|
||||
self.reset_user_password(user_id, password_hash)
|
||||
}
|
||||
|
||||
fn list_user_groups(&self) -> Result<Vec<UserGroupTuple>, Error> {
|
||||
fn list_user_groups(&self) -> Result<Vec<UserGroupView>, Error> {
|
||||
self.list_user_groups()
|
||||
}
|
||||
|
||||
@ -415,7 +423,7 @@ impl IdentityRepo for Database {
|
||||
self.delete_user_group(id)
|
||||
}
|
||||
|
||||
fn get_user_group(&self, id: i64) -> Result<Option<UserGroupTuple>, Error> {
|
||||
fn get_user_group(&self, id: i64) -> Result<Option<UserGroupView>, Error> {
|
||||
self.get_user_group(id)
|
||||
}
|
||||
|
||||
@ -465,11 +473,10 @@ mod tests {
|
||||
assert_eq!(db.user_count().unwrap(), 1);
|
||||
|
||||
let user = db.find_user("admin").unwrap().unwrap();
|
||||
assert_eq!(user.0, 1); // id
|
||||
assert_eq!(user.1, "admin"); // username
|
||||
assert_eq!(user.2, "hash123"); // password_hash
|
||||
assert_eq!(user.3, "admin"); // role
|
||||
assert!(user.4); // force_password_change
|
||||
assert_eq!(user.id, 1);
|
||||
assert_eq!(user.username, "admin");
|
||||
assert_eq!(user.password_hash, "hash123");
|
||||
assert!(user.force_password_change);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -486,13 +493,13 @@ mod tests {
|
||||
db.insert_user("admin", "old_hash", "admin", true).unwrap();
|
||||
|
||||
let user = db.find_user("admin").unwrap().unwrap();
|
||||
assert!(user.4); // force_password_change = true
|
||||
assert!(user.force_password_change);
|
||||
|
||||
db.update_user_password(user.0, "new_hash").unwrap();
|
||||
db.update_user_password(user.id, "new_hash").unwrap();
|
||||
|
||||
let user = db.find_user("admin").unwrap().unwrap();
|
||||
assert!(!user.4); // force_password_change = false
|
||||
assert_eq!(user.2, "new_hash");
|
||||
assert!(!user.force_password_change);
|
||||
assert_eq!(user.password_hash, "new_hash");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -6,8 +6,8 @@ use actix_ws::Message;
|
||||
use futures_util::StreamExt;
|
||||
use tokio::time::interval;
|
||||
|
||||
use crate::core::common::statistics::FlowStatistics;
|
||||
use crate::domain::data_plane::flow_stats::FlowSubscription;
|
||||
use crate::infrastructure::statistics::FlowStatistics;
|
||||
|
||||
/// Default subscription: all flows, no filter, 5 second interval
|
||||
fn default_subscription() -> FlowSubscription {
|
||||
|
||||
@ -4,11 +4,11 @@ use tokio::sync::broadcast;
|
||||
|
||||
use super::{alert_websocket, drop_websocket, flow_websocket, fusion_websocket, health_websocket};
|
||||
use crate::adapter::ebpf::drop_monitor::DropMonitor;
|
||||
use crate::core::identity::jwt::JwtService;
|
||||
use crate::adapter::http::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 {
|
||||
|
||||
@ -4,20 +4,12 @@ use std::sync::atomic::{AtomicU8, Ordering};
|
||||
use macros::log;
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use crate::domain::common::config::constants::enforce_mode_to_u8;
|
||||
use crate::domain::common::error::Error;
|
||||
use crate::domain::common::event::AuditEvent;
|
||||
use crate::domain::common::log::system::SystemLog;
|
||||
use crate::interface::port::app_repo::AppRepo;
|
||||
|
||||
/// Map enforce-mode string to u8: monitor=0, ml_only=1, enforce=2.
|
||||
pub fn enforce_mode_to_u8(mode: &str) -> u8 {
|
||||
match mode {
|
||||
"enforce" => 2,
|
||||
"ml_only" => 1,
|
||||
_ => 0, // "monitor" or unknown → safest default
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles enforce-mode commands and queries by delegating to the repository.
|
||||
pub struct EnforceModeHandler {
|
||||
db: Arc<dyn AppRepo>,
|
||||
@ -1,3 +1,4 @@
|
||||
pub mod config_service;
|
||||
pub mod log_buffer;
|
||||
pub mod enforce_mode_handler;
|
||||
pub mod notification_service;
|
||||
pub mod statistics;
|
||||
|
||||
@ -3,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;
|
||||
|
||||
@ -4,10 +4,10 @@ use std::time::{Duration, Instant};
|
||||
use dashmap::DashMap;
|
||||
use macros::log;
|
||||
|
||||
use crate::core::correlation::correlation_cleanup::capped_cleanup;
|
||||
use crate::domain::common::config::correlation::CorrelationDetectorParams;
|
||||
use crate::domain::common::event::{DetectionEvent, DetectionSource};
|
||||
use crate::domain::detection::attack_type::CanonicalAttackType;
|
||||
use crate::domain::detection::correlation_cleanup::capped_cleanup;
|
||||
use crate::domain::detection::log::DetectionLog;
|
||||
use crate::domain::detection::ml_detection::AlertMessage;
|
||||
|
||||
@ -7,13 +7,13 @@ use tokio::sync::broadcast::error::RecvError;
|
||||
use tokio::sync::{broadcast, mpsc};
|
||||
use tokio::time::interval;
|
||||
|
||||
use crate::core::correlation::botnet::BotnetDetector;
|
||||
use crate::core::correlation::lateral::LateralMovementDetector;
|
||||
use crate::core::correlation::scan::ScanDetector;
|
||||
use crate::domain::common::config::AppConfig;
|
||||
use crate::domain::common::event::DetectionEvent;
|
||||
use crate::domain::detection::botnet::BotnetDetector;
|
||||
use crate::domain::detection::lateral::LateralMovementDetector;
|
||||
use crate::domain::detection::log::DetectionLog;
|
||||
use crate::domain::detection::ml_detection::AlertMessage;
|
||||
use crate::domain::detection::scan::ScanDetector;
|
||||
|
||||
/// Coordinates cross-flow correlation detectors (botnet, scan, lateral movement).
|
||||
/// Subscribes to ML AlertMessage broadcast and feeds enriched DetectionEvents
|
||||
|
||||
@ -1,16 +1,16 @@
|
||||
use std::collections::HashSet;
|
||||
use std::net::IpAddr;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use dashmap::DashMap;
|
||||
use macros::log;
|
||||
|
||||
use crate::core::correlation::correlation_cleanup::capped_cleanup;
|
||||
use crate::domain::common::config::correlation::CorrelationDetectorParams;
|
||||
use crate::domain::common::event::{DetectionEvent, DetectionSource};
|
||||
use crate::domain::detection::attack_type::CanonicalAttackType;
|
||||
use crate::domain::detection::correlation_cleanup::capped_cleanup;
|
||||
use crate::domain::detection::log::DetectionLog;
|
||||
use crate::domain::detection::ml_detection::AlertMessage;
|
||||
use crate::utils::ip_address::is_internal_ip;
|
||||
|
||||
struct TimedDestSet {
|
||||
dests: HashSet<String>,
|
||||
@ -115,39 +115,10 @@ impl LateralMovementDetector {
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if an IP address string represents a private/internal address.
|
||||
/// RFC 1918: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
|
||||
/// RFC 4193: fc00::/7 (IPv6 unique local)
|
||||
pub fn is_internal_ip(ip_str: &str) -> bool {
|
||||
let Ok(ip) = ip_str.parse::<IpAddr>() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
match ip {
|
||||
IpAddr::V4(v4) => {
|
||||
let octets = v4.octets();
|
||||
// 10.0.0.0/8
|
||||
octets[0] == 10
|
||||
// 172.16.0.0/12
|
||||
|| (octets[0] == 172 && (16..=31).contains(&octets[1]))
|
||||
// 192.168.0.0/16
|
||||
|| (octets[0] == 192 && octets[1] == 168)
|
||||
// 127.0.0.0/8 (loopback)
|
||||
|| octets[0] == 127
|
||||
}
|
||||
IpAddr::V6(v6) => {
|
||||
let segments = v6.segments();
|
||||
// fc00::/7
|
||||
(segments[0] & 0xfe00) == 0xfc00
|
||||
// ::1 (loopback)
|
||||
|| v6.is_loopback()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::utils::ip_address::is_internal_ip;
|
||||
|
||||
#[test]
|
||||
fn test_internal_ip_detection() {
|
||||
@ -1 +1,5 @@
|
||||
pub mod botnet;
|
||||
pub mod correlation_cleanup;
|
||||
pub mod engine;
|
||||
pub mod lateral;
|
||||
pub mod scan;
|
||||
|
||||
@ -4,9 +4,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;
|
||||
|
||||
@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
pub mod acl_service;
|
||||
pub mod dns_filter;
|
||||
pub mod dns_filter_service;
|
||||
pub mod rate_limit_service;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -1,15 +1,15 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use arc_swap::ArcSwap;
|
||||
use dashmap::DashMap;
|
||||
use macros::log;
|
||||
use tokio::sync::broadcast::error::RecvError;
|
||||
use tokio::sync::{broadcast, mpsc};
|
||||
use tokio::time::interval;
|
||||
|
||||
use crate::domain::common::config::AppConfig;
|
||||
use crate::domain::common::event::DetectionEvent;
|
||||
use crate::domain::detection::beaconing::BeaconingState;
|
||||
use crate::domain::common::event::{DetectionEvent, DetectionSource};
|
||||
use crate::domain::detection::log::DetectionLog;
|
||||
use crate::domain::detection::ml_detection::AlertMessage;
|
||||
|
||||
@ -74,3 +74,218 @@ impl BeaconingDetector {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type FlowTuple = (String, String, u16);
|
||||
|
||||
struct CachedFlow {
|
||||
timestamps: Vec<Instant>,
|
||||
last_alerted: Option<Instant>,
|
||||
}
|
||||
|
||||
pub struct BeaconingState {
|
||||
flow_cache: DashMap<FlowTuple, CachedFlow>,
|
||||
min_observations: usize,
|
||||
cv_threshold: f64,
|
||||
max_cache_entries: usize,
|
||||
expiry_secs: u64,
|
||||
alert_cooldown_secs: u64,
|
||||
}
|
||||
|
||||
impl BeaconingState {
|
||||
pub fn new(
|
||||
min_observations: usize,
|
||||
cv_threshold: f64,
|
||||
max_cache_entries: usize,
|
||||
expiry_secs: u64,
|
||||
alert_cooldown_secs: u64,
|
||||
) -> Self {
|
||||
Self {
|
||||
flow_cache: DashMap::new(),
|
||||
min_observations,
|
||||
cv_threshold,
|
||||
max_cache_entries,
|
||||
expiry_secs,
|
||||
alert_cooldown_secs,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_flow(&self, alert: &AlertMessage) {
|
||||
let key = (alert.src_ip.clone(), alert.dst_ip.clone(), alert.dst_port);
|
||||
let now = Instant::now();
|
||||
|
||||
let mut entry = self.flow_cache.entry(key).or_insert_with(|| CachedFlow {
|
||||
timestamps: Vec::new(),
|
||||
last_alerted: None,
|
||||
});
|
||||
|
||||
entry.timestamps.push(now);
|
||||
|
||||
if entry.timestamps.len() > 100 {
|
||||
let excess = entry.timestamps.len() - 100;
|
||||
entry.timestamps.drain(..excess);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn analyze(&self) -> Vec<DetectionEvent> {
|
||||
let now = Instant::now();
|
||||
let cooldown = Duration::from_secs(self.alert_cooldown_secs);
|
||||
|
||||
let mut candidates: Vec<(FlowTuple, f64, usize)> = Vec::new();
|
||||
for entry in self.flow_cache.iter() {
|
||||
let flow = entry.value();
|
||||
if flow.timestamps.len() < self.min_observations {
|
||||
continue;
|
||||
}
|
||||
if let Some(last) = flow.last_alerted
|
||||
&& now.duration_since(last) < cooldown
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let cv = compute_cv(&flow.timestamps);
|
||||
if cv < self.cv_threshold {
|
||||
candidates.push((entry.key().clone(), cv, flow.timestamps.len()));
|
||||
}
|
||||
}
|
||||
|
||||
let mut events = Vec::new();
|
||||
for (key, cv, count) in candidates {
|
||||
let (src_ip, dst_ip, dst_port) = &key;
|
||||
log!(DetectionLog::BeaconingDetected(
|
||||
src_ip.clone(),
|
||||
dst_ip.clone(),
|
||||
*dst_port,
|
||||
cv,
|
||||
count,
|
||||
));
|
||||
|
||||
events.push(DetectionEvent {
|
||||
source: DetectionSource::Beaconing,
|
||||
attack_type: "c2_communication".to_string(),
|
||||
confidence: (1.0 - cv / self.cv_threshold) as f32 * 0.5 + 0.5,
|
||||
source_ip: src_ip.clone(),
|
||||
dest_ip: dst_ip.clone(),
|
||||
protocol: 6,
|
||||
packet_count: count as u64,
|
||||
flow_duration_us: 0,
|
||||
ae_score: 0.0,
|
||||
anomaly_score: 0.0,
|
||||
c2_score: 0.0,
|
||||
});
|
||||
|
||||
if let Some(mut entry) = self.flow_cache.get_mut(&key) {
|
||||
entry.last_alerted = Some(now);
|
||||
}
|
||||
}
|
||||
|
||||
events
|
||||
}
|
||||
|
||||
pub fn cleanup(&self) {
|
||||
let now = Instant::now();
|
||||
let expiry = Duration::from_secs(self.expiry_secs);
|
||||
|
||||
self.flow_cache.retain(|_, flow| {
|
||||
flow.timestamps
|
||||
.last()
|
||||
.is_some_and(|last| now.duration_since(*last) < expiry)
|
||||
});
|
||||
|
||||
if self.flow_cache.len() > self.max_cache_entries {
|
||||
let excess = self.flow_cache.len() - self.max_cache_entries;
|
||||
let keys_to_remove: Vec<FlowTuple> = self.flow_cache.iter().take(excess).map(|e| e.key().clone()).collect();
|
||||
for key in keys_to_remove {
|
||||
self.flow_cache.remove(&key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn compute_cv(timestamps: &[Instant]) -> f64 {
|
||||
if timestamps.len() < 2 {
|
||||
return f64::MAX;
|
||||
}
|
||||
|
||||
let intervals: Vec<f64> = timestamps
|
||||
.windows(2)
|
||||
.map(|w| w[1].duration_since(w[0]).as_secs_f64())
|
||||
.collect();
|
||||
|
||||
let n = intervals.len() as f64;
|
||||
let mean = intervals.iter().sum::<f64>() / n;
|
||||
|
||||
if mean <= 0.0 {
|
||||
return f64::MAX;
|
||||
}
|
||||
|
||||
let variance = intervals.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / n;
|
||||
let std = variance.sqrt();
|
||||
|
||||
std / mean
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::core::detection::beaconing::{BeaconingState, CachedFlow, compute_cv};
|
||||
#[test]
|
||||
fn cv_perfectly_periodic() {
|
||||
let base = Instant::now();
|
||||
let timestamps: Vec<Instant> = (0..10).map(|i| base + Duration::from_secs(i * 60)).collect();
|
||||
let cv = compute_cv(×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");
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,2 +1,3 @@
|
||||
pub mod beaconing;
|
||||
pub mod metrics;
|
||||
pub mod orchestrator;
|
||||
|
||||
@ -9,13 +9,14 @@ use tokio::sync::broadcast;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::time::interval;
|
||||
|
||||
use crate::core::detection::metrics::FusionMetrics;
|
||||
use crate::domain::common::config::AppConfig;
|
||||
use crate::domain::common::config::constants::{FUSION_AUDIT_ACTION, FUSION_AUDIT_ACTOR};
|
||||
use crate::domain::common::event::{AuditEvent, DetectionEvent, DetectionSource, ThreatDetectedEvent};
|
||||
use crate::domain::detection::attack_type::translate;
|
||||
use crate::domain::detection::fusion_math::{FusionWindowLengths, fused_confidence};
|
||||
use crate::domain::detection::log::DetectionLog;
|
||||
use crate::domain::detection::metrics::FusionMetrics;
|
||||
use crate::domain::detection::ml_detection::AlertMessage;
|
||||
use crate::interface::port::geo_lookup::GeoLookup;
|
||||
|
||||
/// Per-source record within an in-flight dedup entry. Keeps the strongest
|
||||
@ -354,6 +355,46 @@ impl DetectionOrchestrator {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn bridge_ml_to_detection(mut rx: broadcast::Receiver<AlertMessage>, tx: mpsc::Sender<DetectionEvent>) {
|
||||
log!(DetectionLog::MlBridgeStarted);
|
||||
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(alert) => {
|
||||
let raw_type = alert.attack_type.as_deref().unwrap_or("unknown");
|
||||
|
||||
if raw_type.eq_ignore_ascii_case("normal") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let event = DetectionEvent {
|
||||
source: DetectionSource::ML,
|
||||
attack_type: raw_type.to_string(),
|
||||
confidence: alert.confidence,
|
||||
source_ip: alert.src_ip,
|
||||
dest_ip: alert.dst_ip,
|
||||
protocol: alert.protocol,
|
||||
packet_count: alert.packet_count,
|
||||
flow_duration_us: alert.flow_duration_us,
|
||||
ae_score: alert.ae_score,
|
||||
anomaly_score: alert.anomaly_score,
|
||||
c2_score: alert.c2_score,
|
||||
};
|
||||
if tx.send(event).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
log!(DetectionLog::MlBridgeLagged(n));
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => {
|
||||
log!(DetectionLog::MlAlertChannelClosed);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize the WORM audit evidence payload for a fused threat emission.
|
||||
/// Extracted as a free function so tests can cover schema shape without a
|
||||
/// live broadcast harness.
|
||||
|
||||
165
net-guardia/src/core/identity/auth_service.rs
Normal file
165
net-guardia/src/core/identity/auth_service.rs
Normal file
@ -0,0 +1,165 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use macros::log;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::adapter::http::middleware::jwt::JwtService;
|
||||
use crate::domain::common::error::Error;
|
||||
use crate::domain::identity::auth::{GROUP_ADMIN, GROUP_VIEWER, ROLE_ADMIN, ROLE_VIEWER};
|
||||
use crate::domain::identity::error::AuthError;
|
||||
use crate::domain::identity::password;
|
||||
use crate::domain::identity::validation::{validate_password, validate_username};
|
||||
use crate::interface::port::app_repo::AppRepo;
|
||||
|
||||
pub const DUMMY_HASH: &str = "$argon2id$v=19$m=19456,t=2,p=1$dW5rbm93bg$QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUE";
|
||||
|
||||
pub struct AuthService {
|
||||
db: Arc<dyn AppRepo>,
|
||||
jwt: Arc<JwtService>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct LoginResult {
|
||||
pub token: String,
|
||||
pub role: String,
|
||||
pub force_password_change: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct UserProfile {
|
||||
pub id: i64,
|
||||
pub username: String,
|
||||
pub role: String,
|
||||
pub permissions: Vec<String>,
|
||||
pub groups: Vec<String>,
|
||||
}
|
||||
|
||||
pub enum LoginError {
|
||||
Locked { retry_after_secs: u64 },
|
||||
InvalidCredentials,
|
||||
InternalError,
|
||||
}
|
||||
|
||||
pub enum RegisterError {
|
||||
Validation(&'static str),
|
||||
InvalidRole,
|
||||
Forbidden,
|
||||
HashFailed,
|
||||
Conflict(Error),
|
||||
}
|
||||
|
||||
impl AuthService {
|
||||
pub fn new(db: Arc<dyn AppRepo>, jwt: Arc<JwtService>) -> Self {
|
||||
Self { db, jwt }
|
||||
}
|
||||
|
||||
pub fn login(&self, username: &str, raw_password: &str) -> Result<LoginResult, LoginError> {
|
||||
if let Ok(Some(remaining)) = self.db.check_login_locked(username) {
|
||||
return Err(LoginError::Locked {
|
||||
retry_after_secs: remaining,
|
||||
});
|
||||
}
|
||||
|
||||
let user = match self.db.find_user(username) {
|
||||
Ok(Some(u)) => u,
|
||||
_ => {
|
||||
let _ = password::verify_password(raw_password, DUMMY_HASH);
|
||||
if let Err(e) = self.db.record_login_failure(username) {
|
||||
log!(AuthError::LoginFailureTrackingError(e));
|
||||
}
|
||||
return Err(LoginError::InvalidCredentials);
|
||||
}
|
||||
};
|
||||
|
||||
match password::verify_password(raw_password, &user.password_hash) {
|
||||
Ok(true) => {}
|
||||
_ => {
|
||||
if let Err(e) = self.db.record_login_failure(username) {
|
||||
log!(AuthError::LoginFailureTrackingError(e));
|
||||
}
|
||||
return Err(LoginError::InvalidCredentials);
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = self.db.clear_login_failures(username) {
|
||||
log!(AuthError::LoginClearError(e));
|
||||
}
|
||||
|
||||
let permissions = self.db.list_user_permissions(user.id).unwrap_or_default();
|
||||
let role = self.derive_role(user.id);
|
||||
|
||||
let token = self
|
||||
.jwt
|
||||
.create_token(user.id, &user.username, &role, permissions)
|
||||
.map_err(|_| LoginError::InternalError)?;
|
||||
|
||||
Ok(LoginResult {
|
||||
token,
|
||||
role,
|
||||
force_password_change: user.force_password_change,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn register(
|
||||
&self,
|
||||
username: &str,
|
||||
raw_password: &str,
|
||||
role: &str,
|
||||
caller_role: &str,
|
||||
) -> Result<i64, RegisterError> {
|
||||
validate_username(username).map_err(RegisterError::Validation)?;
|
||||
validate_password(raw_password).map_err(RegisterError::Validation)?;
|
||||
|
||||
if role != ROLE_ADMIN && role != ROLE_VIEWER {
|
||||
return Err(RegisterError::InvalidRole);
|
||||
}
|
||||
if role == ROLE_ADMIN && caller_role != ROLE_ADMIN {
|
||||
return Err(RegisterError::Forbidden);
|
||||
}
|
||||
|
||||
let hash = password::hash_password(raw_password).map_err(|_| RegisterError::HashFailed)?;
|
||||
|
||||
let new_id = self
|
||||
.db
|
||||
.insert_user(username, &hash, role, false)
|
||||
.map_err(RegisterError::Conflict)?;
|
||||
|
||||
let default_group = if role == ROLE_ADMIN { GROUP_ADMIN } else { GROUP_VIEWER };
|
||||
if let Ok(groups) = self.db.list_user_groups()
|
||||
&& let Some(g) = groups.into_iter().find(|g| g.name == default_group)
|
||||
&& let Err(e) = self.db.set_user_groups(new_id, &[g.id])
|
||||
{
|
||||
log!(AuthError::GroupAssignmentFailed(e));
|
||||
}
|
||||
|
||||
Ok(new_id)
|
||||
}
|
||||
|
||||
pub fn user_profile(&self, user_id: i64, username: &str) -> UserProfile {
|
||||
let groups_raw = self.db.list_groups_for_user(user_id).unwrap_or_default();
|
||||
let group_names: Vec<String> = groups_raw.iter().map(|(_, name, _, _)| name.clone()).collect();
|
||||
let role = if group_names.iter().any(|n| n == GROUP_ADMIN) {
|
||||
ROLE_ADMIN.to_string()
|
||||
} else {
|
||||
ROLE_VIEWER.to_string()
|
||||
};
|
||||
let permissions = self.db.list_user_permissions(user_id).unwrap_or_default();
|
||||
|
||||
UserProfile {
|
||||
id: user_id,
|
||||
username: username.to_string(),
|
||||
role,
|
||||
permissions,
|
||||
groups: group_names,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn derive_role(&self, user_id: i64) -> String {
|
||||
let groups = self.db.list_groups_for_user(user_id).unwrap_or_default();
|
||||
if groups.iter().any(|(_, name, _, _)| name == GROUP_ADMIN) {
|
||||
ROLE_ADMIN.to_string()
|
||||
} else {
|
||||
ROLE_VIEWER.to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -1,9 +1,13 @@
|
||||
use std::time::Duration;
|
||||
use std::collections::VecDeque;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
use macros::log;
|
||||
use tokio::sync::{broadcast, mpsc, oneshot};
|
||||
use tokio::time::interval;
|
||||
|
||||
use crate::domain::common::event::DriftDetectedEvent;
|
||||
use crate::domain::common::log::system::SystemLog;
|
||||
use crate::domain::detection::drift::{DriftReport, FeatureBaselines};
|
||||
use crate::domain::detection::drift_detector::DriftDetector;
|
||||
|
||||
enum DriftCmd {
|
||||
Update(Vec<f64>),
|
||||
@ -51,3 +55,156 @@ impl DriftDetectorHandle {
|
||||
reply_rx.await.unwrap_or(None)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DriftDetector {
|
||||
snapshots: VecDeque<(Instant, Vec<f64>)>,
|
||||
num_features: usize,
|
||||
baselines: Option<FeatureBaselines>,
|
||||
drift_window: Duration,
|
||||
max_snapshots: usize,
|
||||
}
|
||||
|
||||
impl DriftDetector {
|
||||
pub fn new(baselines: Option<FeatureBaselines>, drift_window: Duration, max_snapshots: usize) -> Self {
|
||||
let num_features = baselines.as_ref().map_or(0, |b| b.names.len());
|
||||
Self {
|
||||
snapshots: VecDeque::new(),
|
||||
num_features,
|
||||
baselines,
|
||||
drift_window,
|
||||
max_snapshots,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update(&mut self, features: &[f64]) {
|
||||
let now = Instant::now();
|
||||
self.snapshots.push_back((now, features.to_vec()));
|
||||
self.evict_stale(now);
|
||||
while self.snapshots.len() > self.max_snapshots {
|
||||
self.snapshots.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn check_drift(&self) -> Option<DriftReport> {
|
||||
let baselines = self.baselines.as_ref()?;
|
||||
if self.snapshots.is_empty() || self.num_features == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let n = self.snapshots.len() as f64;
|
||||
let mut sums = vec![0.0_f64; self.num_features];
|
||||
|
||||
for (_, features) in &self.snapshots {
|
||||
for (i, &val) in features.iter().enumerate().take(self.num_features) {
|
||||
sums[i] += val;
|
||||
}
|
||||
}
|
||||
|
||||
let mut drifted_features = Vec::new();
|
||||
let mut max_deviation = 0.0_f64;
|
||||
|
||||
for (i, (sum, (bl_mean, bl_std))) in sums
|
||||
.iter()
|
||||
.zip(baselines.means.iter().zip(baselines.stds.iter()))
|
||||
.enumerate()
|
||||
.take(self.num_features)
|
||||
{
|
||||
let current_mean = sum / n;
|
||||
|
||||
if *bl_std < 1e-12 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let deviation = ((current_mean - bl_mean) / bl_std).abs();
|
||||
if deviation > 3.0 {
|
||||
drifted_features.push(baselines.names[i].clone());
|
||||
if deviation > max_deviation {
|
||||
max_deviation = deviation;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if drifted_features.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(DriftReport {
|
||||
drifted_features,
|
||||
max_deviation,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn evict_stale(&mut self, now: Instant) {
|
||||
while let Some((ts, _)) = self.snapshots.front() {
|
||||
if now.duration_since(*ts) > self.drift_window {
|
||||
self.snapshots.pop_front();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run_drift_monitor(drift_detector: DriftDetectorHandle, drift_tx: broadcast::Sender<DriftDetectedEvent>) {
|
||||
let mut tick = interval(Duration::from_secs(60));
|
||||
loop {
|
||||
tick.tick().await;
|
||||
if let Some(report) = drift_detector.check_drift().await {
|
||||
log!(SystemLog::DriftDetected(
|
||||
report.drifted_features.len(),
|
||||
report.max_deviation
|
||||
));
|
||||
let event = DriftDetectedEvent {
|
||||
drifted_features: report.drifted_features,
|
||||
max_deviation: report.max_deviation,
|
||||
};
|
||||
let _ = drift_tx.send(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::core::inference::drift_detector::DriftDetector;
|
||||
fn make_baselines(n: usize) -> FeatureBaselines {
|
||||
FeatureBaselines {
|
||||
names: (0..n).map(|i| format!("feature_{i}")).collect(),
|
||||
means: vec![0.0; n],
|
||||
stds: vec![1.0; n],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_drift_when_within_threshold() {
|
||||
let baselines = make_baselines(3);
|
||||
let mut detector = DriftDetector::new(Some(baselines), Duration::from_secs(3600), 10_000);
|
||||
detector.update(&[1.0, -1.0, 2.0]);
|
||||
detector.update(&[0.5, -0.5, 1.5]);
|
||||
assert!(detector.check_drift().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drift_detected_when_exceeds_threshold() {
|
||||
let baselines = make_baselines(3);
|
||||
let mut detector = DriftDetector::new(Some(baselines), Duration::from_secs(3600), 10_000);
|
||||
detector.update(&[5.0, 0.0, 0.0]);
|
||||
detector.update(&[5.0, 0.0, 0.0]);
|
||||
let report = detector.check_drift().unwrap();
|
||||
assert!(report.drifted_features.contains(&"feature_0".to_string()));
|
||||
assert!(report.max_deviation > 3.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_baselines_means_no_drift() {
|
||||
let mut detector = DriftDetector::new(None, Duration::from_secs(3600), 10_000);
|
||||
detector.update(&[100.0, 200.0]);
|
||||
assert!(detector.check_drift().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_snapshots_no_drift() {
|
||||
let baselines = make_baselines(3);
|
||||
let detector = DriftDetector::new(Some(baselines), Duration::from_secs(3600), 10_000);
|
||||
assert!(detector.check_drift().is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,22 +16,27 @@ use super::alert::MLAlert;
|
||||
use super::drift_detector::DriftDetectorHandle;
|
||||
use super::runner::Inference;
|
||||
use super::traffic_logger::TrafficLogger;
|
||||
use crate::core::inference::aggregator::AttackAggregator;
|
||||
use crate::core::inference::flow_tracker::FlowTracker;
|
||||
use crate::domain::data_plane::user_packet::UserPacket;
|
||||
use crate::domain::detection::aggregator::AttackAggregator;
|
||||
use crate::domain::detection::flow_features::FlowFeatures;
|
||||
use crate::domain::detection::flow_tracker::{FlowData, FlowLimits, FlowTracker};
|
||||
use crate::domain::detection::flow_tracker::{FlowData, FlowLimits};
|
||||
use crate::domain::detection::log::MLLog;
|
||||
use crate::domain::detection::ml_detection::{EngineConfig, FlowKey, InferenceStats};
|
||||
use crate::domain::detection::ml_detection::{FlowKey, InferenceStats};
|
||||
use crate::interface::port::packet_sink::{PacketSink, PacketSinkFactory};
|
||||
|
||||
/// Per-queue tracker. With symmetric hash in eBPF, both directions of a flow
|
||||
/// land on the same queue, so per-queue trackers correctly see bidirectional flows.
|
||||
/// `FlowTracker` itself is internally synchronized (DashMap), so the
|
||||
/// per-queue handle is a plain `Arc`.
|
||||
pub type ThreadTracker = Arc<FlowTracker>;
|
||||
pub struct EngineConfig {
|
||||
pub max_flows: usize,
|
||||
pub min_packets: usize,
|
||||
pub min_packets_floor: usize,
|
||||
pub batch_size: usize,
|
||||
pub inference_interval_secs: u64,
|
||||
pub aggregator_window_secs: u64,
|
||||
pub confirmation_window_fraction: u64,
|
||||
}
|
||||
|
||||
pub struct Engine {
|
||||
trackers: Vec<ThreadTracker>,
|
||||
trackers: Vec<Arc<FlowTracker>>,
|
||||
inference_pipeline: Arc<Inference>,
|
||||
aggregator: AttackAggregator,
|
||||
drift_detector: DriftDetectorHandle,
|
||||
@ -65,7 +70,7 @@ impl Engine {
|
||||
let aggregator = AttackAggregator::new(engine_config.aggregator_window_secs);
|
||||
|
||||
let max_flows_per_thread = engine_config.max_flows / (num_threads as usize).max(1);
|
||||
let trackers: Vec<ThreadTracker> = (0..num_threads)
|
||||
let trackers: Vec<Arc<FlowTracker>> = (0..num_threads)
|
||||
.map(|_| Arc::new(FlowTracker::new(max_flows_per_thread, flow_limits)))
|
||||
.collect();
|
||||
|
||||
@ -85,11 +90,11 @@ impl Engine {
|
||||
}
|
||||
|
||||
/// xsk_manager calls this per queue_id; with symmetric hash each queue has its own tracker.
|
||||
pub fn tracker(&self, queue_id: u32) -> &ThreadTracker {
|
||||
pub fn tracker(&self, queue_id: u32) -> &Arc<FlowTracker> {
|
||||
&self.trackers[queue_id as usize % self.trackers.len()]
|
||||
}
|
||||
|
||||
pub fn trackers(&self) -> &[ThreadTracker] {
|
||||
pub fn trackers(&self) -> &[Arc<FlowTracker>] {
|
||||
&self.trackers
|
||||
}
|
||||
|
||||
@ -337,9 +342,9 @@ impl Engine {
|
||||
}
|
||||
}
|
||||
|
||||
/// Adapter that exposes one `ThreadTracker` (per AF_XDP queue) as a `PacketSink`.
|
||||
/// Adapter that exposes one `Arc<FlowTracker>` (per AF_XDP queue) as a `PacketSink`.
|
||||
struct QueueTrackerSink {
|
||||
tracker: ThreadTracker,
|
||||
tracker: Arc<FlowTracker>,
|
||||
}
|
||||
|
||||
impl PacketSink for QueueTrackerSink {
|
||||
|
||||
244
net-guardia/src/core/inference/flow_tracker.rs
Normal file
244
net-guardia/src/core/inference/flow_tracker.rs
Normal file
@ -0,0 +1,244 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use common::define::tcp_flags::*;
|
||||
use moka::sync::Cache;
|
||||
use parking_lot::Mutex;
|
||||
|
||||
use crate::domain::data_plane::direction::Direction;
|
||||
use crate::domain::data_plane::user_packet::UserPacket;
|
||||
use crate::domain::detection::flow_tracker::{FlowData, FlowLimits};
|
||||
use crate::domain::detection::ml_detection::FlowKey;
|
||||
|
||||
/// Per-flow handle: an `Arc` so map operations stay copy-cheap, with an inner
|
||||
/// `Mutex` because `add_packet` is a read-modify-write that needs exclusive
|
||||
/// access. Same-flow packets land on the same XSK queue (symmetric eBPF
|
||||
/// hash), so this mutex is effectively single-writer; the inference tick
|
||||
/// briefly contends only when it clones the entry for a snapshot.
|
||||
type FlowEntry = Arc<Mutex<FlowData>>;
|
||||
|
||||
/// Per-queue flow tracker backed by a sharded W-TinyLFU cache (`moka`).
|
||||
///
|
||||
/// The hot path (`process_packet`) acquires only the per-shard moka lock
|
||||
/// and the per-flow entry mutex — never a global tracker lock — so the
|
||||
/// inference loop's snapshot pass (`get_uninferred_flows`,
|
||||
/// `cleanup_stale_flows`) can run in parallel without stalling AF_XDP rx.
|
||||
/// W-TinyLFU's frequency sketch keeps high-rate attack flows resident
|
||||
/// even when burst noise floods the cache, which a strict-LRU eviction
|
||||
/// policy would mishandle.
|
||||
pub struct FlowTracker {
|
||||
active: Cache<FlowKey, FlowEntry>,
|
||||
limits: FlowLimits,
|
||||
}
|
||||
|
||||
impl FlowTracker {
|
||||
pub fn new(max_flows: usize, limits: FlowLimits) -> Self {
|
||||
let cap = max_flows.max(1) as u64;
|
||||
Self {
|
||||
active: Cache::builder().max_capacity(cap).build(),
|
||||
limits,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn process_packet(&self, mut packet: UserPacket, is_ingress: bool) {
|
||||
let packet_key = FlowKey::from_packet(&packet);
|
||||
let reversed_key = packet_key.reverse();
|
||||
|
||||
let (actual_key, is_forward) = if self.active.contains_key(&packet_key) {
|
||||
(packet_key, true)
|
||||
} else if self.active.contains_key(&reversed_key) {
|
||||
(reversed_key, false)
|
||||
} else {
|
||||
let syn = packet.tcp_flags & TCP_SYN != 0;
|
||||
let ack = packet.tcp_flags & TCP_ACK != 0;
|
||||
if syn && ack {
|
||||
if is_ingress {
|
||||
(reversed_key, false)
|
||||
} else {
|
||||
(packet_key, true)
|
||||
}
|
||||
} else if syn {
|
||||
(packet_key, true)
|
||||
} else if is_ingress {
|
||||
(reversed_key, false)
|
||||
} else {
|
||||
(packet_key, true)
|
||||
}
|
||||
};
|
||||
|
||||
packet.is_forward = is_forward;
|
||||
|
||||
let initiator_direction = if is_forward {
|
||||
if is_ingress {
|
||||
Direction::Ingress
|
||||
} else {
|
||||
Direction::Egress
|
||||
}
|
||||
} else if is_ingress {
|
||||
Direction::Egress
|
||||
} else {
|
||||
Direction::Ingress
|
||||
};
|
||||
|
||||
let key_for_init = actual_key.clone();
|
||||
let entry = self.active.get_with(actual_key, || {
|
||||
Arc::new(Mutex::new(FlowData::new(key_for_init, &packet, initiator_direction)))
|
||||
});
|
||||
entry.lock().add_packet(&packet, &self.limits);
|
||||
}
|
||||
|
||||
pub fn get_flow_stats<T>(&self, convert: impl Fn(&FlowData) -> T) -> Vec<T> {
|
||||
self.active.iter().map(|(_, entry)| convert(&entry.lock())).collect()
|
||||
}
|
||||
|
||||
pub fn get_uninferred_flows(&self, limit: usize) -> Vec<FlowData> {
|
||||
let mut result = Vec::new();
|
||||
for (_, entry) in self.active.iter() {
|
||||
if result.len() >= limit {
|
||||
break;
|
||||
}
|
||||
let mut flow = entry.lock();
|
||||
if flow.last_time_us > flow.last_inferred_us {
|
||||
let snapshot = FlowData {
|
||||
fwd_packets: std::mem::take(&mut flow.fwd_packets),
|
||||
bwd_packets: std::mem::take(&mut flow.bwd_packets),
|
||||
active_periods: std::mem::take(&mut flow.active_periods),
|
||||
idle_periods: std::mem::take(&mut flow.idle_periods),
|
||||
..flow.clone()
|
||||
};
|
||||
flow.last_inferred_us = flow.last_time_us;
|
||||
result.push(snapshot);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub fn flow_count(&self) -> usize {
|
||||
self.active.entry_count() as usize
|
||||
}
|
||||
|
||||
pub fn cleanup_stale_flows(&self, now_us: u64) -> usize {
|
||||
let mut keys_to_remove = Vec::new();
|
||||
for (key, entry) in self.active.iter() {
|
||||
let flow = entry.lock();
|
||||
let idle = now_us.saturating_sub(flow.last_time_us);
|
||||
let is_terminated = flow.fin_count > 0 || flow.rst_count > 0;
|
||||
let stale = if is_terminated {
|
||||
idle >= self.limits.terminated_timeout_us
|
||||
} else {
|
||||
idle >= self.limits.idle_timeout_us
|
||||
};
|
||||
if stale {
|
||||
keys_to_remove.push((*key).clone());
|
||||
}
|
||||
}
|
||||
let mut removed = 0;
|
||||
for key in keys_to_remove {
|
||||
self.active.invalidate(&key);
|
||||
removed += 1;
|
||||
}
|
||||
removed
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_limits() -> FlowLimits {
|
||||
FlowLimits {
|
||||
max_packets_per_direction: 1000,
|
||||
max_periods: 1000,
|
||||
idle_threshold_us: 1_000_000,
|
||||
bulk_min_packets: 4,
|
||||
bulk_min_bytes: 1000,
|
||||
idle_timeout_us: 120_000_000,
|
||||
terminated_timeout_us: 5_000_000,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_packet(timestamp_us: u64, tcp_flags: u8) -> UserPacket {
|
||||
UserPacket {
|
||||
ip_version: 4,
|
||||
protocol: 6,
|
||||
tcp_flags,
|
||||
src_ip: [10, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
dst_ip: [10, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
src_port: 12345,
|
||||
dst_port: 80,
|
||||
packet_length: 100,
|
||||
payload_length: 60,
|
||||
header_length: 40,
|
||||
tcp_window_size: 65535,
|
||||
timestamp_us,
|
||||
is_forward: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_count(tracker: &FlowTracker) -> usize {
|
||||
tracker.active.run_pending_tasks();
|
||||
tracker.flow_count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_removes_idle_flows() {
|
||||
let tracker = FlowTracker::new(10000, test_limits());
|
||||
let base_ts = 1_000_000_000u64;
|
||||
|
||||
let pkt = make_packet(base_ts, 0x02);
|
||||
tracker.process_packet(pkt, false);
|
||||
assert_eq!(sync_count(&tracker), 1);
|
||||
|
||||
let now = base_ts + 130_000_000;
|
||||
let removed = tracker.cleanup_stale_flows(now);
|
||||
assert_eq!(removed, 1);
|
||||
assert_eq!(sync_count(&tracker), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_keeps_active_flows() {
|
||||
let tracker = FlowTracker::new(10000, test_limits());
|
||||
let base_ts = 1_000_000_000u64;
|
||||
|
||||
let pkt = make_packet(base_ts, 0x02);
|
||||
tracker.process_packet(pkt, false);
|
||||
|
||||
let now = base_ts + 10_000_000;
|
||||
let removed = tracker.cleanup_stale_flows(now);
|
||||
assert_eq!(removed, 0);
|
||||
assert_eq!(sync_count(&tracker), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_removes_terminated_flows_after_short_idle() {
|
||||
let tracker = FlowTracker::new(10000, test_limits());
|
||||
let base_ts = 1_000_000_000u64;
|
||||
|
||||
let pkt1 = make_packet(base_ts, 0x02);
|
||||
tracker.process_packet(pkt1, false);
|
||||
|
||||
let pkt2 = make_packet(base_ts + 1_000_000, 0x01);
|
||||
tracker.process_packet(pkt2, false);
|
||||
|
||||
let now = base_ts + 7_000_000;
|
||||
let removed = tracker.cleanup_stale_flows(now);
|
||||
assert_eq!(removed, 1);
|
||||
assert_eq!(sync_count(&tracker), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_keeps_recently_terminated_flows() {
|
||||
let tracker = FlowTracker::new(10000, test_limits());
|
||||
let base_ts = 1_000_000_000u64;
|
||||
|
||||
let pkt1 = make_packet(base_ts, 0x02);
|
||||
tracker.process_packet(pkt1, false);
|
||||
|
||||
let pkt2 = make_packet(base_ts + 1_000_000, 0x01);
|
||||
tracker.process_packet(pkt2, false);
|
||||
|
||||
let now = base_ts + 3_000_000;
|
||||
let removed = tracker.cleanup_stale_flows(now);
|
||||
assert_eq!(removed, 0);
|
||||
assert_eq!(sync_count(&tracker), 1);
|
||||
}
|
||||
}
|
||||
@ -1,8 +1,9 @@
|
||||
pub mod aggregator;
|
||||
pub mod alert;
|
||||
pub mod config_loader;
|
||||
pub mod drift_detector;
|
||||
pub mod engine;
|
||||
pub mod manifest;
|
||||
pub mod flow_tracker;
|
||||
pub mod model_adapter;
|
||||
pub mod model_loader;
|
||||
pub mod model_watcher;
|
||||
pub mod runner;
|
||||
|
||||
@ -10,10 +10,13 @@ use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::SystemTime;
|
||||
|
||||
use super::manifest::LabelSpec;
|
||||
use crate::domain::detection::ml_detection::RunnableModel;
|
||||
use tract_onnx::prelude::{Graph, SimplePlan, TypedFact, TypedOp};
|
||||
|
||||
use crate::domain::detection::manifest::LabelSpec;
|
||||
use crate::domain::detection::model_source::{ModelInfo, ModelSourceStatus};
|
||||
|
||||
pub type RunnableModel = SimplePlan<TypedFact, Box<dyn TypedOp>, Graph<TypedFact, Box<dyn TypedOp>>>;
|
||||
|
||||
/// Compile-time sanity: `RunnableModel` must be `Send + Sync` because we
|
||||
/// stuff it inside an `ArcSwap`. If a future `tract-onnx` upgrade silently
|
||||
/// drops the bounds, this line stops compiling and we catch it before it
|
||||
@ -16,13 +16,13 @@ use tract_onnx::prelude::*;
|
||||
use tract_onnx::tract_hir::infer::Factoid;
|
||||
use tract_onnx::tract_hir::internal::DimLike;
|
||||
|
||||
use crate::core::inference::model_adapter::MLModelAdapter;
|
||||
use crate::core::inference::model_adapter::RunnableModel;
|
||||
use crate::domain::common::config::constants::MODELS_DIR;
|
||||
use crate::domain::detection::error::MLError;
|
||||
use crate::domain::detection::log::MLLog;
|
||||
use crate::domain::detection::manifest::{AdapterKind, LabelSpec, ModelManifest};
|
||||
use crate::domain::detection::ml_detection::RunnableModel;
|
||||
use crate::domain::detection::ml_inference_config::MLInferenceConfig;
|
||||
use crate::domain::detection::model_adapter::MLModelAdapter;
|
||||
|
||||
/// Build an `MLModelAdapter` by loading the ONNX file(s) the manifest names,
|
||||
/// validating shape against the inference config's feature counts, and
|
||||
|
||||
@ -16,12 +16,12 @@ use tokio::time::sleep;
|
||||
|
||||
use super::model_loader::build_adapter;
|
||||
use super::runner::Inference;
|
||||
use crate::core::inference::model_adapter::ModelSourceState;
|
||||
use crate::domain::common::config::AppConfig;
|
||||
use crate::domain::common::config::constants::{MANIFEST_FILENAME, MODELS_DIR, STAGING_SUBDIR};
|
||||
use crate::domain::detection::error::MLError;
|
||||
use crate::domain::detection::log::MLLog;
|
||||
use crate::domain::detection::ml_inference_config::MLInferenceConfig;
|
||||
use crate::domain::detection::model_adapter::ModelSourceState;
|
||||
use crate::domain::detection::model_source::ModelInfo;
|
||||
|
||||
pub struct ModelWatcher {
|
||||
|
||||
@ -20,14 +20,14 @@ use arc_swap::ArcSwap;
|
||||
use macros::log;
|
||||
use tract_onnx::prelude::*;
|
||||
|
||||
use crate::core::inference::model_adapter::{MLModelAdapter, ModelSourceState, RunnableModel};
|
||||
use crate::domain::common::config::AppConfig;
|
||||
use crate::domain::detection::flow_features::FlowFeatures;
|
||||
use crate::domain::detection::flow_tracker::FlowData;
|
||||
use crate::domain::detection::log::MLLog;
|
||||
use crate::domain::detection::manifest::LabelSpec;
|
||||
use crate::domain::detection::ml_detection::{DetectionResult, RunnableModel};
|
||||
use crate::domain::detection::ml_detection::DetectionResult;
|
||||
use crate::domain::detection::ml_inference_config::MLInferenceConfig;
|
||||
use crate::domain::detection::model_adapter::{MLModelAdapter, ModelSourceState};
|
||||
use crate::domain::detection::model_source::ModelSourceStatus;
|
||||
|
||||
/// (anomaly_scores, per_class_probs, c2_scores) — MultiTask batch output.
|
||||
@ -80,7 +80,7 @@ impl Inference {
|
||||
|
||||
/// Current state snapshot for wire broadcast. Merges the in-memory
|
||||
/// `qps_recent` atomic into the Active info so the UI sees live QPS.
|
||||
pub fn current_status(&self) -> ModelSourceStatus {
|
||||
pub fn model_source_status(&self) -> ModelSourceStatus {
|
||||
let guard = self.state.load();
|
||||
let mut status = guard.to_status();
|
||||
if let ModelSourceStatus::Active { ref mut info } = status {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -2,111 +2,17 @@ use std::sync::Arc;
|
||||
|
||||
use arc_swap::ArcSwap;
|
||||
use chrono::{Local, Weekday};
|
||||
use lettre::message::header::ContentType;
|
||||
use lettre::transport::smtp::authentication::Credentials;
|
||||
use lettre::{Message, SmtpTransport, Transport};
|
||||
use macros::log;
|
||||
use tokio::task::{JoinHandle, spawn_blocking};
|
||||
use tokio::time::{self, Duration};
|
||||
|
||||
use super::email_report as report;
|
||||
use crate::adapter::notification::smtp::SmtpClient;
|
||||
use crate::domain::common::config::AppConfig;
|
||||
use crate::domain::common::config::notification::SmtpConfig;
|
||||
use crate::domain::common::error::Error;
|
||||
use crate::domain::common::error::notification::NotificationError;
|
||||
use crate::domain::common::log::system::SystemLog;
|
||||
use crate::interface::port::secret_store::SecretStorePort;
|
||||
use crate::interface::port::setting::SettingRepo;
|
||||
|
||||
pub struct SmtpClient {
|
||||
host: String,
|
||||
port: u16,
|
||||
username: String,
|
||||
password: String,
|
||||
/// The sender email address. Falls back to `username` if not set.
|
||||
sender: String,
|
||||
}
|
||||
|
||||
impl SmtpClient {
|
||||
pub fn from_config(cfg: &SmtpConfig, secrets: Option<&dyn SecretStorePort>) -> Result<Option<Self>, Error> {
|
||||
if cfg.host.is_empty() || cfg.username.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let password = match secrets.and_then(|ss| ss.get_secret("smtp_password").ok().flatten()) {
|
||||
Some(pw) if !pw.is_empty() => pw,
|
||||
_ => return Ok(None),
|
||||
};
|
||||
|
||||
let sender = if cfg.sender.is_empty() {
|
||||
cfg.username.clone()
|
||||
} else {
|
||||
cfg.sender.clone()
|
||||
};
|
||||
|
||||
if !sender.contains('@') {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(Some(Self {
|
||||
host: cfg.host.clone(),
|
||||
port: cfg.port,
|
||||
username: cfg.username.clone(),
|
||||
password,
|
||||
sender,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Send an HTML email using the configured SMTP transport.
|
||||
pub fn send(&self, to: &str, subject: &str, html_body: &str) -> Result<(), Error> {
|
||||
let from_addr = self
|
||||
.sender
|
||||
.parse()
|
||||
.map_err(|e| NotificationError::InvalidAddress("from", e))?;
|
||||
let to_addr = to.parse().map_err(|e| NotificationError::InvalidAddress("to", e))?;
|
||||
|
||||
let email = Message::builder()
|
||||
.from(from_addr)
|
||||
.to(to_addr)
|
||||
.subject(subject)
|
||||
.header(ContentType::TEXT_HTML)
|
||||
.body(html_body.to_string())
|
||||
.map_err(NotificationError::MessageBuildFailed)?;
|
||||
|
||||
let creds = Credentials::new(self.username.clone(), self.password.clone());
|
||||
|
||||
let mailer = match self.port {
|
||||
465 => {
|
||||
// Implicit TLS (SMTPS)
|
||||
SmtpTransport::relay(&self.host)
|
||||
.map_err(NotificationError::SmtpConnectionFailed)?
|
||||
.port(self.port)
|
||||
.credentials(creds)
|
||||
.build()
|
||||
}
|
||||
25 | 587 => {
|
||||
// STARTTLS (standard submission ports)
|
||||
SmtpTransport::starttls_relay(&self.host)
|
||||
.map_err(NotificationError::SmtpConnectionFailed)?
|
||||
.port(self.port)
|
||||
.credentials(creds)
|
||||
.build()
|
||||
}
|
||||
_ => {
|
||||
// Non-standard port — use unencrypted transport with credentials
|
||||
SmtpTransport::builder_dangerous(&self.host)
|
||||
.port(self.port)
|
||||
.credentials(creds)
|
||||
.build()
|
||||
}
|
||||
};
|
||||
|
||||
mailer.send(&email).map_err(NotificationError::SmtpSendFailed)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ReportScheduler {
|
||||
db: Arc<dyn SettingRepo + Send + Sync>,
|
||||
config: Arc<ArcSwap<AppConfig>>,
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
pub mod email_report;
|
||||
pub mod email_scheduler;
|
||||
pub mod report_data_builder;
|
||||
pub mod report_engine;
|
||||
pub mod stats_aggregator;
|
||||
|
||||
124
net-guardia/src/core/reporting/report_data_builder.rs
Normal file
124
net-guardia/src/core/reporting/report_data_builder.rs
Normal file
@ -0,0 +1,124 @@
|
||||
use chrono::{Duration as ChronoDuration, Local};
|
||||
|
||||
use crate::domain::common::error::Error;
|
||||
use crate::domain::report::data::{
|
||||
BlockedIpItem, ExecutiveSummary, GeoItem, ReportData, SoarActivity, SystemHealthSummary, ThreatBreakdownItem,
|
||||
};
|
||||
use crate::interface::port::setting::SettingRepo;
|
||||
|
||||
pub fn build_report_data(db: &dyn SettingRepo) -> Result<ReportData, Error> {
|
||||
let now = Local::now();
|
||||
let period = format!(
|
||||
"{} — {}",
|
||||
(now - ChronoDuration::days(7)).format("%Y-%m-%d"),
|
||||
now.format("%Y-%m-%d")
|
||||
);
|
||||
|
||||
let threats_count: u64 = db
|
||||
.get_setting("weekly_threats_count")?
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
let top_ips: Vec<BlockedIpItem> = db
|
||||
.get_setting("weekly_top_ips")?
|
||||
.and_then(|v| serde_json::from_str(&v).ok())
|
||||
.unwrap_or_else(|| {
|
||||
vec![BlockedIpItem {
|
||||
ip: "—".into(),
|
||||
count: 0,
|
||||
country: "N/A".into(),
|
||||
}]
|
||||
});
|
||||
|
||||
let breakdown: Vec<ThreatBreakdownItem> = db
|
||||
.get_setting("weekly_threat_breakdown")?
|
||||
.and_then(|v| {
|
||||
let obj: serde_json::Value = serde_json::from_str(&v).ok()?;
|
||||
let items = obj
|
||||
.as_object()?
|
||||
.iter()
|
||||
.map(|(k, v)| ThreatBreakdownItem {
|
||||
threat_type: k.clone(),
|
||||
count: v.as_u64().unwrap_or(0),
|
||||
trend: "—".into(),
|
||||
})
|
||||
.collect();
|
||||
Some(items)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let health: SystemHealthSummary = db
|
||||
.get_setting("weekly_system_health")?
|
||||
.and_then(|v| serde_json::from_str(&v).ok())
|
||||
.unwrap_or(SystemHealthSummary {
|
||||
avg_cpu_percent: 0.0,
|
||||
avg_memory_percent: 0.0,
|
||||
disk_usage_percent: 0.0,
|
||||
ebpf_status: "running".into(),
|
||||
});
|
||||
|
||||
let mut recommendations = Vec::new();
|
||||
if threats_count > 10 {
|
||||
recommendations.push("Consider enabling geo-blocking for high-risk regions".into());
|
||||
}
|
||||
if breakdown.iter().any(|b| b.threat_type == "port_scan" && b.count > 50) {
|
||||
recommendations.push("Review exposed ports and consider tightening protocol filter rules".into());
|
||||
}
|
||||
if recommendations.is_empty() {
|
||||
recommendations.push("No action needed — your network security posture is healthy".into());
|
||||
}
|
||||
|
||||
let uptime_percent: f64 = db
|
||||
.get_setting("system_uptime_percent")?
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(0.0);
|
||||
|
||||
let active_rules: u64 = db
|
||||
.get_setting("active_rules_count")?
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
let geo_distribution: Vec<GeoItem> = db
|
||||
.get_setting("weekly_geo_distribution")?
|
||||
.and_then(|v| serde_json::from_str(&v).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
let auto_blocks: u64 = db
|
||||
.get_setting("weekly_soar_blocks")?
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(0);
|
||||
let playbooks_triggered: u64 = db
|
||||
.get_setting("weekly_soar_triggers")?
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(0);
|
||||
let auto_unblocks: u64 = db
|
||||
.get_setting("weekly_soar_unblocks")?
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
let blocked_count: u64 = db
|
||||
.get_setting("weekly_blocked_count")?
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(auto_blocks);
|
||||
|
||||
Ok(ReportData {
|
||||
period,
|
||||
generated_at: now.format("%Y-%m-%d %H:%M:%S").to_string(),
|
||||
executive_summary: ExecutiveSummary {
|
||||
total_threats: threats_count,
|
||||
total_blocked: blocked_count,
|
||||
uptime_percent,
|
||||
active_rules,
|
||||
},
|
||||
threat_breakdown: breakdown,
|
||||
top_blocked_ips: top_ips,
|
||||
geo_distribution,
|
||||
soar_activity: SoarActivity {
|
||||
auto_blocks_executed: auto_blocks,
|
||||
playbooks_triggered,
|
||||
auto_unblocks,
|
||||
},
|
||||
system_health: health,
|
||||
recommendations,
|
||||
})
|
||||
}
|
||||
@ -4,6 +4,7 @@ use std::path::PathBuf;
|
||||
use chrono::Local;
|
||||
use macros::log;
|
||||
|
||||
use super::report_data_builder::build_report_data;
|
||||
use crate::domain::common::error::Error;
|
||||
use crate::domain::common::error::io::IOError;
|
||||
use crate::domain::common::error::misc::MiscError;
|
||||
@ -14,7 +15,7 @@ use crate::interface::port::setting::SettingRepo;
|
||||
/// Generate a self-contained HTML security report and write to disk.
|
||||
/// Returns the path to the generated HTML file.
|
||||
pub fn generate_html_report(db: &dyn SettingRepo, output_dir: &str) -> Result<PathBuf, Error> {
|
||||
let data = ReportData::from_database(db)?;
|
||||
let data = build_report_data(db)?;
|
||||
let html = render_html_report(&data);
|
||||
|
||||
let html_path = PathBuf::from(output_dir).join(format!(
|
||||
@ -203,6 +204,6 @@ fn html_escape(s: &str) -> String {
|
||||
|
||||
/// Generate report data and format as JSON (for API responses).
|
||||
pub fn generate_report_json(db: &dyn SettingRepo) -> Result<serde_json::Value, Error> {
|
||||
let data = ReportData::from_database(db)?;
|
||||
let data = build_report_data(db)?;
|
||||
serde_json::to_value(&data).map_err(|e| MiscError::SerializeError(e).into())
|
||||
}
|
||||
|
||||
@ -7,19 +7,19 @@ use tokio::time::{self, Duration};
|
||||
|
||||
use crate::domain::common::error::Error;
|
||||
use crate::domain::common::log::system::SystemLog;
|
||||
use crate::infrastructure::health::SystemHealth;
|
||||
use crate::interface::port::setting::SettingRepo;
|
||||
use crate::interface::port::stats::StatsRepo;
|
||||
|
||||
/// Background service that periodically aggregates statistics from SOAR/ML tables
|
||||
/// and writes them to the settings table for the Report engine to consume.
|
||||
pub struct StatsAggregator {
|
||||
stats: Arc<dyn StatsRepo>,
|
||||
repo: Arc<dyn SettingRepo + Send + Sync>,
|
||||
health: Arc<SystemHealth>,
|
||||
}
|
||||
|
||||
impl StatsAggregator {
|
||||
pub fn new(stats: Arc<dyn StatsRepo>, repo: Arc<dyn SettingRepo + Send + Sync>) -> Self {
|
||||
Self { stats, repo }
|
||||
pub fn new(stats: Arc<dyn StatsRepo>, repo: Arc<dyn SettingRepo + Send + Sync>, health: Arc<SystemHealth>) -> Self {
|
||||
Self { stats, repo, health }
|
||||
}
|
||||
|
||||
/// Spawn a background task that runs aggregation every hour.
|
||||
@ -93,34 +93,23 @@ impl StatsAggregator {
|
||||
let active_rules = self.stats.count_acl_rules()?;
|
||||
self.repo.set_setting("active_rules_count", &active_rules.to_string())?;
|
||||
|
||||
// System health snapshot using sysinfo
|
||||
{
|
||||
use sysinfo::System;
|
||||
let mut sys = System::new();
|
||||
sys.refresh_cpu_all();
|
||||
sys.refresh_memory();
|
||||
let cpu_usage = sys.global_cpu_usage() as f64;
|
||||
let mem_total = sys.total_memory();
|
||||
let mem_used = sys.used_memory();
|
||||
let mem_percent = if mem_total > 0 {
|
||||
(mem_used as f64 / mem_total as f64) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let metrics = self.health.get_current_metrics();
|
||||
let cpu_usage = metrics.cpu_details.cpu_usage as f64;
|
||||
let mem_percent = metrics.memory_usage.usage_percent as f64;
|
||||
|
||||
let health_json = serde_json::json!({
|
||||
"avg_cpu_percent": cpu_usage,
|
||||
"avg_memory_percent": mem_percent,
|
||||
"disk_usage_percent": 0.0,
|
||||
"ebpf_status": "running",
|
||||
"ebpf_status": format!("{:?}", metrics.ebpf),
|
||||
});
|
||||
self.repo.set_setting(
|
||||
"weekly_system_health",
|
||||
&serde_json::to_string(&health_json).unwrap_or_else(|_| "{}".to_string()),
|
||||
)?;
|
||||
|
||||
// System uptime
|
||||
let uptime_secs = System::uptime();
|
||||
let uptime_secs = metrics.uptime_seconds;
|
||||
let week_secs = (days as u64) * 86400;
|
||||
let uptime_percent = if uptime_secs >= week_secs {
|
||||
100.0
|
||||
@ -151,6 +140,15 @@ impl StatsAggregator {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::adapter::persistence::Database;
|
||||
use crate::domain::common::config::AppConfig;
|
||||
use crate::domain::common::system::health::EbpfHealth;
|
||||
|
||||
fn test_health(db: &Arc<Database>) -> Arc<SystemHealth> {
|
||||
use arc_swap::ArcSwap;
|
||||
let config = Arc::new(ArcSwap::from_pointee(AppConfig::from_settings(db.as_ref()).unwrap()));
|
||||
let ebpf_health = Arc::new(ArcSwap::from_pointee(EbpfHealth::Healthy));
|
||||
Arc::new(SystemHealth::new(config, ebpf_health).unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aggregator_writes_weekly_stats() {
|
||||
@ -163,13 +161,14 @@ mod tests {
|
||||
db.insert_soar_execution(1, Some("5.6.7.8"), "brute_force", "[]").ok();
|
||||
db.insert_soar_block_rule("1.2.3.4", 1, "2099-01-01 00:00:00").ok();
|
||||
|
||||
let health = test_health(&db);
|
||||
let aggregator = StatsAggregator::new(
|
||||
db.clone() as Arc<dyn StatsRepo>,
|
||||
db.clone() as Arc<dyn SettingRepo + Send + Sync>,
|
||||
health,
|
||||
);
|
||||
aggregator.aggregate().expect("aggregation should succeed");
|
||||
|
||||
// Verify settings were written
|
||||
let threats = db.get_setting("weekly_threats_count").unwrap().unwrap();
|
||||
assert_eq!(threats, "2");
|
||||
|
||||
@ -187,17 +186,19 @@ mod tests {
|
||||
let uptime = db.get_setting("system_uptime_percent").unwrap().unwrap();
|
||||
assert!(!uptime.is_empty());
|
||||
|
||||
let health = db.get_setting("weekly_system_health").unwrap().unwrap();
|
||||
let health_val: serde_json::Value = serde_json::from_str(&health).unwrap();
|
||||
assert!(health_val["ebpf_status"].as_str() == Some("running"));
|
||||
let sys_health = db.get_setting("weekly_system_health").unwrap().unwrap();
|
||||
let health_val: serde_json::Value = serde_json::from_str(&sys_health).unwrap();
|
||||
assert!(health_val["avg_cpu_percent"].as_f64().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aggregator_handles_empty_db() {
|
||||
let db = Arc::new(Database::new(":memory:").expect("test db"));
|
||||
let health = test_health(&db);
|
||||
let aggregator = StatsAggregator::new(
|
||||
db.clone() as Arc<dyn StatsRepo>,
|
||||
db.clone() as Arc<dyn SettingRepo + Send + Sync>,
|
||||
health,
|
||||
);
|
||||
aggregator
|
||||
.aggregate()
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::core::response::engine::SoarEngine;
|
||||
@ -9,6 +8,7 @@ use crate::domain::response::playbook_data::{
|
||||
};
|
||||
use crate::interface::port::access_control::AccessControlPort;
|
||||
use crate::interface::port::app_repo::AppRepo;
|
||||
use crate::utils::ip_address::ip_version_from_str;
|
||||
|
||||
/// Domain service for SOAR playbook CRUD operations.
|
||||
/// Coordinates DB reads/writes, SOAR engine cache refresh, and eBPF unblock.
|
||||
@ -157,41 +157,3 @@ impl PlaybookService {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Determine IP version from a string address using proper parsing.
|
||||
pub fn ip_version_from_str(ip: &str) -> u8 {
|
||||
match ip.parse::<IpAddr>() {
|
||||
Ok(IpAddr::V4(_)) => 4,
|
||||
Ok(IpAddr::V6(_)) => 6,
|
||||
Err(_) => {
|
||||
if ip.contains(':') {
|
||||
6
|
||||
} else {
|
||||
4
|
||||
}
|
||||
} // fallback
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ip_version_from_str_ipv4() {
|
||||
assert_eq!(ip_version_from_str("1.2.3.4"), 4);
|
||||
assert_eq!(ip_version_from_str("192.168.1.1"), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ip_version_from_str_ipv6() {
|
||||
assert_eq!(ip_version_from_str("::1"), 6);
|
||||
assert_eq!(ip_version_from_str("2001:db8::1"), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ip_version_from_str_ipv4_mapped_ipv6() {
|
||||
// ::ffff:1.2.3.4 should be recognized as IPv6 (it is an IPv6 address)
|
||||
assert_eq!(ip_version_from_str("::ffff:1.2.3.4"), 6);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
10
net-guardia/src/domain/common/audit.rs
Normal file
10
net-guardia/src/domain/common/audit.rs
Normal file
@ -0,0 +1,10 @@
|
||||
/// Audit log entry returned by `list_audit_logs` and
|
||||
/// `verify_audit_log_chain` APIs.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuditLogEntry {
|
||||
pub id: i64,
|
||||
pub actor: String,
|
||||
pub action: String,
|
||||
pub detail: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
24
net-guardia/src/domain/common/config/health.rs
Normal file
24
net-guardia/src/domain/common/config/health.rs
Normal file
@ -0,0 +1,24 @@
|
||||
use macros::config_settings;
|
||||
|
||||
#[config_settings(section = "health")]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HealthConfig {
|
||||
#[setting(key = "health_cpu_issue_percent", default = "90.0")]
|
||||
pub cpu_issue_percent: f32,
|
||||
#[setting(key = "health_cpu_warn_percent", default = "75.0")]
|
||||
pub cpu_warn_percent: f32,
|
||||
#[setting(key = "health_mem_issue_percent", default = "95.0")]
|
||||
pub mem_issue_percent: f32,
|
||||
#[setting(key = "health_mem_warn_percent", default = "80.0")]
|
||||
pub mem_warn_percent: f32,
|
||||
#[setting(key = "health_disk_issue_percent", default = "95.0")]
|
||||
pub disk_issue_percent: f32,
|
||||
#[setting(key = "health_disk_warn_percent", default = "90.0")]
|
||||
pub disk_warn_percent: f32,
|
||||
#[setting(key = "health_temp_issue_celsius", default = "80.0")]
|
||||
pub temp_issue_celsius: f32,
|
||||
#[setting(key = "health_temp_warn_celsius", default = "70.0")]
|
||||
pub temp_warn_celsius: f32,
|
||||
#[setting(key = "health_monitoring_interval_secs", default = "5")]
|
||||
pub monitoring_interval_secs: u64,
|
||||
}
|
||||
@ -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)?;
|
||||
|
||||
@ -37,15 +37,3 @@ traceable! {
|
||||
AuditRowHashMismatch { id: i64, computed: String, stored: String } => tracing::Level::ERROR,
|
||||
}
|
||||
}
|
||||
|
||||
impl From<rusqlite::Error> for DatabaseError {
|
||||
fn from(e: rusqlite::Error) -> Self {
|
||||
DatabaseError::QueryFailed(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<rusqlite::Error> for super::Error {
|
||||
fn from(e: rusqlite::Error) -> Self {
|
||||
Self::Database(DatabaseError::from(e))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
11
net-guardia/src/domain/common/notification.rs
Normal file
11
net-guardia/src/domain/common/notification.rs
Normal file
@ -0,0 +1,11 @@
|
||||
/// Alert notification data sent by SOAR engine.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AlertPayload {
|
||||
pub source_ip: String,
|
||||
pub dest_ip: String,
|
||||
pub country: Option<String>,
|
||||
pub threat_type: String,
|
||||
pub confidence: f32,
|
||||
pub action_description: String,
|
||||
pub timestamp: String,
|
||||
}
|
||||
@ -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,
|
||||
|
||||
9
net-guardia/src/domain/data_plane/acl_rule.rs
Normal file
9
net-guardia/src/domain/data_plane/acl_rule.rs
Normal file
@ -0,0 +1,9 @@
|
||||
/// Stored ACL rule view.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct AclRuleView {
|
||||
pub ip_version: u8,
|
||||
pub direction: String,
|
||||
pub list_type: String,
|
||||
pub ip_address: String,
|
||||
pub port: u16,
|
||||
}
|
||||
@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
pub mod acl_rule;
|
||||
pub mod direction;
|
||||
pub mod drop_event;
|
||||
pub mod error;
|
||||
|
||||
@ -1,224 +0,0 @@
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use dashmap::DashMap;
|
||||
use macros::log;
|
||||
|
||||
use crate::domain::common::event::{DetectionEvent, DetectionSource};
|
||||
use crate::domain::detection::log::DetectionLog;
|
||||
use crate::domain::detection::ml_detection::AlertMessage;
|
||||
|
||||
type FlowTuple = (String, String, u16);
|
||||
|
||||
struct CachedFlow {
|
||||
timestamps: Vec<Instant>,
|
||||
last_alerted: Option<Instant>,
|
||||
}
|
||||
|
||||
pub struct BeaconingState {
|
||||
flow_cache: DashMap<FlowTuple, CachedFlow>,
|
||||
min_observations: usize,
|
||||
cv_threshold: f64,
|
||||
max_cache_entries: usize,
|
||||
expiry_secs: u64,
|
||||
alert_cooldown_secs: u64,
|
||||
}
|
||||
|
||||
impl BeaconingState {
|
||||
pub fn new(
|
||||
min_observations: usize,
|
||||
cv_threshold: f64,
|
||||
max_cache_entries: usize,
|
||||
expiry_secs: u64,
|
||||
alert_cooldown_secs: u64,
|
||||
) -> Self {
|
||||
Self {
|
||||
flow_cache: DashMap::new(),
|
||||
min_observations,
|
||||
cv_threshold,
|
||||
max_cache_entries,
|
||||
expiry_secs,
|
||||
alert_cooldown_secs,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_flow(&self, alert: &AlertMessage) {
|
||||
let key = (alert.src_ip.clone(), alert.dst_ip.clone(), alert.dst_port);
|
||||
let now = Instant::now();
|
||||
|
||||
let mut entry = self.flow_cache.entry(key).or_insert_with(|| CachedFlow {
|
||||
timestamps: Vec::new(),
|
||||
last_alerted: None,
|
||||
});
|
||||
|
||||
entry.timestamps.push(now);
|
||||
|
||||
if entry.timestamps.len() > 100 {
|
||||
let excess = entry.timestamps.len() - 100;
|
||||
entry.timestamps.drain(..excess);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn analyze(&self) -> Vec<DetectionEvent> {
|
||||
let now = Instant::now();
|
||||
let cooldown = Duration::from_secs(self.alert_cooldown_secs);
|
||||
|
||||
let mut candidates: Vec<(FlowTuple, f64, usize)> = Vec::new();
|
||||
for entry in self.flow_cache.iter() {
|
||||
let flow = entry.value();
|
||||
if flow.timestamps.len() < self.min_observations {
|
||||
continue;
|
||||
}
|
||||
if let Some(last) = flow.last_alerted
|
||||
&& now.duration_since(last) < cooldown
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let cv = compute_cv(&flow.timestamps);
|
||||
if cv < self.cv_threshold {
|
||||
candidates.push((entry.key().clone(), cv, flow.timestamps.len()));
|
||||
}
|
||||
}
|
||||
|
||||
let mut events = Vec::new();
|
||||
for (key, cv, count) in candidates {
|
||||
let (src_ip, dst_ip, dst_port) = &key;
|
||||
log!(DetectionLog::BeaconingDetected(
|
||||
src_ip.clone(),
|
||||
dst_ip.clone(),
|
||||
*dst_port,
|
||||
cv,
|
||||
count,
|
||||
));
|
||||
|
||||
events.push(DetectionEvent {
|
||||
source: DetectionSource::Beaconing,
|
||||
attack_type: "c2_communication".to_string(),
|
||||
confidence: (1.0 - cv / self.cv_threshold) as f32 * 0.5 + 0.5,
|
||||
source_ip: src_ip.clone(),
|
||||
dest_ip: dst_ip.clone(),
|
||||
protocol: 6,
|
||||
packet_count: count as u64,
|
||||
flow_duration_us: 0,
|
||||
ae_score: 0.0,
|
||||
anomaly_score: 0.0,
|
||||
c2_score: 0.0,
|
||||
});
|
||||
|
||||
if let Some(mut entry) = self.flow_cache.get_mut(&key) {
|
||||
entry.last_alerted = Some(now);
|
||||
}
|
||||
}
|
||||
|
||||
events
|
||||
}
|
||||
|
||||
pub fn cleanup(&self) {
|
||||
let now = Instant::now();
|
||||
let expiry = Duration::from_secs(self.expiry_secs);
|
||||
|
||||
self.flow_cache.retain(|_, flow| {
|
||||
flow.timestamps
|
||||
.last()
|
||||
.is_some_and(|last| now.duration_since(*last) < expiry)
|
||||
});
|
||||
|
||||
if self.flow_cache.len() > self.max_cache_entries {
|
||||
let excess = self.flow_cache.len() - self.max_cache_entries;
|
||||
let keys_to_remove: Vec<FlowTuple> = self.flow_cache.iter().take(excess).map(|e| e.key().clone()).collect();
|
||||
for key in keys_to_remove {
|
||||
self.flow_cache.remove(&key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn compute_cv(timestamps: &[Instant]) -> f64 {
|
||||
if timestamps.len() < 2 {
|
||||
return f64::MAX;
|
||||
}
|
||||
|
||||
let intervals: Vec<f64> = timestamps
|
||||
.windows(2)
|
||||
.map(|w| w[1].duration_since(w[0]).as_secs_f64())
|
||||
.collect();
|
||||
|
||||
let n = intervals.len() as f64;
|
||||
let mean = intervals.iter().sum::<f64>() / n;
|
||||
|
||||
if mean <= 0.0 {
|
||||
return f64::MAX;
|
||||
}
|
||||
|
||||
let variance = intervals.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / n;
|
||||
let std = variance.sqrt();
|
||||
|
||||
std / mean
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cv_perfectly_periodic() {
|
||||
let base = Instant::now();
|
||||
let timestamps: Vec<Instant> = (0..10).map(|i| base + Duration::from_secs(i * 60)).collect();
|
||||
let cv = compute_cv(×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");
|
||||
}
|
||||
}
|
||||
@ -1,140 +0,0 @@
|
||||
use std::collections::VecDeque;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::domain::detection::drift::{DriftReport, FeatureBaselines};
|
||||
|
||||
pub struct DriftDetector {
|
||||
snapshots: VecDeque<(Instant, Vec<f64>)>,
|
||||
num_features: usize,
|
||||
baselines: Option<FeatureBaselines>,
|
||||
drift_window: Duration,
|
||||
max_snapshots: usize,
|
||||
}
|
||||
|
||||
impl DriftDetector {
|
||||
pub fn new(baselines: Option<FeatureBaselines>, drift_window: Duration, max_snapshots: usize) -> Self {
|
||||
let num_features = baselines.as_ref().map_or(0, |b| b.names.len());
|
||||
Self {
|
||||
snapshots: VecDeque::new(),
|
||||
num_features,
|
||||
baselines,
|
||||
drift_window,
|
||||
max_snapshots,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update(&mut self, features: &[f64]) {
|
||||
let now = Instant::now();
|
||||
self.snapshots.push_back((now, features.to_vec()));
|
||||
self.evict_stale(now);
|
||||
while self.snapshots.len() > self.max_snapshots {
|
||||
self.snapshots.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn check_drift(&self) -> Option<DriftReport> {
|
||||
let baselines = self.baselines.as_ref()?;
|
||||
if self.snapshots.is_empty() || self.num_features == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let n = self.snapshots.len() as f64;
|
||||
let mut sums = vec![0.0_f64; self.num_features];
|
||||
|
||||
for (_, features) in &self.snapshots {
|
||||
for (i, &val) in features.iter().enumerate().take(self.num_features) {
|
||||
sums[i] += val;
|
||||
}
|
||||
}
|
||||
|
||||
let mut drifted_features = Vec::new();
|
||||
let mut max_deviation = 0.0_f64;
|
||||
|
||||
for (i, (sum, (bl_mean, bl_std))) in sums
|
||||
.iter()
|
||||
.zip(baselines.means.iter().zip(baselines.stds.iter()))
|
||||
.enumerate()
|
||||
.take(self.num_features)
|
||||
{
|
||||
let current_mean = sum / n;
|
||||
|
||||
if *bl_std < 1e-12 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let deviation = ((current_mean - bl_mean) / bl_std).abs();
|
||||
if deviation > 3.0 {
|
||||
drifted_features.push(baselines.names[i].clone());
|
||||
if deviation > max_deviation {
|
||||
max_deviation = deviation;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if drifted_features.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(DriftReport {
|
||||
drifted_features,
|
||||
max_deviation,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn evict_stale(&mut self, now: Instant) {
|
||||
while let Some((ts, _)) = self.snapshots.front() {
|
||||
if now.duration_since(*ts) > self.drift_window {
|
||||
self.snapshots.pop_front();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_baselines(n: usize) -> FeatureBaselines {
|
||||
FeatureBaselines {
|
||||
names: (0..n).map(|i| format!("feature_{i}")).collect(),
|
||||
means: vec![0.0; n],
|
||||
stds: vec![1.0; n],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_drift_when_within_threshold() {
|
||||
let baselines = make_baselines(3);
|
||||
let mut detector = DriftDetector::new(Some(baselines), Duration::from_secs(3600), 10_000);
|
||||
detector.update(&[1.0, -1.0, 2.0]);
|
||||
detector.update(&[0.5, -0.5, 1.5]);
|
||||
assert!(detector.check_drift().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drift_detected_when_exceeds_threshold() {
|
||||
let baselines = make_baselines(3);
|
||||
let mut detector = DriftDetector::new(Some(baselines), Duration::from_secs(3600), 10_000);
|
||||
detector.update(&[5.0, 0.0, 0.0]);
|
||||
detector.update(&[5.0, 0.0, 0.0]);
|
||||
let report = detector.check_drift().unwrap();
|
||||
assert!(report.drifted_features.contains(&"feature_0".to_string()));
|
||||
assert!(report.max_deviation > 3.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_baselines_means_no_drift() {
|
||||
let mut detector = DriftDetector::new(None, Duration::from_secs(3600), 10_000);
|
||||
detector.update(&[100.0, 200.0]);
|
||||
assert!(detector.check_drift().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_snapshots_no_drift() {
|
||||
let baselines = make_baselines(3);
|
||||
let detector = DriftDetector::new(Some(baselines), Duration::from_secs(3600), 10_000);
|
||||
assert!(detector.check_drift().is_none());
|
||||
}
|
||||
}
|
||||
@ -1,8 +1,4 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use common::define::tcp_flags::*;
|
||||
use moka::sync::Cache;
|
||||
use parking_lot::Mutex;
|
||||
|
||||
use crate::domain::data_plane::direction::Direction;
|
||||
use crate::domain::data_plane::user_packet::UserPacket;
|
||||
@ -49,7 +45,7 @@ pub struct FlowData {
|
||||
pub fwd_bulk_state: BulkState,
|
||||
pub bwd_bulk_state: BulkState,
|
||||
pub act_data_pkt_fwd: u32,
|
||||
is_first_packet: bool,
|
||||
pub(crate) is_first_packet: bool,
|
||||
/// Timestamp (us) when this flow was last sent to ML inference.
|
||||
/// 0 means never inferred. Used to avoid re-inferring unchanged flows.
|
||||
pub last_inferred_us: u64,
|
||||
@ -214,253 +210,3 @@ impl FlowData {
|
||||
self.fwd_packets.len() + self.bwd_packets.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-flow handle: an `Arc` so map operations stay copy-cheap, with an inner
|
||||
/// `Mutex` because `add_packet` is a read-modify-write that needs exclusive
|
||||
/// access. Same-flow packets land on the same XSK queue (symmetric eBPF
|
||||
/// hash), so this mutex is effectively single-writer; the inference tick
|
||||
/// briefly contends only when it clones the entry for a snapshot.
|
||||
type FlowEntry = Arc<Mutex<FlowData>>;
|
||||
|
||||
/// Per-queue flow tracker backed by a sharded W-TinyLFU cache (`moka`).
|
||||
///
|
||||
/// The hot path (`process_packet`) acquires only the per-shard moka lock
|
||||
/// and the per-flow entry mutex — never a global tracker lock — so the
|
||||
/// inference loop's snapshot pass (`get_uninferred_flows`,
|
||||
/// `cleanup_stale_flows`) can run in parallel without stalling AF_XDP rx.
|
||||
/// W-TinyLFU's frequency sketch keeps high-rate attack flows resident
|
||||
/// even when burst noise floods the cache, which a strict-LRU eviction
|
||||
/// policy would mishandle.
|
||||
pub struct FlowTracker {
|
||||
active: Cache<FlowKey, FlowEntry>,
|
||||
limits: FlowLimits,
|
||||
}
|
||||
|
||||
impl FlowTracker {
|
||||
pub fn new(max_flows: usize, limits: FlowLimits) -> Self {
|
||||
let cap = max_flows.max(1) as u64;
|
||||
Self {
|
||||
active: Cache::builder().max_capacity(cap).build(),
|
||||
limits,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn process_packet(&self, mut packet: UserPacket, is_ingress: bool) {
|
||||
let packet_key = FlowKey::from_packet(&packet);
|
||||
let reversed_key = packet_key.reverse();
|
||||
|
||||
let (actual_key, is_forward) = if self.active.contains_key(&packet_key) {
|
||||
(packet_key, true)
|
||||
} else if self.active.contains_key(&reversed_key) {
|
||||
(reversed_key, false)
|
||||
} else {
|
||||
// New flow: determine initiator using TCP flags, fall back to is_ingress.
|
||||
let syn = packet.tcp_flags & TCP_SYN != 0;
|
||||
let ack = packet.tcp_flags & TCP_ACK != 0;
|
||||
if syn && ack {
|
||||
// SYN+ACK: sender is the responder.
|
||||
// Ingress: external server responding to internal client → reverse so
|
||||
// canonical key has internal client as src.
|
||||
// Egress: internal server responding to external client → keep as-is.
|
||||
if is_ingress {
|
||||
(reversed_key, false)
|
||||
} else {
|
||||
(packet_key, true)
|
||||
}
|
||||
} else if syn {
|
||||
// SYN: sender is always the initiator.
|
||||
(packet_key, true)
|
||||
} else {
|
||||
// Mid-stream / UDP / ICMP: use is_ingress as best-effort heuristic.
|
||||
// Egress = we are the initiator (forward); ingress = remote initiated (backward).
|
||||
if is_ingress {
|
||||
(reversed_key, false)
|
||||
} else {
|
||||
(packet_key, true)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
packet.is_forward = is_forward;
|
||||
|
||||
let initiator_direction = if is_forward {
|
||||
if is_ingress {
|
||||
Direction::Ingress
|
||||
} else {
|
||||
Direction::Egress
|
||||
}
|
||||
} else if is_ingress {
|
||||
Direction::Egress
|
||||
} else {
|
||||
Direction::Ingress
|
||||
};
|
||||
|
||||
let key_for_init = actual_key.clone();
|
||||
let entry = self.active.get_with(actual_key, || {
|
||||
Arc::new(Mutex::new(FlowData::new(key_for_init, &packet, initiator_direction)))
|
||||
});
|
||||
entry.lock().add_packet(&packet, &self.limits);
|
||||
}
|
||||
|
||||
/// Extract scalar stats from all active flows without cloning packet vectors.
|
||||
pub fn get_flow_stats<T>(&self, convert: impl Fn(&FlowData) -> T) -> Vec<T> {
|
||||
self.active.iter().map(|(_, entry)| convert(&entry.lock())).collect()
|
||||
}
|
||||
|
||||
/// Get flows that received new packets since their last inference, and
|
||||
/// mark them as inferred. Used by ML engine.
|
||||
pub fn get_uninferred_flows(&self, limit: usize) -> Vec<FlowData> {
|
||||
let mut result = Vec::new();
|
||||
for (_, entry) in self.active.iter() {
|
||||
if result.len() >= limit {
|
||||
break;
|
||||
}
|
||||
let mut flow = entry.lock();
|
||||
if flow.last_time_us > flow.last_inferred_us {
|
||||
let snapshot = FlowData {
|
||||
fwd_packets: std::mem::take(&mut flow.fwd_packets),
|
||||
bwd_packets: std::mem::take(&mut flow.bwd_packets),
|
||||
active_periods: std::mem::take(&mut flow.active_periods),
|
||||
idle_periods: std::mem::take(&mut flow.idle_periods),
|
||||
..flow.clone()
|
||||
};
|
||||
flow.last_inferred_us = flow.last_time_us;
|
||||
result.push(snapshot);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub fn flow_count(&self) -> usize {
|
||||
self.active.entry_count() as usize
|
||||
}
|
||||
|
||||
/// Remove flows that have been idle too long or are terminated (FIN/RST seen).
|
||||
/// `now_us`: current timestamp in microseconds (same scale as packet timestamps).
|
||||
/// Returns the number of flows removed.
|
||||
pub fn cleanup_stale_flows(&self, now_us: u64) -> usize {
|
||||
let mut keys_to_remove = Vec::new();
|
||||
for (key, entry) in self.active.iter() {
|
||||
let flow = entry.lock();
|
||||
let idle = now_us.saturating_sub(flow.last_time_us);
|
||||
let is_terminated = flow.fin_count > 0 || flow.rst_count > 0;
|
||||
let stale = if is_terminated {
|
||||
idle >= self.limits.terminated_timeout_us
|
||||
} else {
|
||||
idle >= self.limits.idle_timeout_us
|
||||
};
|
||||
if stale {
|
||||
keys_to_remove.push((*key).clone());
|
||||
}
|
||||
}
|
||||
let mut removed = 0;
|
||||
for key in keys_to_remove {
|
||||
self.active.invalidate(&key);
|
||||
removed += 1;
|
||||
}
|
||||
removed
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_limits() -> FlowLimits {
|
||||
FlowLimits {
|
||||
max_packets_per_direction: 1000,
|
||||
max_periods: 1000,
|
||||
idle_threshold_us: 1_000_000,
|
||||
bulk_min_packets: 4,
|
||||
bulk_min_bytes: 1000,
|
||||
idle_timeout_us: 120_000_000,
|
||||
terminated_timeout_us: 5_000_000,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_packet(timestamp_us: u64, tcp_flags: u8) -> UserPacket {
|
||||
UserPacket {
|
||||
ip_version: 4,
|
||||
protocol: 6, // TCP
|
||||
tcp_flags,
|
||||
src_ip: [10, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
dst_ip: [10, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
src_port: 12345,
|
||||
dst_port: 80,
|
||||
packet_length: 100,
|
||||
payload_length: 60,
|
||||
header_length: 40,
|
||||
tcp_window_size: 65535,
|
||||
timestamp_us,
|
||||
is_forward: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_count(tracker: &FlowTracker) -> usize {
|
||||
tracker.active.run_pending_tasks();
|
||||
tracker.flow_count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_removes_idle_flows() {
|
||||
let tracker = FlowTracker::new(10000, test_limits());
|
||||
let base_ts = 1_000_000_000u64; // 1000 seconds
|
||||
|
||||
let pkt = make_packet(base_ts, 0x02); // SYN
|
||||
tracker.process_packet(pkt, false);
|
||||
assert_eq!(sync_count(&tracker), 1);
|
||||
|
||||
let now = base_ts + 130_000_000;
|
||||
let removed = tracker.cleanup_stale_flows(now);
|
||||
assert_eq!(removed, 1);
|
||||
assert_eq!(sync_count(&tracker), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_keeps_active_flows() {
|
||||
let tracker = FlowTracker::new(10000, test_limits());
|
||||
let base_ts = 1_000_000_000u64;
|
||||
|
||||
let pkt = make_packet(base_ts, 0x02);
|
||||
tracker.process_packet(pkt, false);
|
||||
|
||||
let now = base_ts + 10_000_000;
|
||||
let removed = tracker.cleanup_stale_flows(now);
|
||||
assert_eq!(removed, 0);
|
||||
assert_eq!(sync_count(&tracker), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_removes_terminated_flows_after_short_idle() {
|
||||
let tracker = FlowTracker::new(10000, test_limits());
|
||||
let base_ts = 1_000_000_000u64;
|
||||
|
||||
let pkt1 = make_packet(base_ts, 0x02);
|
||||
tracker.process_packet(pkt1, false);
|
||||
|
||||
let pkt2 = make_packet(base_ts + 1_000_000, 0x01); // FIN
|
||||
tracker.process_packet(pkt2, false);
|
||||
|
||||
let now = base_ts + 7_000_000;
|
||||
let removed = tracker.cleanup_stale_flows(now);
|
||||
assert_eq!(removed, 1);
|
||||
assert_eq!(sync_count(&tracker), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_keeps_recently_terminated_flows() {
|
||||
let tracker = FlowTracker::new(10000, test_limits());
|
||||
let base_ts = 1_000_000_000u64;
|
||||
|
||||
let pkt1 = make_packet(base_ts, 0x02);
|
||||
tracker.process_packet(pkt1, false);
|
||||
|
||||
let pkt2 = make_packet(base_ts + 1_000_000, 0x01);
|
||||
tracker.process_packet(pkt2, false);
|
||||
|
||||
let now = base_ts + 3_000_000;
|
||||
let removed = tracker.cleanup_stale_flows(now);
|
||||
assert_eq!(removed, 0);
|
||||
assert_eq!(sync_count(&tracker), 1);
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,23 +2,10 @@ use std::net::{Ipv4Addr, Ipv6Addr};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tract_onnx::prelude::{Graph, SimplePlan, TypedFact, TypedOp};
|
||||
|
||||
use crate::domain::data_plane::direction::Direction;
|
||||
use crate::domain::data_plane::user_packet::UserPacket;
|
||||
|
||||
pub type RunnableModel = SimplePlan<TypedFact, Box<dyn TypedOp>, Graph<TypedFact, Box<dyn TypedOp>>>;
|
||||
|
||||
pub struct EngineConfig {
|
||||
pub max_flows: usize,
|
||||
pub min_packets: usize,
|
||||
pub min_packets_floor: usize,
|
||||
pub batch_size: usize,
|
||||
pub inference_interval_secs: u64,
|
||||
pub aggregator_window_secs: u64,
|
||||
pub confirmation_window_fraction: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ClipParams {
|
||||
pub lower: f64,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
pub mod auth;
|
||||
pub mod error;
|
||||
pub mod password;
|
||||
pub mod user;
|
||||
pub mod validation;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user