refactor(style): error variant redesign + deep-path hoist + ?/into() sweep

- Errors: eliminate #[no_source] + reason:String anti-pattern that forced
  callers to stringify upstream errors. Removed from DatabaseError::QueryFailed,
  CryptoError::{EncryptionFailed,DecryptionFailed}, MiscError::GeoIPDatabaseError,
  NotificationError::{Smtp*,MessageBuildFailed,InvalidAddress}, SoarError::ActionFailed
  — these now wrap source via macro-generated err field.
- Split mixed-intent variants into structured forms: CryptoError::InvalidEnvelope
  (+ EnvelopeParseFailed, UnsupportedEnvelopeVersion, MissingEnvelopeField,
  AlgNoneRejected, InvalidNonceLength, UnsupportedAlgorithm); SoarError
  (+ UnknownActionType, RateLimitUnavailable, InvalidRateLimitFactor, Webhook*,
  UnblockRuleNotFound, UnknownConditionType); DatabaseError (+ EncryptionKeyInvalid,
  DatabaseNotReadable, SourceDatabaseNotReadable, GroupAlreadyExists,
  AuditPrevHashMismatch, AuditRowHashMismatch); MiscError (+ DnsLabelOutOfRange,
  DnsDomainTooLong).
- Drop NotificationError::TelegramApiError; add TelegramRequestFailed (source) +
  TelegramHttpError { status, body }. Add IOError::WriteFileFailed; fix
  report/engine.rs to use proper IOError/MiscError variants.
- Paths: hoist 11 three-segment inline paths (core::task::Context,
  sd_notify::NotifyState, tokio::time/signal, super::report, core::str,
  serde_json::Value).
- Propagation: convert 18 Err(X.into()) to Err(X)?; keep 7 cases where borrow
  checker or let-else requires return Err(X.into()).

cargo fmt + cargo clippy -D warnings clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
DaLaw2 2026-04-16 23:40:15 +08:00
parent 86358074b9
commit 3acd2ebf96
68 changed files with 851 additions and 760 deletions

View File

@ -7,6 +7,7 @@ use crate::core::ebpf::access_control::AccessControl;
use crate::interface::port::access_control::AccessControlPort;
use crate::model::access_control::list_type::ListType;
use crate::model::error::Error;
use crate::model::error::ebpf::EbpfError;
use crate::model::monitoring::direction::FlowDirection;
/// Adapter that implements AccessControlPort by delegating to the eBPF AccessControl.
@ -25,7 +26,7 @@ impl AccessControlPort for EbpfAccessControlAdapter {
async fn block_ip(&self, ip: &str) -> Result<(), Error> {
let addr: IpAddr = ip
.parse()
.map_err(|_| Error::from(crate::model::error::ebpf::EbpfError::InvalidIpAddress { ip: ip.to_string() }))?;
.map_err(|_| Error::from(EbpfError::InvalidIpAddress { ip: ip.to_string() }))?;
match addr {
IpAddr::V4(v4) => {
let socket = SocketAddrV4::new(v4, 0);
@ -45,7 +46,7 @@ impl AccessControlPort for EbpfAccessControlAdapter {
async fn unblock_ip(&self, ip: &str) -> Result<(), Error> {
let addr: IpAddr = ip
.parse()
.map_err(|_| Error::from(crate::model::error::ebpf::EbpfError::InvalidIpAddress { ip: ip.to_string() }))?;
.map_err(|_| Error::from(EbpfError::InvalidIpAddress { ip: ip.to_string() }))?;
match addr {
IpAddr::V4(v4) => {
let socket = SocketAddrV4::new(v4, 0);

View File

@ -1,3 +1,8 @@
use std::fs;
use std::io::ErrorKind;
use std::path::Path;
use std::time::UNIX_EPOCH;
use actix_web::{HttpResponse, Scope, web};
use serde::Serialize;
@ -32,7 +37,7 @@ struct LogFileEntry {
async fn list_logs() -> HttpResponse {
let log_dir = LOG_DIR;
let entries = match std::fs::read_dir(log_dir) {
let entries = match fs::read_dir(log_dir) {
Ok(dir) => dir
.filter_map(|e| e.ok())
.filter_map(|e| {
@ -44,7 +49,7 @@ async fn list_logs() -> HttpResponse {
let modified = meta
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_secs());
Some(LogFileEntry {
name,
@ -68,10 +73,10 @@ async fn download_log(path: web::Path<String>) -> HttpResponse {
}));
}
let file_path = std::path::Path::new(LOG_DIR).join(&filename);
let file_path = Path::new(LOG_DIR).join(&filename);
// Canonicalize to prevent symlink traversal
let canonical = match std::fs::canonicalize(&file_path) {
let canonical = match fs::canonicalize(&file_path) {
Ok(p) => p,
Err(_) => {
return HttpResponse::NotFound().json(serde_json::json!({
@ -79,7 +84,7 @@ async fn download_log(path: web::Path<String>) -> HttpResponse {
}));
}
};
if let Ok(log_dir_canonical) = std::fs::canonicalize(LOG_DIR)
if let Ok(log_dir_canonical) = fs::canonicalize(LOG_DIR)
&& !canonical.starts_with(&log_dir_canonical)
{
return HttpResponse::Forbidden().json(serde_json::json!({
@ -88,13 +93,13 @@ async fn download_log(path: web::Path<String>) -> HttpResponse {
}
// Check file size before reading to prevent OOM on large logs
match std::fs::metadata(&canonical) {
match fs::metadata(&canonical) {
Ok(meta) if meta.len() > MAX_DOWNLOAD_SIZE => {
return HttpResponse::PayloadTooLarge().json(serde_json::json!({
"error": format!("Log file exceeds maximum download size ({}MB)", MAX_DOWNLOAD_SIZE / 1024 / 1024)
}));
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
Err(e) if e.kind() == ErrorKind::NotFound => {
return HttpResponse::NotFound().json(serde_json::json!({
"error": format!("Log file '{}' not found", filename)
}));
@ -107,7 +112,7 @@ async fn download_log(path: web::Path<String>) -> HttpResponse {
Ok(_) => {}
}
let content = match std::fs::read(&canonical) {
let content = match fs::read(&canonical) {
Ok(bytes) => bytes,
Err(e) => {
return HttpResponse::InternalServerError().json(serde_json::json!({

View File

@ -1,7 +1,12 @@
use std::fs;
use actix_web::{HttpResponse, Scope, web};
use chrono::Local;
use tokio::task::spawn_blocking;
use crate::adapter::persistence::Database;
use crate::core::auth::extractor::AuthClaims;
use crate::core::email::report::generate_weekly_report;
use crate::core::email::scheduler::SmtpClient;
use crate::core::report::engine;
use crate::infrastructure::secret_store::SecretStore;
@ -20,14 +25,14 @@ async fn generate_report(_auth: AuthClaims, db: web::Data<Database>) -> HttpResp
.ok()
.flatten()
.unwrap_or_else(|| "/var/lib/netguardia/reports".to_string());
if let Err(e) = std::fs::create_dir_all(&report_dir) {
if let Err(e) = fs::create_dir_all(&report_dir) {
return HttpResponse::InternalServerError().json(serde_json::json!({
"error": format!("Failed to create report directory: {}", e)
}));
}
let db_ref = db.get_ref();
match engine::generate_html_report(db_ref as &dyn RepositoryPort, &report_dir) {
Ok(path) => match std::fs::read(&path) {
Ok(path) => match fs::read(&path) {
Ok(content) => HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.insert_header((
@ -89,7 +94,7 @@ async fn send_report(_auth: AuthClaims, db: web::Data<Database>, secrets: web::D
}
};
let html = match crate::core::email::report::generate_weekly_report(db_ref) {
let html = match generate_weekly_report(db_ref) {
Ok(h) => h,
Err(e) => {
return HttpResponse::InternalServerError().json(serde_json::json!({
@ -99,9 +104,9 @@ async fn send_report(_auth: AuthClaims, db: web::Data<Database>, secrets: web::D
}
};
let subject = format!("NetGuardia Weekly Report — {}", chrono::Local::now().format("%Y-%m-%d"));
let subject = format!("NetGuardia Weekly Report — {}", Local::now().format("%Y-%m-%d"));
let send_result = tokio::task::spawn_blocking(move || smtp.send(&recipient, &subject, &html)).await;
let send_result = spawn_blocking(move || smtp.send(&recipient, &subject, &html)).await;
match send_result {
Ok(Ok(())) => HttpResponse::Ok().json(serde_json::json!({

View File

@ -1,14 +1,18 @@
use actix_web::{HttpResponse, Scope, web};
use serde::Deserialize;
use std::fs;
use std::path::Path;
use std::sync::atomic::Ordering;
use actix_web::{HttpResponse, Scope, web};
use macros::log;
use serde::Deserialize;
use serde_json::Value;
use crate::adapter::persistence::Database;
use crate::core::auth::password;
use crate::core::auth::setup_guard::SetupCompleteFlag;
use crate::infrastructure::secret_store::SecretStore;
use crate::interface::port::secret_store::SecretStorePort;
use crate::model::error::Error;
use crate::model::error::system::SystemError;
pub fn initialize() -> Scope {
@ -27,7 +31,7 @@ async fn setup_status(setup_flag: web::Data<SetupCompleteFlag>) -> HttpResponse
async fn list_interfaces() -> HttpResponse {
// List available network interfaces
let interfaces: Vec<serde_json::Value> = match std::fs::read_dir("/sys/class/net") {
let interfaces: Vec<Value> = match fs::read_dir("/sys/class/net") {
Ok(entries) => entries
.filter_map(|e| e.ok())
.map(|e| {
@ -98,7 +102,7 @@ async fn complete_setup(
}));
}
let iface_path = format!("/sys/class/net/{}", iface);
if !std::path::Path::new(&iface_path).exists() {
if !Path::new(&iface_path).exists() {
return HttpResponse::BadRequest().json(serde_json::json!({
"error": format!("Network interface '{}' not found", iface)
}));
@ -172,11 +176,7 @@ async fn complete_setup(
}))
}
fn save_config(
db: &Database,
secrets: &dyn SecretStorePort,
req: &SetupRequest,
) -> Result<(), crate::model::error::Error> {
fn save_config(db: &Database, secrets: &dyn SecretStorePort, req: &SetupRequest) -> Result<(), Error> {
// Save network config
db.set_setting("ingress_interface", &req.ingress_interface)?;
db.set_setting("egress_interface", &req.egress_interface)?;

View File

@ -8,6 +8,8 @@ use crate::infrastructure::communication_manager::CommunicationManager;
use crate::interface::communication::command_types::ChangeEnforceModeCommand;
use crate::interface::communication::query_types::GetEnforceModeQuery;
use crate::interface::port::repository::RepositoryPort;
use crate::utils::boot_time;
use crate::utils::logging::Logging;
type Repo = dyn RepositoryPort;
@ -31,7 +33,7 @@ pub fn initialize() -> Scope {
}
async fn get_boot_time() -> impl Responder {
HttpResponse::Ok().json(crate::utils::boot_time::boot_time())
HttpResponse::Ok().json(boot_time::boot_time())
}
async fn get_enforce_mode(comm: web::Data<CommunicationManager>) -> impl Responder {
@ -81,7 +83,7 @@ async fn get_config(svc: web::Data<ConfigService>) -> impl Responder {
async fn get_log_level() -> impl Responder {
HttpResponse::Ok().json(serde_json::json!({
"level": crate::utils::logging::Logging::current_level(),
"level": Logging::current_level(),
}))
}
@ -91,7 +93,7 @@ struct LogLevelRequest {
}
async fn set_log_level(body: web::Json<LogLevelRequest>) -> impl Responder {
match crate::utils::logging::Logging::set_level(&body.level) {
match Logging::set_level(&body.level) {
Ok(new_level) => HttpResponse::Ok().json(serde_json::json!({
"level": new_level,
"message": "Log level updated",

View File

@ -1,18 +1,31 @@
use std::collections::{HashMap, HashSet};
use std::env;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use chrono::Utc;
use macros::log;
use r2d2::Pool;
use r2d2_sqlite::SqliteConnectionManager;
use rusqlite::params;
use std::collections::HashMap;
use macros::log;
use rusqlite::{self, Connection, Error as RusqliteError, params};
use crate::interface::port::api_key::{ApiKeyListItem, ApiKeyPort};
use crate::interface::port::audit::AuditPort;
use crate::interface::port::notification::NotificationConfigPort;
use crate::interface::port::repository::{
AclRuleTuple, RepositoryPort, UserGroupTuple, UserListItem, UserTuple, UserWithGroups,
};
use crate::interface::port::soar::{PlaybookRow, SoarExecutionRow, SoarPort};
use crate::interface::port::stats::StatsPort;
use crate::model::error::Error;
use crate::model::error::database::DatabaseError;
use crate::model::identity::auth::Claims;
use crate::model::log::misc::MiscLog;
use crate::model::soar::playbook_data::UpdatePlaybookRow;
/// 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).
fn db_encryption_key() -> Option<String> {
match std::env::var("NETGUARDIA_DB_KEY") {
match env::var("NETGUARDIA_DB_KEY") {
Ok(k) if !k.is_empty() => Some(k),
_ => None,
}
@ -74,18 +87,14 @@ impl Database {
.max_size(if path == ":memory:" { 1 } else { 6 })
.connection_customizer(Box::new(customizer))
.build(manager)
.map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?;
.map_err(DatabaseError::QueryFailed)?;
// Verify the pool is actually usable (catches wrong key / corrupt DB early).
{
let test_conn = pool
.get()
.map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?;
let test_conn = pool.get().map_err(DatabaseError::QueryFailed)?;
test_conn
.query_row("SELECT count(*) FROM sqlite_master", [], |_| Ok(()))
.map_err(|_| DatabaseError::QueryFailed {
reason: "Database encryption key is incorrect or database is corrupted".to_string(),
})?;
.map_err(|_| DatabaseError::EncryptionKeyInvalid)?;
}
let api_key_hmac = Self::derive_api_key_hmac();
@ -100,10 +109,10 @@ impl Database {
use hkdf::Hkdf;
use sha2::Sha256;
let root_key = std::env::var("NETGUARDIA_SECRETS_KEY")
let root_key = env::var("NETGUARDIA_SECRETS_KEY")
.ok()
.filter(|k| !k.is_empty())
.or_else(|| std::env::var("NETGUARDIA_DB_KEY").ok().filter(|k| !k.is_empty()))
.or_else(|| env::var("NETGUARDIA_DB_KEY").ok().filter(|k| !k.is_empty()))
.unwrap_or_else(|| "netguardia-dev-api-key-secret".to_string());
let hk = Hkdf::<Sha256>::new(Some(b"netguardia-v1-salt"), root_key.as_bytes());
@ -134,55 +143,47 @@ impl Database {
/// Export an encrypted database to a plaintext copy.
/// The original file is NOT modified.
pub fn decrypt_to_file(src_path: &str, key: &str, dest_path: &str) -> Result<(), Error> {
let conn =
rusqlite::Connection::open(src_path).map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?;
let conn = Connection::open(src_path).map_err(DatabaseError::QueryFailed)?;
conn.pragma_update(None, "key", key)
.map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?;
.map_err(DatabaseError::QueryFailed)?;
// Verify we can read the encrypted DB
conn.query_row("SELECT count(*) FROM sqlite_master", [], |_| Ok(()))
.map_err(|_| DatabaseError::QueryFailed {
reason: "Cannot read database with provided key — wrong key or not encrypted".to_string(),
})?;
.map_err(|_| DatabaseError::DatabaseNotReadable)?;
// Attach a plaintext destination (empty key = no encryption)
conn.execute_batch(&format!(
"ATTACH DATABASE '{}' AS plaintext KEY '';",
dest_path.replace('\'', "''"),
))
.map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?;
.map_err(DatabaseError::QueryFailed)?;
conn.query_row("SELECT sqlcipher_export('plaintext')", [], |_| Ok(()))
.map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?;
.map_err(DatabaseError::QueryFailed)?;
conn.execute_batch("DETACH DATABASE plaintext;")
.map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?;
.map_err(DatabaseError::QueryFailed)?;
Ok(())
}
/// Encrypt a plaintext database to a new encrypted copy.
/// The original file is NOT modified.
pub fn encrypt_to_file(src_path: &str, key: &str, dest_path: &str) -> Result<(), Error> {
let conn =
rusqlite::Connection::open(src_path).map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?;
let conn = Connection::open(src_path).map_err(DatabaseError::QueryFailed)?;
// Verify it's readable as plaintext
conn.query_row("SELECT count(*) FROM sqlite_master", [], |_| Ok(()))
.map_err(|_| DatabaseError::QueryFailed {
reason: "Cannot read source database — may already be encrypted".to_string(),
})?;
.map_err(|_| DatabaseError::SourceDatabaseNotReadable)?;
conn.execute_batch(&format!(
"ATTACH DATABASE '{}' AS encrypted KEY '{}';",
dest_path.replace('\'', "''"),
key.replace('\'', "''"),
))
.map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?;
.map_err(DatabaseError::QueryFailed)?;
conn.query_row("SELECT sqlcipher_export('encrypted')", [], |_| Ok(()))
.map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?;
.map_err(DatabaseError::QueryFailed)?;
conn.execute_batch("DETACH DATABASE encrypted;")
.map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?;
.map_err(DatabaseError::QueryFailed)?;
Ok(())
}
fn conn(&self) -> Result<r2d2::PooledConnection<SqliteConnectionManager>, Error> {
self.pool
.get()
.map_err(|e| -> Error { DatabaseError::QueryFailed { reason: e.to_string() }.into() })
self.pool.get().map_err(|e| DatabaseError::QueryFailed(e).into())
}
fn create_tables(&self) -> Result<(), Error> {
@ -440,7 +441,7 @@ impl Database {
Ok(())
}
pub fn load_acl_rules(&self) -> Result<Vec<crate::interface::port::repository::AclRuleTuple>, Error> {
pub fn load_acl_rules(&self) -> Result<Vec<AclRuleTuple>, 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| {
@ -545,8 +546,8 @@ impl Database {
});
match result {
Ok(val) => Ok(Some(val)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e.into()),
Err(RusqliteError::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e)?,
}
}
@ -568,8 +569,8 @@ impl Database {
});
match result {
Ok(val) => Ok(Some(val)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e.into()),
Err(RusqliteError::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e)?,
}
}
@ -583,7 +584,7 @@ impl Database {
}
// --- Users ---
pub fn find_user(&self, username: &str) -> Result<Option<crate::interface::port::repository::UserTuple>, Error> {
pub fn find_user(&self, username: &str) -> Result<Option<UserTuple>, Error> {
let conn = self.conn()?;
let result = conn.query_row(
"SELECT id, username, password_hash, role, force_password_change FROM users WHERE username = ?1",
@ -600,8 +601,8 @@ impl Database {
);
match result {
Ok(user) => Ok(Some(user)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e.into()),
Err(RusqliteError::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e)?,
}
}
@ -644,7 +645,7 @@ impl Database {
Ok(conn.query_row("SELECT COUNT(*) FROM users", [], |row| row.get(0))?)
}
pub fn list_users(&self) -> Result<Vec<crate::interface::port::repository::UserListItem>, Error> {
pub fn list_users(&self) -> Result<Vec<UserListItem>, Error> {
let conn = self.conn()?;
let mut stmt =
conn.prepare("SELECT id, username, role, force_password_change, created_at FROM users ORDER BY id")?;
@ -664,7 +665,7 @@ impl Database {
Ok(results)
}
pub fn list_users_with_groups(&self) -> Result<Vec<crate::interface::port::repository::UserWithGroups>, Error> {
pub fn list_users_with_groups(&self) -> Result<Vec<UserWithGroups>, Error> {
let conn = self.conn()?;
let mut stmt = conn.prepare(
"SELECT u.id, u.username, u.role, u.force_password_change, u.created_at, \
@ -686,7 +687,7 @@ impl Database {
))
})?;
let mut user_map: HashMap<i64, crate::interface::port::repository::UserWithGroups> = HashMap::new();
let mut user_map: HashMap<i64, UserWithGroups> = HashMap::new();
let mut order: Vec<i64> = Vec::new();
for row in rows {
@ -725,10 +726,7 @@ impl Database {
Ok(())
}
pub fn find_user_by_id(
&self,
user_id: i64,
) -> Result<Option<crate::interface::port::repository::UserTuple>, Error> {
pub fn find_user_by_id(&self, user_id: i64) -> Result<Option<UserTuple>, Error> {
let conn = self.conn()?;
let result = conn.query_row(
"SELECT id, username, password_hash, role, force_password_change FROM users WHERE id = ?1",
@ -745,13 +743,13 @@ impl Database {
);
match result {
Ok(user) => Ok(Some(user)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e.into()),
Err(RusqliteError::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e)?,
}
}
// --- User Groups ---
pub fn list_user_groups(&self) -> Result<Vec<crate::interface::port::repository::UserGroupTuple>, Error> {
pub fn list_user_groups(&self) -> Result<Vec<UserGroupTuple>, Error> {
let conn = self.conn()?;
let mut stmt =
conn.prepare("SELECT id, name, description, permissions, created_at FROM user_groups ORDER BY id")?;
@ -779,10 +777,7 @@ impl Database {
)
.map_err(|e| -> Error {
if e.to_string().contains("UNIQUE constraint") {
DatabaseError::QueryFailed {
reason: format!("Group '{}' already exists", name),
}
.into()
DatabaseError::GroupAlreadyExists(name).into()
} else {
e.into()
}
@ -806,7 +801,7 @@ impl Database {
Ok(affected > 0)
}
pub fn get_user_group(&self, id: i64) -> Result<Option<crate::interface::port::repository::UserGroupTuple>, Error> {
pub fn get_user_group(&self, id: i64) -> Result<Option<UserGroupTuple>, Error> {
let conn = self.conn()?;
let result = conn.query_row(
"SELECT id, name, description, permissions, created_at FROM user_groups WHERE id = ?1",
@ -823,8 +818,8 @@ impl Database {
);
match result {
Ok(group) => Ok(Some(group)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e.into()),
Err(RusqliteError::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e)?,
}
}
@ -865,7 +860,7 @@ impl Database {
pub fn get_user_permissions(&self, user_id: i64) -> Result<Vec<String>, Error> {
let groups = self.get_user_groups(user_id)?;
let mut all_perms = std::collections::HashSet::new();
let mut all_perms = HashSet::new();
for (_id, _name, _desc, perms_json) in groups {
if let Ok(perms) = serde_json::from_str::<Vec<String>>(&perms_json) {
for p in perms {
@ -922,9 +917,9 @@ impl Database {
self.set_setting(&key_count, &count.to_string())?;
if count >= 5 {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or(std::time::Duration::ZERO)
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or(Duration::ZERO)
.as_secs();
let locked_until = now + 900; // 15 minutes
self.set_setting(&key_locked, &locked_until.to_string())?;
@ -939,9 +934,9 @@ impl Database {
if let Some(locked_str) = self.get_setting(&key_locked)?
&& let Ok(locked_until) = locked_str.parse::<u64>()
{
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or(std::time::Duration::ZERO)
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or(Duration::ZERO)
.as_secs();
if now < locked_until {
return Ok(Some(locked_until - now));
@ -969,7 +964,7 @@ impl Database {
/// Validate an API key and return Claims if valid.
/// Computes HMAC-SHA256 of the key and looks it up in api_keys table.
pub fn validate_api_key(&self, api_key: &str) -> Result<Option<crate::model::identity::auth::Claims>, Error> {
pub fn validate_api_key(&self, api_key: &str) -> Result<Option<Claims>, Error> {
let digest = self.hmac_api_key(api_key);
let conn = self.conn()?;
@ -1023,7 +1018,7 @@ impl Database {
],
};
Ok(Some(crate::model::identity::auth::Claims {
Ok(Some(Claims {
sub: -id, // negative ID to distinguish from user IDs
username: format!("api:{}", name),
role: level,
@ -1031,8 +1026,8 @@ impl Database {
exp: usize::MAX, // API keys don't expire (revocation via DB deletion)
}))
}
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e.into()),
Err(RusqliteError::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e)?,
}
}
@ -1259,11 +1254,7 @@ impl Database {
Ok(conn.last_insert_rowid())
}
pub fn update_playbook(
&self,
id: i64,
row: &crate::model::soar::playbook_data::UpdatePlaybookRow,
) -> Result<bool, Error> {
pub fn update_playbook(&self, id: i64, row: &UpdatePlaybookRow) -> Result<bool, Error> {
let conn = self.conn()?;
let rows = conn.execute(
"UPDATE playbooks SET name = ?2, trigger_event = ?3, condition_threshold = ?4, \
@ -1429,8 +1420,8 @@ impl Database {
|row| row.get::<_, String>(0),
) {
Ok(json) => Ok(Some(json)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e.into()),
Err(RusqliteError::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e)?,
}
}
@ -1611,7 +1602,7 @@ impl Database {
/// lookup, row_hash compute, insert) sequence is atomic and serializable.
pub fn insert_audit_log(&self, actor: &str, action: &str, detail: &str) -> Result<(), Error> {
let mut conn = self.conn()?;
let ts = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
let ts = Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
let tx = conn.transaction()?;
let prev_hash: String = tx
@ -1670,19 +1661,11 @@ impl Database {
let row_hash: String = row.get(6)?;
if prev_hash != expected_prev {
return Err(DatabaseError::AuditChainBroken {
id,
reason: format!("prev_hash mismatch (expected {}, found {})", expected_prev, prev_hash),
}
.into());
return Err(DatabaseError::AuditPrevHashMismatch(id, expected_prev, prev_hash).into());
}
let computed = Self::audit_row_hash(&ts, &actor, &action, &detail, &prev_hash);
if computed != row_hash {
return Err(DatabaseError::AuditChainBroken {
id,
reason: format!("row_hash mismatch (computed {}, stored {})", computed, row_hash),
}
.into());
return Err(DatabaseError::AuditRowHashMismatch(id, computed, row_hash).into());
}
expected_prev = row_hash;
count += 1;
@ -1693,7 +1676,7 @@ impl Database {
/// Implement the RepositoryPort trait, proving Database satisfies the port contract.
/// This enables adapter-level testing with mock implementations.
impl crate::interface::port::repository::RepositoryPort for Database {
impl RepositoryPort for Database {
fn insert_acl_rule(
&self,
ip_version: u8,
@ -1714,7 +1697,7 @@ impl crate::interface::port::repository::RepositoryPort for Database {
) -> Result<(), Error> {
self.delete_acl_rule(ip_version, direction, list_type, ip_address, port)
}
fn load_acl_rules(&self) -> Result<Vec<crate::interface::port::repository::AclRuleTuple>, Error> {
fn load_acl_rules(&self) -> Result<Vec<AclRuleTuple>, Error> {
self.load_acl_rules()
}
fn set_rate_limit(&self, key: &str, value: u64) -> Result<(), Error> {
@ -1747,7 +1730,7 @@ impl crate::interface::port::repository::RepositoryPort for Database {
fn set_setting(&self, key: &str, value: &str) -> Result<(), Error> {
self.set_setting(key, value)
}
fn find_user(&self, username: &str) -> Result<Option<crate::interface::port::repository::UserTuple>, Error> {
fn find_user(&self, username: &str) -> Result<Option<UserTuple>, Error> {
self.find_user(username)
}
fn insert_user(
@ -1765,10 +1748,10 @@ impl crate::interface::port::repository::RepositoryPort for Database {
fn user_count(&self) -> Result<i64, Error> {
self.user_count()
}
fn list_users(&self) -> Result<Vec<crate::interface::port::repository::UserListItem>, Error> {
fn list_users(&self) -> Result<Vec<UserListItem>, Error> {
self.list_users()
}
fn list_users_with_groups(&self) -> Result<Vec<crate::interface::port::repository::UserWithGroups>, Error> {
fn list_users_with_groups(&self) -> Result<Vec<UserWithGroups>, Error> {
self.list_users_with_groups()
}
fn delete_user(&self, user_id: i64) -> Result<bool, Error> {
@ -1780,10 +1763,10 @@ impl crate::interface::port::repository::RepositoryPort for Database {
fn reset_user_password(&self, user_id: i64, password_hash: &str) -> Result<(), Error> {
self.reset_user_password(user_id, password_hash)
}
fn find_user_by_id(&self, user_id: i64) -> Result<Option<crate::interface::port::repository::UserTuple>, Error> {
fn find_user_by_id(&self, user_id: i64) -> Result<Option<UserTuple>, Error> {
self.find_user_by_id(user_id)
}
fn list_user_groups(&self) -> Result<Vec<crate::interface::port::repository::UserGroupTuple>, Error> {
fn list_user_groups(&self) -> Result<Vec<UserGroupTuple>, Error> {
self.list_user_groups()
}
fn create_user_group(&self, name: &str, description: &str, permissions: &str) -> Result<i64, Error> {
@ -1795,7 +1778,7 @@ impl crate::interface::port::repository::RepositoryPort for Database {
fn delete_user_group(&self, id: i64) -> Result<bool, Error> {
self.delete_user_group(id)
}
fn get_user_group(&self, id: i64) -> Result<Option<crate::interface::port::repository::UserGroupTuple>, Error> {
fn get_user_group(&self, id: i64) -> Result<Option<UserGroupTuple>, Error> {
self.get_user_group(id)
}
fn get_user_groups(&self, user_id: i64) -> Result<Vec<(i64, String, String, String)>, Error> {
@ -1827,7 +1810,7 @@ impl crate::interface::port::repository::RepositoryPort for Database {
}
}
impl crate::interface::port::soar::SoarPort for Database {
impl SoarPort for Database {
fn get_setting(&self, key: &str) -> Result<Option<String>, Error> {
self.get_setting(key)
}
@ -1874,14 +1857,10 @@ impl crate::interface::port::soar::SoarPort for Database {
) -> Result<i64, Error> {
self.insert_playbook_action(playbook_id, action_order, action_type, params_json)
}
fn load_playbooks_with_actions(&self) -> Result<Vec<crate::interface::port::soar::PlaybookRow>, Error> {
fn load_playbooks_with_actions(&self) -> Result<Vec<PlaybookRow>, Error> {
self.load_playbooks_with_actions()
}
fn update_playbook(
&self,
id: i64,
row: &crate::model::soar::playbook_data::UpdatePlaybookRow,
) -> Result<bool, Error> {
fn update_playbook(&self, id: i64, row: &UpdatePlaybookRow) -> Result<bool, Error> {
self.update_playbook(id, row)
}
fn update_playbook_enabled(&self, id: i64, enabled: bool) -> Result<bool, Error> {
@ -1954,7 +1933,7 @@ impl crate::interface::port::soar::SoarPort for Database {
) -> Result<i64, Error> {
self.insert_soar_execution(playbook_id, source_ip, trigger_event, actions_json)
}
fn list_soar_executions(&self, limit: i64) -> Result<Vec<crate::interface::port::soar::SoarExecutionRow>, Error> {
fn list_soar_executions(&self, limit: i64) -> Result<Vec<SoarExecutionRow>, Error> {
self.list_soar_executions(limit)
}
fn load_admin_whitelist(&self) -> Result<Vec<String>, Error> {
@ -1968,7 +1947,7 @@ impl crate::interface::port::soar::SoarPort for Database {
}
}
impl crate::interface::port::stats::StatsPort for Database {
impl StatsPort for Database {
fn count_weekly_executions(&self, days: i64) -> Result<u64, Error> {
self.count_weekly_executions(days)
}
@ -1989,7 +1968,7 @@ impl crate::interface::port::stats::StatsPort for Database {
}
}
impl crate::interface::port::notification::NotificationConfigPort for Database {
impl NotificationConfigPort for Database {
fn get_notification_config(&self, channel: &str) -> Result<Option<String>, Error> {
self.get_notification_config(channel)
}
@ -1998,14 +1977,14 @@ impl crate::interface::port::notification::NotificationConfigPort for Database {
}
}
impl crate::interface::port::audit::AuditPort for Database {
impl AuditPort for Database {
fn insert_audit_log(&self, actor: &str, action: &str, detail: &str) -> Result<(), Error> {
self.insert_audit_log(actor, action, detail)
}
}
impl crate::interface::port::api_key::ApiKeyPort for Database {
fn validate_api_key(&self, api_key: &str) -> Result<Option<crate::model::identity::auth::Claims>, Error> {
impl ApiKeyPort for Database {
fn validate_api_key(&self, api_key: &str) -> Result<Option<Claims>, Error> {
self.validate_api_key(api_key)
}
fn hmac_api_key(&self, raw_key: &str) -> String {
@ -2014,7 +1993,7 @@ impl crate::interface::port::api_key::ApiKeyPort for Database {
fn insert_api_key(&self, key_hash: &str, name: &str, permission_level: &str) -> Result<i64, Error> {
self.insert_api_key(key_hash, name, permission_level)
}
fn list_api_keys(&self) -> Result<Vec<crate::interface::port::api_key::ApiKeyListItem>, Error> {
fn list_api_keys(&self) -> Result<Vec<ApiKeyListItem>, Error> {
self.list_api_keys()
}
fn delete_api_key(&self, id: i64) -> Result<bool, Error> {

View File

@ -2,9 +2,10 @@ use std::sync::Arc;
use std::time::{Duration, Instant};
use async_trait::async_trait;
use macros::log;
use parking_lot::Mutex;
use reqwest::Client;
use tracing::{debug, warn};
use tokio::time::sleep;
use crate::interface::port::notification::{AlertNotifier, AlertPayload, NotificationConfigPort};
use crate::interface::port::repository::RepositoryPort;
@ -12,6 +13,7 @@ use crate::interface::port::secret_store::SecretStorePort;
use crate::model::config::constants::TELEGRAM_MAX_RETRIES;
use crate::model::error::Error;
use crate::model::error::notification::NotificationError;
use crate::model::log::system::SystemLog;
/// Telegram Bot API adapter implementing AlertNotifier.
pub struct TelegramAdapter {
@ -32,9 +34,7 @@ impl TelegramAdapter {
let client = Client::builder()
.timeout(Duration::from_secs(10))
.build()
.map_err(|e| NotificationError::TelegramApiError {
reason: format!("Failed to create HTTP client: {}", e),
})?;
.map_err(NotificationError::TelegramRequestFailed)?;
Ok(Self {
client,
@ -51,9 +51,7 @@ impl TelegramAdapter {
match self.notif.get_notification_config("telegram")? {
Some(json_str) => {
let config: serde_json::Value =
serde_json::from_str(&json_str).map_err(|e| NotificationError::TelegramApiError {
reason: format!("Invalid telegram config JSON: {}", e),
})?;
serde_json::from_str(&json_str).map_err(NotificationError::TelegramRequestFailed)?;
let mut token = config.get("bot_token").and_then(|v| v.as_str()).map(|s| s.to_string());
let chat_id = config.get("chat_id").and_then(|v| v.as_str()).map(|s| s.to_string());
@ -119,7 +117,7 @@ impl TelegramAdapter {
if e.is_timeout() {
NotificationError::Timeout
} else {
NotificationError::TelegramApiError { reason: e.to_string() }
NotificationError::TelegramRequestFailed(e)
}
})?;
@ -150,13 +148,12 @@ impl TelegramAdapter {
.unwrap_or(5);
if attempt < TELEGRAM_MAX_RETRIES {
warn!(
"Telegram rate limited, retrying after {}s (attempt {}/{})",
log!(SystemLog::TelegramRateLimitedRetry(
retry_after,
attempt + 1,
TELEGRAM_MAX_RETRIES
);
tokio::time::sleep(Duration::from_secs(retry_after)).await;
TELEGRAM_MAX_RETRIES,
));
sleep(Duration::from_secs(retry_after)).await;
continue;
} else {
return Err(NotificationError::TelegramRateLimited {
@ -168,10 +165,7 @@ impl TelegramAdapter {
// Other error
let body = resp.text().await.unwrap_or_default();
return Err(NotificationError::TelegramApiError {
reason: format!("HTTP {}: {}", status, body),
}
.into());
Err(NotificationError::TelegramHttpError(status.as_u16(), body))?;
}
unreachable!()
@ -205,7 +199,7 @@ impl AlertNotifier for TelegramAdapter {
let (bot_token, chat_id) = match self.get_config()? {
Some(config) => config,
None => {
debug!("Telegram not configured, skipping alert");
log!(SystemLog::TelegramNotConfiguredSkipped);
return Ok(());
}
};
@ -218,10 +212,10 @@ impl AlertNotifier for TelegramAdapter {
.flatten()
.and_then(|v| v.parse().ok())
.unwrap_or(20);
warn!(
"Telegram rate limit reached ({}/min), dropping alert for IP {}",
max_per_min, payload.source_ip
);
log!(SystemLog::TelegramLocalRateLimitDropped(
max_per_min,
payload.source_ip.clone(),
));
return Ok(());
}
@ -232,12 +226,7 @@ impl AlertNotifier for TelegramAdapter {
async fn send_test_message(&self) -> Result<(), Error> {
let (bot_token, chat_id) = match self.get_config()? {
Some(config) => config,
None => {
return Err(NotificationError::NotConfigured {
channel: "telegram".to_string(),
}
.into());
}
None => Err(NotificationError::NotConfigured("telegram"))?,
};
self.send_message(

View File

@ -1,8 +1,10 @@
use actix_web::rt::spawn;
use actix_web::{HttpRequest, HttpResponse, Result, web};
use actix_ws::{Message, MessageStream, Session, handle};
use futures_util::StreamExt;
use macros::log;
use tokio::sync::broadcast;
use tokio::sync::broadcast::error::RecvError;
use crate::core::ml::alert::MLAlert;
use crate::model::detection::ml_detection::AlertMessage;
@ -15,7 +17,7 @@ pub async fn websocket_alert(req: HttpRequest, body: web::Payload, ai: web::Data
let broadcast_rx = ai.subscribe_to_alerts();
actix_web::rt::spawn(async move {
spawn(async move {
handle_alert_connection(session, msg_stream, broadcast_rx).await;
});
@ -41,11 +43,11 @@ async fn handle_alert_connection(
break;
}
}
Err(broadcast::error::RecvError::Lagged(skipped)) => {
Err(RecvError::Lagged(skipped)) => {
log!(HttpLog::WebSocketLagged(skipped));
continue;
}
Err(broadcast::error::RecvError::Closed) => {
Err(RecvError::Closed) => {
break;
}
}

View File

@ -1,8 +1,10 @@
use actix_web::rt::spawn;
use actix_web::{HttpRequest, HttpResponse, Result, web};
use actix_ws::{Message, MessageStream, Session, handle};
use futures_util::StreamExt;
use macros::log;
use tokio::sync::broadcast;
use tokio::sync::broadcast::error::RecvError;
use crate::core::ebpf::drop_monitor::DropMonitor;
use crate::model::error::http::HttpError;
@ -19,7 +21,7 @@ pub async fn websocket_drops(
let broadcast_rx = monitor.subscribe();
actix_web::rt::spawn(async move {
spawn(async move {
handle_drop_connection(session, msg_stream, broadcast_rx).await;
});
@ -45,11 +47,11 @@ async fn handle_drop_connection(
break;
}
}
Err(broadcast::error::RecvError::Lagged(skipped)) => {
Err(RecvError::Lagged(skipped)) => {
log!(HttpLog::WebSocketLagged(skipped));
continue;
}
Err(broadcast::error::RecvError::Closed) => {
Err(RecvError::Closed) => {
break;
}
}

View File

@ -1,5 +1,6 @@
use std::time::Duration;
use actix_web::rt::spawn;
use actix_web::{HttpRequest, HttpResponse, web};
use actix_ws::Message;
use futures_util::StreamExt;
@ -31,7 +32,7 @@ pub async fn flow_stats_ws(
) -> Result<HttpResponse, actix_web::Error> {
let (response, mut session, mut msg_stream) = actix_ws::handle(&req, body)?;
actix_web::rt::spawn(async move {
spawn(async move {
let mut subscription = default_subscription();
let mut ticker = interval(Duration::from_secs(subscription.interval_secs.unwrap_or(5)));

View File

@ -1,8 +1,10 @@
use actix_web::rt::spawn;
use actix_web::{HttpRequest, HttpResponse, Result, web};
use actix_ws::{Message, MessageStream, Session, handle};
use futures_util::StreamExt;
use macros::log;
use tokio::sync::broadcast;
use tokio::sync::broadcast::error::RecvError;
use crate::infrastructure::health::SystemHealth;
use crate::model::error::http::HttpError;
@ -19,7 +21,7 @@ pub async fn websocket_system_health(
let broadcast_rx = health.subscribe_to_metrics();
actix_web::rt::spawn(async move {
spawn(async move {
handle_health_connection(session, msg_stream, broadcast_rx).await;
});
@ -45,11 +47,11 @@ async fn handle_health_connection(
break;
}
}
Err(broadcast::error::RecvError::Lagged(skipped)) => {
Err(RecvError::Lagged(skipped)) => {
log!(HttpLog::WebSocketLagged(skipped));
continue;
}
Err(broadcast::error::RecvError::Closed) => {
Err(RecvError::Closed) => {
break;
}
}

View File

@ -1,7 +1,9 @@
use std::future::{Ready, ready};
use std::ops::Deref;
use actix_web::dev::Payload;
use actix_web::{FromRequest, HttpMessage, HttpRequest};
use actix_web::error::ErrorUnauthorized;
use actix_web::{Error as ActixError, FromRequest, HttpMessage, HttpRequest};
use crate::model::identity::auth::Claims;
@ -19,7 +21,7 @@ use crate::model::identity::auth::Claims;
/// ```
pub struct AuthClaims(pub Claims);
impl std::ops::Deref for AuthClaims {
impl Deref for AuthClaims {
type Target = Claims;
fn deref(&self) -> &Self::Target {
&self.0
@ -27,15 +29,13 @@ impl std::ops::Deref for AuthClaims {
}
impl FromRequest for AuthClaims {
type Error = actix_web::Error;
type Error = ActixError;
type Future = Ready<Result<Self, Self::Error>>;
fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future {
match req.extensions().get::<Claims>().cloned() {
Some(claims) => ready(Ok(AuthClaims(claims))),
None => ready(Err(actix_web::error::ErrorUnauthorized(
serde_json::json!({"error": "Unauthorized"}),
))),
None => ready(Err(ErrorUnauthorized(serde_json::json!({"error": "Unauthorized"})))),
}
}
}

View File

@ -1,7 +1,10 @@
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::task::{Context, Poll};
use actix_web::body::EitherBody;
use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform};
@ -40,13 +43,13 @@ fn is_safe_redirect_host(host: &str) -> bool {
}
// Try parsing as IP — allow private ranges only
if let Ok(ip) = hostname.parse::<std::net::IpAddr>() {
if let Ok(ip) = hostname.parse::<IpAddr>() {
return match ip {
std::net::IpAddr::V4(v4) => {
IpAddr::V4(v4) => {
let o = v4.octets();
o[0] == 10 || (o[0] == 172 && (16..=31).contains(&o[1])) || (o[0] == 192 && o[1] == 168) || o[0] == 127
}
std::net::IpAddr::V6(v6) => v6.is_loopback() || (v6.segments()[0] & 0xfe00) == 0xfc00,
IpAddr::V6(v6) => v6.is_loopback() || (v6.segments()[0] & 0xfe00) == 0xfc00,
};
}
@ -68,13 +71,13 @@ where
fn new_transform(&self, service: S) -> Self::Future {
ready(Ok(HttpsRedirectService {
service: std::rc::Rc::new(service),
service: Rc::new(service),
}))
}
}
pub struct HttpsRedirectService<S> {
service: std::rc::Rc<S>,
service: Rc<S>,
}
impl<S, B> Service<ServiceRequest> for HttpsRedirectService<S>
@ -86,12 +89,12 @@ where
type Error = ActixError;
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>>>>;
fn poll_ready(&self, ctx: &mut core::task::Context<'_>) -> std::task::Poll<Result<(), Self::Error>> {
fn poll_ready(&self, ctx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.service.poll_ready(ctx)
}
fn call(&self, req: ServiceRequest) -> Self::Future {
let service = std::rc::Rc::clone(&self.service);
let service = Rc::clone(&self.service);
Box::pin(async move {
// Check if force_https is enabled

View File

@ -1,4 +1,5 @@
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode, errors::ErrorKind};
@ -34,9 +35,9 @@ impl JwtService {
role: &str,
permissions: Vec<String>,
) -> Result<String, Error> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or(std::time::Duration::ZERO)
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or(Duration::ZERO)
.as_secs();
let claims = Claims {

View File

@ -1,9 +1,11 @@
use std::future::{Future, Ready, ready};
use std::pin::Pin;
use std::rc::Rc;
use std::task::{Context, Poll};
use actix_web::body::EitherBody;
use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform};
use actix_web::http::Method;
use actix_web::{Error as ActixError, HttpMessage, HttpResponse, web};
use macros::log;
@ -37,7 +39,7 @@ pub struct AuthMiddlewareService<S> {
service: Rc<S>,
}
fn required_permission(path: &str, method: &actix_web::http::Method) -> Option<String> {
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)
} else if path.starts_with("/api/auth/") {
@ -75,7 +77,7 @@ fn required_permission(path: &str, method: &actix_web::http::Method) -> Option<S
};
let action = match *method {
actix_web::http::Method::GET => "read",
Method::GET => "read",
_ => "write",
};
@ -91,7 +93,7 @@ where
type Error = ActixError;
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>>>>;
fn poll_ready(&self, ctx: &mut core::task::Context<'_>) -> std::task::Poll<Result<(), Self::Error>> {
fn poll_ready(&self, ctx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.service.poll_ready(ctx)
}

View File

@ -1,7 +1,9 @@
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::task::{Context, Poll};
use actix_web::body::EitherBody;
use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform};
@ -26,13 +28,13 @@ where
fn new_transform(&self, service: S) -> Self::Future {
ready(Ok(SetupGuardService {
service: std::rc::Rc::new(service),
service: Rc::new(service),
}))
}
}
pub struct SetupGuardService<S> {
service: std::rc::Rc<S>,
service: Rc<S>,
}
impl<S, B> Service<ServiceRequest> for SetupGuardService<S>
@ -44,12 +46,12 @@ where
type Error = ActixError;
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>>>>;
fn poll_ready(&self, ctx: &mut core::task::Context<'_>) -> std::task::Poll<Result<(), Self::Error>> {
fn poll_ready(&self, ctx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.service.poll_ready(ctx)
}
fn call(&self, req: ServiceRequest) -> Self::Future {
let service = std::rc::Rc::clone(&self.service);
let service = Rc::clone(&self.service);
Box::pin(async move {
let path = req.path().to_string();

View File

@ -1,5 +1,7 @@
use std::sync::Arc;
use serde_json::Value;
use crate::interface::port::repository::RepositoryPort;
use crate::interface::port::secret_store::SecretStorePort;
use crate::model::error::Error;
@ -187,14 +189,11 @@ impl ConfigService {
let stages: Vec<&str> = val.split(',').map(|s| s.trim()).collect();
for stage in &stages {
if !stage.is_empty() && !VALID_PIPELINE_STAGES.contains(stage) {
return Err(MiscError::ValidationError {
message: format!(
"Invalid pipeline stage '{}'. Valid stages: {}",
stage,
VALID_PIPELINE_STAGES.join(", ")
),
}
.into());
Err(MiscError::ValidationError(format!(
"Invalid pipeline stage '{}'. Valid stages: {}",
stage,
VALID_PIPELINE_STAGES.join(", ")
)))?;
}
}
}
@ -211,9 +210,9 @@ impl ConfigService {
/// Extract a JSON value as a non-empty string, handling string, boolean, and number types.
fn json_value_as_string(v: &serde_json::Value) -> Option<String> {
match v {
serde_json::Value::String(s) if !s.is_empty() => Some(s.clone()),
serde_json::Value::Bool(b) => Some(b.to_string()),
serde_json::Value::Number(n) => Some(n.to_string()),
Value::String(s) if !s.is_empty() => Some(s.clone()),
Value::Bool(b) => Some(b.to_string()),
Value::Number(n) => Some(n.to_string()),
_ => None,
}
}

View File

@ -130,6 +130,8 @@ impl BotnetDetector {
#[cfg(test)]
mod tests {
use std::thread;
use super::*;
fn make_alert(src_ip: &str, dst_ip: &str) -> AlertMessage {
@ -187,7 +189,7 @@ mod tests {
detector.process(&alert, &tx);
assert_eq!(detector.state.len(), 1);
std::thread::sleep(Duration::from_millis(20));
thread::sleep(Duration::from_millis(20));
let removed = detector.cleanup();
assert_eq!(removed, 1);
assert_eq!(detector.state.len(), 0);

View File

@ -1,7 +1,9 @@
use std::time::Duration;
use macros::log;
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;
@ -43,15 +45,15 @@ impl CorrelationEngine {
async fn run(mut self) {
log!(DetectionLog::CorrelationEngineStarted);
let mut cleanup_interval = tokio::time::interval(Duration::from_secs(CLEANUP_INTERVAL_SECS));
let mut cleanup_interval = interval(Duration::from_secs(CLEANUP_INTERVAL_SECS));
loop {
tokio::select! {
result = self.alert_rx.recv() => {
match result {
Ok(alert) => self.process_alert(&alert),
Err(broadcast::error::RecvError::Lagged(_)) => continue,
Err(broadcast::error::RecvError::Closed) => break,
Err(RecvError::Lagged(_)) => continue,
Err(RecvError::Closed) => break,
}
}
_ = cleanup_interval.tick() => {

View File

@ -2,7 +2,9 @@ use std::time::{Duration, Instant};
use dashmap::DashMap;
use macros::log;
use tokio::sync::broadcast::error::RecvError;
use tokio::sync::{broadcast, mpsc};
use tokio::time::interval;
use crate::model::detection::ml_detection::AlertMessage;
use crate::model::event::{DetectionEvent, DetectionSource};
@ -61,15 +63,15 @@ impl BeaconingDetector {
async fn run(mut self) {
log!(DetectionLog::BeaconingDetectorStarted);
let mut analysis_interval = tokio::time::interval(Duration::from_secs(ANALYSIS_INTERVAL_SECS));
let mut analysis_interval = interval(Duration::from_secs(ANALYSIS_INTERVAL_SECS));
loop {
tokio::select! {
result = self.alert_rx.recv() => {
match result {
Ok(alert) => self.record_flow(&alert),
Err(broadcast::error::RecvError::Lagged(_)) => continue,
Err(broadcast::error::RecvError::Closed) => break,
Err(RecvError::Lagged(_)) => continue,
Err(RecvError::Closed) => break,
}
}
_ = analysis_interval.tick() => {

View File

@ -2,8 +2,10 @@ use std::num::NonZero;
use std::sync::Arc;
use std::time::{Duration, Instant};
use lru::LruCache;
use macros::log;
use tokio::sync::mpsc;
use tokio::time::interval;
use crate::infrastructure::communication_manager::CommunicationManager;
use crate::infrastructure::geoip::GeoIpService;
@ -55,9 +57,9 @@ impl DetectionOrchestrator {
comm,
geoip,
// SAFETY: NonZero::new on a non-zero literal is infallible.
src_ip_counts: lru::LruCache::new(NonZero::new(10_000).unwrap()),
repeat_tracker: lru::LruCache::new(NonZero::new(5_000).unwrap()),
dedup: lru::LruCache::new(NonZero::new(MAX_DEDUP_ENTRIES).unwrap()),
src_ip_counts: LruCache::new(NonZero::new(10_000).unwrap()),
repeat_tracker: LruCache::new(NonZero::new(5_000).unwrap()),
dedup: LruCache::new(NonZero::new(MAX_DEDUP_ENTRIES).unwrap()),
dedup_window: Duration::from_secs(DEDUP_WINDOW_SECS),
}
}
@ -70,7 +72,7 @@ impl DetectionOrchestrator {
async fn run(mut self) {
log!(DetectionLog::OrchestratorStarted);
let mut cleanup_interval = tokio::time::interval(Duration::from_secs(CLEANUP_INTERVAL_SECS));
let mut cleanup_interval = interval(Duration::from_secs(CLEANUP_INTERVAL_SECS));
loop {
tokio::select! {

View File

@ -30,10 +30,10 @@ impl DnsFilterService {
.and_then(|v| v.parse().ok())
.unwrap_or(1000);
if domains.len() > max_domains {
return Err(MiscError::ValidationError {
message: format!("too many domains (max {})", max_domains),
}
.into());
Err(MiscError::ValidationError(format!(
"too many domains (max {})",
max_domains
)))?;
}
// eBPF first
for domain in domains {

View File

@ -1,3 +1,4 @@
use core::str;
use std::collections::HashSet;
use common::model::dns_name::DnsName;
@ -187,16 +188,10 @@ fn domain_to_wire_format(domain: &str) -> Result<DnsName, Error> {
let label_bytes = label.as_bytes();
let label_len = label_bytes.len();
if label_len == 0 || label_len >= 64 {
return Err(MiscError::InvalidDnsName {
reason: format!("invalid label length: {}", label_len),
}
.into());
return Err(MiscError::DnsLabelOutOfRange(label_len).into());
}
if pos + 1 + label_len >= 128 {
return Err(MiscError::InvalidDnsName {
reason: format!("domain name too long: {}", domain),
}
.into());
return Err(MiscError::DnsDomainTooLong(domain).into());
}
name.data[pos] = label_len as u8;
pos += 1;
@ -229,7 +224,7 @@ fn wire_format_to_domain(name: &DnsName) -> Option<String> {
return None;
}
pos += 1;
let label = core::str::from_utf8(&name.data[pos..pos + label_len]).ok()?;
let label = str::from_utf8(&name.data[pos..pos + label_len]).ok()?;
labels.push(label.to_string());
pos += label_len;
}

View File

@ -1,9 +1,11 @@
use std::mem;
use std::net::Ipv6Addr;
use std::sync::Arc;
use std::time::Duration;
use aya::maps::{MapData, RingBuf};
use tokio::sync::{broadcast, oneshot};
use tokio::time::interval;
use common::define::drop_reason::*;
use common::model::drop_event::DropEvent as RawDropEvent;
@ -101,7 +103,7 @@ fn format_ips(raw: &RawDropEvent) -> (String, String) {
}
fn format_ipv6(bytes: &[u8; 16]) -> String {
std::net::Ipv6Addr::from(*bytes).to_string()
Ipv6Addr::from(*bytes).to_string()
}
fn reason_to_str(reason: u8) -> &'static str {
@ -124,7 +126,7 @@ pub async fn start_consumer(ring_buf: RingBuf<MapData>, monitor: Arc<DropMonitor
tokio::spawn(async move {
let mut ring_buf = ring_buf;
let mut interval = tokio::time::interval(Duration::from_millis(100));
let mut interval = interval(Duration::from_millis(100));
loop {
tokio::select! {

View File

@ -35,10 +35,7 @@ impl GeoBlock {
let v6_trie = LpmTrie::try_from(v6_map).map_err(EbpfError::MapOperationError)?;
let db_path = &app_config.misc.geoip_db_name;
let reader = Reader::open_readfile(db_path).map_err(|e| MiscError::GeoIPDatabaseError {
path: db_path.clone(),
reason: e.to_string(),
})?;
let reader = Reader::open_readfile(db_path).map_err(|e| MiscError::GeoIPDatabaseError(db_path.clone(), e))?;
let index = Self::build_index(&reader)?;
@ -171,7 +168,7 @@ impl GeoBlock {
let mut v6_guard = self.geo_block_v6.write();
let (v4_trie, v6_trie) = match (v4_guard.as_mut(), v6_guard.as_mut()) {
(Some(v4), Some(v6)) => (v4, v6),
_ => return Err(EbpfError::NotLoaded.into()),
_ => Err(EbpfError::NotLoaded)?,
};
Self::clear_trie_v4(v4_trie);
Self::clear_trie_v6(v6_trie);

View File

@ -1,5 +1,5 @@
use std::ffi::CString;
use std::io::Write;
use std::io::{ErrorKind, Write};
use std::num::NonZero;
use std::os::fd::AsRawFd;
use std::sync::Arc;
@ -8,11 +8,11 @@ use std::time::Duration;
use aya::Ebpf;
use aya::maps::{MapData, XskMap};
use crossbeam::channel::{Receiver, Sender, bounded};
use crossbeam::channel::{Receiver, Sender, TrySendError, bounded};
use crossbeam::queue::SegQueue;
use macros::log;
use parking_lot::Mutex;
use tokio::sync::oneshot;
use tokio::sync::oneshot::{self, error::TryRecvError};
use xsk_rs::config::{BindFlags, FrameSize, Interface, LibxdpFlags, QueueSize, SocketConfig, UmemConfig};
use xsk_rs::{CompQueue, FillQueue, FrameDesc, RxQueue, Socket, TxQueue, Umem};
@ -227,7 +227,7 @@ impl XskPair {
let submitted = unsafe { fill_queue.produce(&fill_frames) };
if submitted != fill_frames.len() {
return Err(EbpfError::FillQueueInitFailed.into());
Err(EbpfError::FillQueueInitFailed)?;
}
let pool_frames: Vec<FrameDesc> = frame_descs.iter().skip(fill_frames_count).copied().collect();
@ -270,10 +270,10 @@ impl XskPair {
loop {
if let Some(ref mut rx) = shutdown_rx {
match rx.try_recv() {
Ok(_) | Err(oneshot::error::TryRecvError::Closed) => {
Ok(_) | Err(TryRecvError::Closed) => {
break;
}
Err(oneshot::error::TryRecvError::Empty) => {}
Err(TryRecvError::Empty) => {}
}
}
@ -375,11 +375,11 @@ impl XskPair {
buf.extend_from_slice(raw);
if let Err(e) = forward_tx.try_send(buf) {
match e {
crossbeam::channel::TrySendError::Full(returned) => {
TrySendError::Full(returned) => {
buffer_pool.put(returned);
log!(EbpfLog::ForwardChannelFull);
}
crossbeam::channel::TrySendError::Disconnected(returned) => {
TrySendError::Disconnected(returned) => {
buffer_pool.put(returned);
log!(EbpfLog::ForwardChannelDisconnected);
}
@ -462,7 +462,7 @@ impl XskPair {
}
if let Err(e) = self.tx.wakeup()
&& e.kind() != std::io::ErrorKind::WouldBlock
&& e.kind() != ErrorKind::WouldBlock
{
log!(EbpfLog::TXWakeupFailed(e.to_string()));
}

View File

@ -1,3 +1,5 @@
use chrono::Local;
use crate::interface::port::repository::RepositoryPort;
use crate::model::error::Error;
@ -77,7 +79,7 @@ pub fn generate_weekly_report(db: &dyn RepositoryPort) -> Result<String, Error>
let mem = health["memory_percent"].as_f64().unwrap_or(0.0);
let disk = health["disk_percent"].as_f64().unwrap_or(0.0);
let now = chrono::Local::now().format("%Y-%m-%d %H:%M");
let now = Local::now().format("%Y-%m-%d %H:%M");
let html = format!(
r#"<!DOCTYPE html>

View File

@ -1,13 +1,20 @@
use crate::interface::port::repository::RepositoryPort;
use crate::interface::port::secret_store::SecretStorePort;
use crate::model::error::Error;
use crate::model::error::notification::NotificationError;
use std::sync::Arc;
use chrono::{Local, Weekday};
use lettre::message::header::ContentType;
use lettre::transport::smtp::authentication::Credentials;
use lettre::{Message, SmtpTransport, Transport};
use std::sync::Arc;
use macros::log;
use tokio::task::{JoinHandle, spawn_blocking};
use tokio::time::{self, Duration};
use tracing::{error, info, warn};
use super::report;
use crate::interface::port::repository::RepositoryPort;
use crate::interface::port::secret_store::SecretStorePort;
use crate::interface::port::soar::SoarPort;
use crate::model::error::Error;
use crate::model::error::notification::NotificationError;
use crate::model::log::system::SystemLog;
/// SMTP client wrapper that builds a `lettre::SmtpTransport` from Database
/// settings and sends an email.
@ -74,10 +81,7 @@ impl SmtpClient {
/// Try to construct an `SmtpClient` from a SOAR port (which also provides `get_setting`).
/// Same logic as `from_database`, but accepts `&dyn SoarPort` instead of `&dyn RepositoryPort`.
pub fn from_soar_port(
db: &dyn crate::interface::port::soar::SoarPort,
secrets: Option<&dyn SecretStorePort>,
) -> Result<Option<Self>, Error> {
pub fn from_soar_port(db: &dyn SoarPort, secrets: Option<&dyn SecretStorePort>) -> Result<Option<Self>, Error> {
let host = match db.get_setting("smtp_host")? {
Some(v) if !v.is_empty() => v,
_ => return Ok(None),
@ -126,12 +130,11 @@ impl SmtpClient {
/// 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 {
reason: format!("invalid from address: {e}"),
})?;
let to_addr = to.parse().map_err(|e| NotificationError::InvalidAddress {
reason: format!("invalid to address: {e}"),
})?;
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)
@ -139,7 +142,7 @@ impl SmtpClient {
.subject(subject)
.header(ContentType::TEXT_HTML)
.body(html_body.to_string())
.map_err(|e| NotificationError::MessageBuildFailed { reason: e.to_string() })?;
.map_err(NotificationError::MessageBuildFailed)?;
let creds = Credentials::new(self.username.clone(), self.password.clone());
@ -147,7 +150,7 @@ impl SmtpClient {
465 => {
// Implicit TLS (SMTPS)
SmtpTransport::relay(&self.host)
.map_err(|e| NotificationError::SmtpConnectionFailed { reason: e.to_string() })?
.map_err(NotificationError::SmtpConnectionFailed)?
.port(self.port)
.credentials(creds)
.build()
@ -155,7 +158,7 @@ impl SmtpClient {
25 | 587 => {
// STARTTLS (standard submission ports)
SmtpTransport::starttls_relay(&self.host)
.map_err(|e| NotificationError::SmtpConnectionFailed { reason: e.to_string() })?
.map_err(NotificationError::SmtpConnectionFailed)?
.port(self.port)
.credentials(creds)
.build()
@ -169,9 +172,7 @@ impl SmtpClient {
}
};
mailer
.send(&email)
.map_err(|e| NotificationError::SmtpSendFailed { reason: e.to_string() })?;
mailer.send(&email).map_err(NotificationError::SmtpSendFailed)?;
Ok(())
}
@ -190,11 +191,11 @@ impl ReportScheduler {
}
/// Spawn a background tokio task that runs the weekly check loop.
pub fn run(&self) -> tokio::task::JoinHandle<()> {
pub fn run(&self) -> JoinHandle<()> {
let db = Arc::clone(&self.db);
let secrets = self.secrets.clone();
tokio::spawn(async move {
info!("Weekly report scheduler started");
log!(SystemLog::WeeklyReportSchedulerStarted);
let mut interval = time::interval(Duration::from_secs(3600));
loop {
interval.tick().await;
@ -203,19 +204,16 @@ impl ReportScheduler {
continue;
}
info!("Weekly report window reached — preparing report");
log!(SystemLog::WeeklyReportWindowReached);
let smtp = match SmtpClient::from_database(&*db, secrets.as_deref()) {
Ok(Some(client)) => client,
Ok(None) => {
warn!(
"SMTP is not configured (missing smtp_host/port/username/password). \
Skipping weekly report."
);
log!(SystemLog::SmtpNotConfigured);
continue;
}
Err(e) => {
error!("Failed to read SMTP settings: {e}");
log!(SystemLog::SmtpSettingsReadFailed(e.to_string()));
continue;
}
};
@ -223,26 +221,26 @@ impl ReportScheduler {
let recipient = match db.get_setting("smtp_recipient") {
Ok(Some(r)) if !r.is_empty() => r,
_ => {
warn!("No smtp_recipient configured. Skipping weekly report.");
log!(SystemLog::SmtpRecipientMissing);
continue;
}
};
let html = match super::report::generate_weekly_report(&*db) {
let html = match report::generate_weekly_report(&*db) {
Ok(h) => h,
Err(e) => {
error!("Failed to generate weekly report: {e}");
log!(SystemLog::WeeklyReportGenerationFailed(e.to_string()));
continue;
}
};
let subject = format!("NetGuardia Weekly Report — {}", chrono::Local::now().format("%Y-%m-%d"));
let send_result = tokio::task::spawn_blocking(move || smtp.send(&recipient, &subject, &html)).await;
let subject = format!("NetGuardia Weekly Report — {}", Local::now().format("%Y-%m-%d"));
let send_result = spawn_blocking(move || smtp.send(&recipient, &subject, &html)).await;
match send_result {
Ok(Ok(())) => info!("Weekly report sent successfully"),
Ok(Err(e)) => error!("Failed to send weekly report: {e}"),
Err(e) => error!("Send task panicked: {e}"),
Ok(Ok(())) => log!(SystemLog::WeeklyReportSent),
Ok(Err(e)) => log!(SystemLog::WeeklyReportSendFailed(e.to_string())),
Err(e) => log!(SystemLog::WeeklyReportSendPanicked(e.to_string())),
}
}
})
@ -253,6 +251,6 @@ impl ReportScheduler {
/// hour (i.e. Monday, hour == 8).
fn is_send_window() -> bool {
use chrono::{Datelike, Timelike};
let now = chrono::Local::now();
now.weekday() == chrono::Weekday::Mon && now.hour() == 8
let now = Local::now();
now.weekday() == Weekday::Mon && now.hour() == 8
}

View File

@ -1,4 +1,4 @@
use std::collections::HashMap;
use std::collections::{BTreeMap, HashMap};
use std::fs;
use std::path::{Path, PathBuf};
@ -13,7 +13,8 @@ impl MLInferenceConfig {
}
fn load_file_at(path: &Path) -> Result<Self, MLError> {
let content = fs::read_to_string(path).map_err(|_| MLError::ConfigLoadFailed(path.to_path_buf()))?;
let content = fs::read_to_string(path)
.map_err(|e| MLError::ConfigLoadFailed(path.to_path_buf(), format!("read failed: {e}")))?;
let config: MLInferenceConfig =
serde_json::from_str(&content).map_err(|e| MLError::ConfigParseFailed(e.to_string()))?;
validate(&config)?;
@ -114,13 +115,17 @@ fn apply_manifest_overrides(manifest: &ModelManifest, config: &mut MLInferenceCo
}
}
fn manifest_labels_to_map(labels: &std::collections::BTreeMap<String, LabelSpec>) -> HashMap<String, String> {
fn manifest_labels_to_map(labels: &BTreeMap<String, LabelSpec>) -> HashMap<String, String> {
labels.iter().map(|(k, v)| (k.clone(), v.name.clone())).collect()
}
#[cfg(test)]
mod tests {
use std::env;
use std::io::Write;
use super::*;
use crate::model::detection::ml_detection::ClipParams;
/// Integration test: the shipped `models/manifest.yaml` must successfully pair
/// with its scaler sidecar to yield a valid `MLInferenceConfig`. Skipped silently
@ -144,9 +149,6 @@ mod tests {
#[test]
fn feature_mismatch_between_manifest_and_sidecar_is_rejected() {
use crate::model::detection::ml_detection::ClipParams;
use std::io::Write;
// Build a minimal sidecar JSON with 2 features.
let sidecar = MLInferenceConfig {
ae_feature_names: vec!["flow_duration".into(), "fwd_packets".into()],
@ -169,11 +171,11 @@ mod tests {
ae_feature_weights: HashMap::new(),
};
let tmp = std::env::temp_dir().join("netguardia-m1-mismatch-test");
std::fs::create_dir_all(&tmp).unwrap();
let tmp = env::temp_dir().join("netguardia-m1-mismatch-test");
fs::create_dir_all(&tmp).unwrap();
let sidecar_path = tmp.join("sidecar.json");
let manifest_path = tmp.join("manifest.yaml");
let mut f = std::fs::File::create(&sidecar_path).unwrap();
let mut f = fs::File::create(&sidecar_path).unwrap();
f.write_all(serde_json::to_string(&sidecar).unwrap().as_bytes())
.unwrap();
@ -191,7 +193,7 @@ features:
preprocessing:
scaler_sidecar: sidecar.json
"#;
std::fs::write(&manifest_path, manifest_yaml).unwrap();
fs::write(&manifest_path, manifest_yaml).unwrap();
let err =
MLInferenceConfig::from_manifest_with_sidecar(&manifest_path).expect_err("should reject count mismatch");
assert!(matches!(err, MLError::ManifestInvalid { .. }), "got {err:?}");

View File

@ -1,23 +1,23 @@
use std::sync::Arc;
use std::time::{Duration, Instant};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use macros::log;
use parking_lot::Mutex;
use tokio::sync::oneshot;
use tokio::task::spawn_blocking;
use tokio::time::interval;
use super::aggregator::AttackAggregator;
use super::alert::MLAlert;
use super::drift_detector::DriftDetector;
use super::flow_tracker::{FlowData, FlowTracker};
use super::inference::Inference;
use super::model_loader::MLModels;
use super::traffic_logger::TrafficLogger;
use crate::model::detection::flow_features::FlowFeatures;
use crate::model::system::config::MLInferenceConfig;
use super::alert::MLAlert;
use crate::model::detection::ml_detection::{EngineConfig, InferenceStats};
use crate::model::detection::ml_detection::{EngineConfig, FlowKey, InferenceStats};
use crate::model::log::ml::MLLog;
use crate::model::system::config::MLInferenceConfig;
/// 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.
@ -95,7 +95,7 @@ impl Engine {
/// Protocol/port-aware min_packets: some traffic patterns are meaningful
/// at very low packet counts and would be invisible to ML at the global threshold.
fn effective_min_packets(flow_key: &crate::model::detection::ml_detection::FlowKey, global: usize) -> usize {
fn effective_min_packets(flow_key: &FlowKey, global: usize) -> usize {
match flow_key.protocol {
// ICMP: single-packet SYN scans, ping sweeps
1 => 1,
@ -142,7 +142,7 @@ impl Engine {
// Move CPU-bound ML inference off the tokio executor
let engine = Arc::clone(&self);
let _ = tokio::task::spawn_blocking(move || {
let _ = spawn_blocking(move || {
engine.run_inference_tick();
})
.await;
@ -152,8 +152,8 @@ impl Engine {
fn run_inference_tick(&self) {
let mut all_flows = Vec::new();
let mut total_count = 0;
let now_us = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
let now_us = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_micros() as u64)
.unwrap_or(0);

View File

@ -1,3 +1,5 @@
use std::cmp::Ordering as CmpOrdering;
use std::panic::{self, AssertUnwindSafe};
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
@ -71,7 +73,7 @@ impl Inference {
return Vec::new();
}
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.infer_batch_inner(flows))) {
match panic::catch_unwind(AssertUnwindSafe(|| self.infer_batch_inner(flows))) {
Ok(results) => {
// Success: reset failure counter
self.failure_count.store(0, Ordering::Relaxed);
@ -258,7 +260,7 @@ impl Inference {
let (predicted_class, class_confidence) = class_probs
.iter()
.enumerate()
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(CmpOrdering::Equal))
.map(|(i, &p)| (i, p))
.unwrap_or((0, 0.0));

View File

@ -1,4 +1,4 @@
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::time::Instant;
use macros::log;
@ -61,34 +61,34 @@ impl MLModels {
}
fn loader_inner(
model_path: &PathBuf,
model_path: &Path,
model: &str,
features: usize,
batch_size: usize,
) -> Result<RunnableModel, MLError> {
let mut onnx_model = onnx()
.model_for_path(model_path)
.map_err(|_| MLError::ModelLoadFailed(model_path.clone()))?;
.map_err(|e| MLError::ModelLoadFailed(model_path.to_path_buf(), format!("parse ONNX: {e}")))?;
// Introspect the ONNX input fact and cross-check its last dim against the
// expected feature count. A dynamic/symbolic dim is skipped — we only fail
// when the ONNX graph declares a concrete integer that disagrees.
if let Some(onnx_dim) = introspect_input_features(&onnx_model) {
let matched = onnx_dim == features;
log!(MLLog::OnnxShapeChecked(model.to_string(), features, onnx_dim, matched,));
log!(MLLog::OnnxShapeChecked(model.to_string(), features, onnx_dim, matched));
if !matched {
return Err(MLError::FeatureMismatch(model_path.clone(), features, onnx_dim));
return Err(MLError::FeatureMismatch(model_path.to_path_buf(), features, onnx_dim));
}
}
onnx_model
.set_input_fact(0, f32::fact([batch_size, features]).into())
.map_err(|_| MLError::ModelLoadFailed(model_path.clone()))?;
.map_err(|e| MLError::ModelLoadFailed(model_path.to_path_buf(), format!("set_input_fact: {e}")))?;
onnx_model
.into_optimized()
.and_then(|m| m.into_runnable())
.map_err(|_| MLError::ModelLoadFailed(model_path.clone()))
.map_err(|e| MLError::ModelLoadFailed(model_path.to_path_buf(), format!("optimize/runnable: {e}")))
}
pub fn get_model_info(&self, name: &str) -> String {
@ -153,7 +153,7 @@ mod tests {
/// names + sidecar-derived inference config must succeed without shape mismatch.
#[test]
fn v10_load_named_with_manifest_paths_succeeds() {
let manifest_path = std::path::Path::new("models/manifest.yaml");
let manifest_path = Path::new("models/manifest.yaml");
if !manifest_path.exists() {
eprintln!("skipping: models/manifest.yaml absent");
return;

View File

@ -5,10 +5,12 @@ use std::time::Duration;
use macros::log;
use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use tokio::sync::mpsc;
use tokio::time::sleep;
use super::inference::Inference;
use super::model_loader::MLModels;
use crate::infrastructure::app_config::AppConfig;
use crate::model::error::ml::MLError;
use crate::model::log::ml::MLLog;
use crate::model::system::config::MLInferenceConfig;
@ -47,7 +49,7 @@ impl ModelWatcher {
});
}
async fn run(self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
async fn run(self) -> Result<(), MLError> {
let models_dir = PathBuf::from("models");
if !models_dir.exists() {
log!(MLLog::InferenceFailed(
@ -71,7 +73,7 @@ impl ModelWatcher {
}
// Debounce: drain any additional events within the window
tokio::time::sleep(Duration::from_secs(DEBOUNCE_SECS)).await;
sleep(Duration::from_secs(DEBOUNCE_SECS)).await;
while rx.try_recv().is_ok() {}
// Attempt reload
@ -81,7 +83,7 @@ impl ModelWatcher {
Ok(())
}
fn spawn_watcher(models_dir: PathBuf, tx: mpsc::Sender<()>) -> Result<RecommendedWatcher, notify::Error> {
fn spawn_watcher(models_dir: PathBuf, tx: mpsc::Sender<()>) -> Result<RecommendedWatcher, MLError> {
let mut watcher = notify::recommended_watcher(move |res: Result<Event, notify::Error>| {
if let Ok(event) = res {
let dominated = matches!(event.kind, EventKind::Create(_) | EventKind::Modify(_));
@ -93,9 +95,12 @@ impl ModelWatcher {
let _ = tx.blocking_send(());
}
}
})?;
})
.map_err(MLError::ModelWatcherFailed)?;
watcher.watch(&models_dir, RecursiveMode::NonRecursive)?;
watcher
.watch(&models_dir, RecursiveMode::NonRecursive)
.map_err(MLError::ModelWatcherFailed)?;
Ok(watcher)
}

View File

@ -1,5 +1,5 @@
use std::fs::OpenOptions;
use std::io::{BufWriter, Write};
use std::io::{self, BufWriter, Write};
use std::thread;
use crossbeam::channel::{Sender, TrySendError, bounded};
@ -13,7 +13,7 @@ pub struct TrafficLogger {
}
impl TrafficLogger {
pub fn new(csv_path: &str, header: Vec<String>) -> Result<Self, std::io::Error> {
pub fn new(csv_path: &str, header: Vec<String>) -> Result<Self, io::Error> {
let file = OpenOptions::new()
.create(true)
.write(true)

View File

@ -1,5 +1,9 @@
use std::sync::Arc;
use serde_json::Value;
use crate::adapter::telegram::TelegramAdapter;
use crate::core::email::scheduler::SmtpClient;
use crate::interface::port::notification::{AlertNotifier, NotificationConfigPort};
use crate::interface::port::repository::RepositoryPort;
use crate::interface::port::secret_store::SecretStorePort;
@ -39,10 +43,10 @@ impl NotificationService {
&& t.len() > 8
{
let redacted = format!("{}...{}", &t[..4], &t[t.len() - 4..]);
config["bot_token_redacted"] = serde_json::Value::String(redacted);
config["bot_token_redacted"] = Value::String(redacted);
}
config.as_object_mut().map(|obj| obj.remove("bot_token"));
config["configured"] = serde_json::Value::Bool(true);
config["configured"] = Value::Bool(true);
Ok(config)
}
Err(_) => Ok(serde_json::json!({"configured": false})),
@ -66,18 +70,13 @@ impl NotificationService {
/// Send a test Telegram message using current config.
pub async fn test_telegram(&self) -> Result<(), Error> {
let adapter = crate::adapter::telegram::TelegramAdapter::new(
self.notif.clone(),
self.repo.clone(),
Some(self.secrets.clone()),
)?;
let adapter = TelegramAdapter::new(self.notif.clone(), self.repo.clone(), Some(self.secrets.clone()))?;
adapter.send_test_message().await
}
/// Send a test email using current SMTP config.
pub fn test_smtp(&self) -> Result<String, Error> {
let smtp_client =
crate::core::email::scheduler::SmtpClient::from_database(self.repo.as_ref(), Some(self.secrets.as_ref()))?;
let smtp_client = SmtpClient::from_database(self.repo.as_ref(), Some(self.secrets.as_ref()))?;
let smtp = smtp_client.ok_or_else(|| MiscError::ValidationError {
message: "SMTP not configured. Set smtp_host, smtp_port, smtp_username, smtp_password first. \
If smtp_username is not an email address, also set smtp_sender."

View File

@ -1,18 +1,19 @@
use std::collections::HashMap;
use std::net::IpAddr;
use std::sync::Arc;
use macros::log;
use serde_json::Value;
use crate::core::soar::engine::SoarEngine;
use crate::interface::port::access_control::AccessControlPort;
use crate::interface::port::soar::SoarPort;
use macros::log;
use crate::model::error::Error;
use crate::model::error::soar::SoarError;
use crate::model::soar::playbook_data::{
ActionData, ActiveBlockData, ConditionData, CreatePlaybookInput, ExecutionData, PlaybookData, UpdatePlaybookRow,
};
use std::collections::HashMap;
/// Domain service for SOAR playbook CRUD operations.
/// Coordinates DB reads/writes, SOAR engine cache refresh, and eBPF unblock.
pub struct PlaybookService {
@ -98,7 +99,7 @@ impl PlaybookService {
id: aid,
action_order: order,
action_type: atype,
params: serde_json::from_str(&params_str).unwrap_or(serde_json::Value::Null),
params: serde_json::from_str(&params_str).unwrap_or(Value::Null),
});
}
}
@ -220,10 +221,7 @@ impl PlaybookService {
let block = self
.db
.get_soar_block_by_id(id)?
.ok_or_else(|| SoarError::ActionFailed {
action_type: "manual_unblock".to_string(),
reason: format!("Block rule {} not found", id),
})?;
.ok_or_else(|| SoarError::UnblockRuleNotFound(id))?;
let source_ip = &block.1;
// Remove from eBPF ACL
@ -254,7 +252,7 @@ impl PlaybookService {
playbook_id: pb_id,
source_ip,
trigger_event,
actions_executed: serde_json::from_str(&actions).unwrap_or(serde_json::Value::Null),
actions_executed: serde_json::from_str(&actions).unwrap_or(Value::Null),
created_at,
},
)
@ -280,9 +278,9 @@ impl PlaybookService {
/// Determine IP version from a string address using proper parsing.
pub fn ip_version_from_str(ip: &str) -> u8 {
match ip.parse::<std::net::IpAddr>() {
Ok(std::net::IpAddr::V4(_)) => 4,
Ok(std::net::IpAddr::V6(_)) => 6,
match ip.parse::<IpAddr>() {
Ok(IpAddr::V4(_)) => 4,
Ok(IpAddr::V6(_)) => 6,
Err(_) => {
if ip.contains(':') {
6

View File

@ -1,9 +1,14 @@
use std::fs;
use std::path::PathBuf;
use tracing::info;
use chrono::Local;
use macros::log;
use crate::interface::port::repository::RepositoryPort;
use crate::model::error::Error;
use crate::model::error::notification::NotificationError;
use crate::model::error::io::IOError;
use crate::model::error::misc::MiscError;
use crate::model::log::system::SystemLog;
use crate::model::report::data::ReportData;
/// Generate a self-contained HTML security report and write to disk.
@ -14,18 +19,14 @@ pub fn generate_html_report(db: &dyn RepositoryPort, output_dir: &str) -> Result
let html_path = PathBuf::from(output_dir).join(format!(
"netguardia-report-{}.html",
chrono::Local::now().format("%Y%m%d-%H%M%S")
Local::now().format("%Y%m%d-%H%M%S")
));
std::fs::create_dir_all(output_dir).map_err(|e| NotificationError::TelegramApiError {
reason: format!("Failed to create report directory: {}", e),
})?;
fs::create_dir_all(output_dir).map_err(|e| IOError::CreateDirectoryFailed(PathBuf::from(output_dir), e))?;
std::fs::write(&html_path, &html).map_err(|e| NotificationError::TelegramApiError {
reason: format!("Failed to write HTML report: {}", e),
})?;
fs::write(&html_path, &html).map_err(|e| IOError::WriteFileFailed(html_path.clone(), e))?;
info!("HTML report generated at {:?}", html_path);
log!(SystemLog::HtmlReportGenerated(format!("{html_path:?}")));
Ok(html_path)
}
@ -203,10 +204,5 @@ fn html_escape(s: &str) -> String {
/// Generate report data and format as JSON (for API responses).
pub fn generate_report_json(db: &dyn RepositoryPort) -> Result<serde_json::Value, Error> {
let data = ReportData::from_database(db)?;
serde_json::to_value(&data).map_err(|e| {
NotificationError::TelegramApiError {
reason: format!("Failed to serialize report: {}", e),
}
.into()
})
serde_json::to_value(&data).map_err(|e| MiscError::SerializeError(e).into())
}

View File

@ -1,13 +1,23 @@
use std::collections::HashSet;
use std::net::IpAddr;
use std::net::{IpAddr, SocketAddr};
use std::sync::Arc;
use std::sync::atomic::{AtomicU8, AtomicU32, Ordering};
use std::time::{Duration, Instant};
use chrono::{Duration as ChronoDuration, NaiveDateTime, Utc};
use dashmap::DashMap;
use macros::log;
use tokio::sync::broadcast;
use parking_lot::RwLock;
use reqwest::Client;
use serde_json::Value;
use tokio::net::lookup_host;
use tokio::sync::broadcast::error::RecvError;
use tokio::sync::{Mutex as TokioMutex, broadcast};
use tokio::task::spawn_blocking;
use url::Url;
use crate::core::ebpf::rate_limit::RateLimitConfig;
use crate::core::playbook_service::ip_version_from_str;
use crate::core::soar::frequency::FrequencyTracker;
use crate::infrastructure::communication_manager::CommunicationManager;
use crate::infrastructure::geoip::GeoIpService;
@ -31,11 +41,11 @@ pub struct SoarEngine {
db: Arc<dyn SoarPort>,
access_control: Arc<dyn AccessControlPort>,
/// In-memory cache of playbooks (loaded at startup, refreshed on change).
playbooks: parking_lot::RwLock<Vec<Playbook>>,
playbooks: RwLock<Vec<Playbook>>,
/// In-memory cache of admin whitelist IPs.
admin_whitelist: parking_lot::RwLock<HashSet<String>>,
admin_whitelist: RwLock<HashSet<String>>,
/// Cooldown tracker: maps (playbook_id, source_ip) → last execution time.
cooldowns: DashMap<CooldownKey, std::time::Instant>,
cooldowns: DashMap<CooldownKey, Instant>,
/// Frequency tracker for frequency-based conditions.
frequency_tracker: FrequencyTracker,
/// AtomicU32 counter for active auto-blocks (avoids DB query per event).
@ -47,7 +57,7 @@ pub struct SoarEngine {
/// Optional rate limit config for adjust_rate_limit action.
rate_limit: Option<Arc<RateLimitConfig>>,
/// Lock to serialize rate limit read-save-write sequences (Item 6: atomicity).
rate_limit_lock: tokio::sync::Mutex<()>,
rate_limit_lock: TokioMutex<()>,
/// Cached enforce level: Monitor=0, MlOnly=1, Enforce=2.
enforce_level_cache: Arc<AtomicU8>,
/// Secret store for decrypting SMTP passwords etc.
@ -67,15 +77,15 @@ impl SoarEngine {
let engine = Self {
db,
access_control,
playbooks: parking_lot::RwLock::new(Vec::new()),
admin_whitelist: parking_lot::RwLock::new(HashSet::new()),
playbooks: RwLock::new(Vec::new()),
admin_whitelist: RwLock::new(HashSet::new()),
cooldowns: DashMap::new(),
frequency_tracker: FrequencyTracker::new(),
active_block_count: AtomicU32::new(0),
alert_notifier,
geoip,
rate_limit,
rate_limit_lock: tokio::sync::Mutex::new(()),
rate_limit_lock: TokioMutex::new(()),
enforce_level_cache,
secrets,
};
@ -132,7 +142,7 @@ impl SoarEngine {
name: pb.name.clone(),
error: format!("Malformed action params JSON: {}", e),
});
serde_json::Value::Object(Default::default())
Value::Object(Default::default())
}),
});
}
@ -197,10 +207,7 @@ impl SoarEngine {
"CRITICAL: SOAR engine failed to subscribe — automated threat response is DISABLED: {}",
e
)));
SoarError::ActionFailed {
action_type: "subscribe".to_string(),
reason: e.to_string(),
}
SoarError::ActionFailed("subscribe", e)
})?;
tokio::spawn(async move {
Self::event_loop(self, rx).await;
@ -220,10 +227,10 @@ impl SoarEngine {
}
});
}
Err(broadcast::error::RecvError::Lagged(n)) => {
Err(RecvError::Lagged(n)) => {
log!(SoarLog::ReceiverLagged(n));
}
Err(broadcast::error::RecvError::Closed) => {
Err(RecvError::Closed) => {
log!(SoarLog::ChannelClosed);
break;
}
@ -400,7 +407,7 @@ impl SoarEngine {
/// Record cooldown for a playbook + source IP combination.
fn record_cooldown(&self, playbook_id: i64, source_ip: &str) {
let key = (playbook_id, source_ip.to_string());
self.cooldowns.insert(key, std::time::Instant::now());
self.cooldowns.insert(key, Instant::now());
}
/// Execute a single playbook against an event.
@ -494,11 +501,7 @@ impl SoarEngine {
"send_email" => self.action_send_email(event).await,
"webhook" => self.action_webhook(action, event).await,
"log" => self.action_log(action, event),
other => Err(SoarError::ActionFailed {
action_type: other.to_string(),
reason: "Unknown action type".to_string(),
}
.into()),
other => Err(SoarError::UnknownActionType(other))?,
}
}
@ -520,11 +523,7 @@ impl SoarEngine {
.and_then(|v| v.parse().ok())
.unwrap_or(86400);
if ttl_secs > max_ttl {
return Err(SoarError::InvalidTtl {
ttl_secs,
max_secs: max_ttl,
}
.into());
Err(SoarError::InvalidTtl(ttl_secs, max_ttl))?;
}
// Atomically check cap and reserve a slot using CAS loop (runtime-configurable via DB)
@ -539,7 +538,7 @@ impl SoarEngine {
let current_count = self.active_block_count.load(Ordering::SeqCst);
if current_count >= max_cap {
log!(SoarLog::CapReached(current_count, max_cap, event.source_ip.clone()));
return Err(SoarError::CapReached { max_cap }.into());
Err(SoarError::CapReached(max_cap))?;
}
if self
.active_block_count
@ -557,7 +556,7 @@ impl SoarEngine {
}
// Calculate expiry time
let expires_at = chrono::Utc::now() + chrono::Duration::seconds(ttl_secs as i64);
let expires_at = Utc::now() + ChronoDuration::seconds(ttl_secs as i64);
let expires_str = expires_at.format("%Y-%m-%d %H:%M:%S").to_string();
// Record in soar_block_rules
@ -584,7 +583,7 @@ impl SoarEngine {
}
// Also persist to acl_rules for consistency
let ip_version = crate::core::playbook_service::ip_version_from_str(&event.source_ip);
let ip_version = ip_version_from_str(&event.source_ip);
self.db
.insert_acl_rule(ip_version, "source", "blacklist", &event.source_ip, 0)?;
@ -599,20 +598,13 @@ impl SoarEngine {
action: &PlaybookAction,
event: &ThreatDetectedEvent,
) -> Result<String, Error> {
let rate_limit = self.rate_limit.as_ref().ok_or_else(|| SoarError::ActionFailed {
action_type: "adjust_rate_limit".to_string(),
reason: "Rate limit config not available".to_string(),
})?;
let rate_limit = self.rate_limit.as_ref().ok_or(SoarError::RateLimitUnavailable)?;
let factor = action.params.get("factor").and_then(|v| v.as_f64()).unwrap_or(0.5);
let ttl_secs = action.params.get("ttl_secs").and_then(|v| v.as_u64()).unwrap_or(600);
if !(0.01..=1.0).contains(&factor) {
return Err(SoarError::ActionFailed {
action_type: "adjust_rate_limit".to_string(),
reason: format!("factor must be 0.01..1.0, got {}", factor),
}
.into());
Err(SoarError::InvalidRateLimitFactor(factor))?;
}
let max_ttl: u64 = self
@ -623,11 +615,7 @@ impl SoarEngine {
.and_then(|v| v.parse().ok())
.unwrap_or(86400);
if ttl_secs > max_ttl {
return Err(SoarError::InvalidTtl {
ttl_secs,
max_secs: max_ttl,
}
.into());
Err(SoarError::InvalidTtl(ttl_secs, max_ttl))?;
}
// Acquire lock to serialize rate limit read-save-write (Item 6: atomicity)
@ -652,7 +640,7 @@ impl SoarEngine {
}
// Store TTL for restoration
let expires_at = chrono::Utc::now() + chrono::Duration::seconds(ttl_secs as i64);
let expires_at = Utc::now() + ChronoDuration::seconds(ttl_secs as i64);
self.db.set_setting(
"soar_rate_limit_expires",
&expires_at.format("%Y-%m-%d %H:%M:%S").to_string(),
@ -697,7 +685,7 @@ impl SoarEngine {
async fn action_send_telegram(&self, event: &ThreatDetectedEvent) -> Result<String, Error> {
if let Some(notifier) = &self.alert_notifier {
let country = if let Some(geoip) = &self.geoip {
if let Ok(ip_addr) = event.source_ip.parse::<std::net::IpAddr>() {
if let Ok(ip_addr) = event.source_ip.parse::<IpAddr>() {
match geoip.lookup(ip_addr).await {
Ok(Some(loc)) => loc.country,
_ => None,
@ -719,7 +707,7 @@ impl SoarEngine {
"SOAR auto-response triggered (hits: {}{})",
event.flow_count, repeat_tag,
),
timestamp: chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(),
timestamp: Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(),
};
notifier.send_alert(&payload).await?;
Ok("Telegram notification sent".to_string())
@ -748,15 +736,12 @@ impl SoarEngine {
event.source_ip,
event.attack_type,
event.confidence * 100.0,
chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC"),
Utc::now().format("%Y-%m-%d %H:%M:%S UTC"),
);
if let Some(recipient) = self.db.get_setting("smtp_recipient")? {
tokio::task::spawn_blocking(move || smtp.send(&recipient, &subject, &body))
spawn_blocking(move || smtp.send(&recipient, &subject, &body))
.await
.map_err(|e| SoarError::ActionFailed {
action_type: "send_email".to_string(),
reason: e.to_string(),
})??;
.map_err(|e| SoarError::ActionFailed("send_email", e))??;
Ok("Email alert sent".to_string())
} else {
Ok("No SMTP recipient configured, skipped".to_string())
@ -773,41 +758,25 @@ impl SoarEngine {
.params
.get("url")
.and_then(|v| v.as_str())
.ok_or_else(|| SoarError::ActionFailed {
action_type: "webhook".to_string(),
reason: "Missing 'url' parameter".to_string(),
})?;
.ok_or_else(|| SoarError::WebhookMissingParam("url"))?;
let timeout_secs = action.params.get("timeout_secs").and_then(|v| v.as_u64()).unwrap_or(10);
// Parse URL and extract host
let parsed_url = url::Url::parse(url_str).map_err(|e| SoarError::ActionFailed {
action_type: "webhook".to_string(),
reason: format!("Invalid URL: {}", e),
})?;
let parsed_url = Url::parse(url_str).map_err(|e| SoarError::ActionFailed("webhook", e))?;
let host = parsed_url.host_str().ok_or_else(|| SoarError::ActionFailed {
action_type: "webhook".to_string(),
reason: "URL has no host".to_string(),
})?;
let host = parsed_url.host_str().ok_or(SoarError::WebhookUrlNoHost)?;
// DNS resolve all IPs and verify none are private/loopback/link-local
let port = parsed_url.port_or_known_default().unwrap_or(443);
let resolve_target = format!("{}:{}", host, port);
let addrs: Vec<std::net::SocketAddr> = tokio::net::lookup_host(&resolve_target)
let addrs: Vec<SocketAddr> = lookup_host(&resolve_target)
.await
.map_err(|e| SoarError::ActionFailed {
action_type: "webhook".to_string(),
reason: format!("DNS resolution failed for '{}': {}", host, e),
})?
.map_err(|e| SoarError::ActionFailed(format!("webhook (DNS for {})", host), e))?
.collect();
if addrs.is_empty() {
return Err(SoarError::ActionFailed {
action_type: "webhook".to_string(),
reason: format!("DNS resolution returned no addresses for '{}'", host),
}
.into());
Err(SoarError::WebhookDnsEmpty(host))?;
}
for addr in &addrs {
@ -817,11 +786,7 @@ impl SoarEngine {
url_str,
addr.ip()
)));
return Err(SoarError::ActionFailed {
action_type: "webhook".to_string(),
reason: format!("SSRF blocked: host '{}' resolves to private IP {}", host, addr.ip()),
}
.into());
Err(SoarError::WebhookSsrfBlocked(host, addr.ip().to_string()))?;
}
}
@ -838,40 +803,32 @@ impl SoarEngine {
"geoip_country": event.geoip_country,
"is_repeat_offender": event.is_repeat_offender,
"detection_sources": sources_str,
"timestamp": chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(),
"timestamp": Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(),
});
// Pin resolved IPs to prevent DNS rebinding: the DNS check above verified
// all resolved addresses are public, so we force reqwest to use those same
// addresses instead of re-resolving (which could return a private IP on TTL expiry).
let mut client_builder = reqwest::Client::builder().timeout(std::time::Duration::from_secs(timeout_secs));
let mut client_builder = Client::builder().timeout(Duration::from_secs(timeout_secs));
for addr in &addrs {
client_builder = client_builder.resolve(host, *addr);
}
let client = client_builder.build().map_err(|e| SoarError::ActionFailed {
action_type: "webhook".to_string(),
reason: format!("HTTP client error: {}", e),
})?;
let client = client_builder
.build()
.map_err(|e| SoarError::ActionFailed("webhook", e))?;
let resp = client
.post(url_str)
.json(&payload)
.send()
.await
.map_err(|e| SoarError::ActionFailed {
action_type: "webhook".to_string(),
reason: format!("HTTP request failed: {}", e),
})?;
.map_err(|e| SoarError::ActionFailed("webhook", e))?;
let status = resp.status();
if status.is_success() {
Ok(format!("Webhook sent to {} (status {})", url_str, status))
} else {
Err(SoarError::ActionFailed {
action_type: "webhook".to_string(),
reason: format!("Webhook returned HTTP {}", status),
}
.into())
Err(SoarError::WebhookHttpStatus(status.as_u16()))?
}
}
@ -1042,11 +999,11 @@ impl SoarEngine {
None => return Ok(()), // No active adjustment
};
let expires = chrono::NaiveDateTime::parse_from_str(&expires_str, "%Y-%m-%d %H:%M:%S")
let expires = NaiveDateTime::parse_from_str(&expires_str, "%Y-%m-%d %H:%M:%S")
.map(|dt| dt.and_utc())
.unwrap_or_else(|_| chrono::Utc::now());
.unwrap_or_else(|_| Utc::now());
if chrono::Utc::now() < expires {
if Utc::now() < expires {
return Ok(()); // Not yet expired
}
@ -1110,7 +1067,7 @@ impl SoarEngine {
let playbooks = self.playbooks.read();
playbooks.iter().map(|p| p.cooldown_secs as u64).max().unwrap_or(3600)
};
let expiry = std::time::Duration::from_secs(max_cooldown_secs.saturating_mul(2).max(3600));
let expiry = Duration::from_secs(max_cooldown_secs.saturating_mul(2).max(3600));
let before = self.cooldowns.len();
self.cooldowns.retain(|_, instant| instant.elapsed() < expiry);
let removed = before.saturating_sub(self.cooldowns.len());
@ -1148,6 +1105,7 @@ impl SoarEngine {
mod tests {
use super::*;
use crate::model::error::ebpf::EbpfError;
use crate::model::event::DetectionSource;
use parking_lot::Mutex;
use std::sync::atomic::AtomicBool;
@ -1169,10 +1127,10 @@ mod tests {
}
#[async_trait::async_trait]
impl crate::interface::port::access_control::AccessControlPort for MockAccessControl {
impl AccessControlPort for MockAccessControl {
async fn block_ip(&self, ip: &str) -> Result<(), Error> {
if self.should_fail.load(Ordering::SeqCst) {
return Err(EbpfError::UnknownError.into());
Err(EbpfError::UnknownError)?;
}
self.blocked_ips.lock().push(ip.to_string());
Ok(())
@ -1180,7 +1138,7 @@ mod tests {
async fn unblock_ip(&self, ip: &str) -> Result<(), Error> {
if self.should_fail.load(Ordering::SeqCst) {
return Err(EbpfError::UnknownError.into());
Err(EbpfError::UnknownError)?;
}
self.unblocked_ips.lock().push(ip.to_string());
Ok(())
@ -1192,7 +1150,7 @@ mod tests {
Arc::new(Database::new(":memory:").expect("Failed to create test database")) as Arc<dyn SoarPort>
}
fn test_engine(ac: Arc<dyn crate::interface::port::access_control::AccessControlPort>) -> SoarEngine {
fn test_engine(ac: Arc<dyn AccessControlPort>) -> SoarEngine {
let db = test_db();
db.seed_default_playbooks().ok();
// Tests expect enforce mode to be active so block_ip actions execute
@ -1217,7 +1175,7 @@ mod tests {
protocol: 6,
geoip_country: None,
is_repeat_offender: false,
sources: vec![crate::model::event::DetectionSource::ML],
sources: vec![DetectionSource::ML],
ae_score: 0.0,
anomaly_score: 0.0,
c2_score: 0.0,
@ -1252,7 +1210,7 @@ mod tests {
protocol: 6,
geoip_country: None,
is_repeat_offender: false,
sources: vec![crate::model::event::DetectionSource::ML],
sources: vec![DetectionSource::ML],
ae_score: 0.0,
anomaly_score: 0.0,
c2_score: 0.0,
@ -1279,7 +1237,7 @@ mod tests {
db.seed_default_playbooks().ok();
// Insert a fake active block
let expires = (chrono::Utc::now() + chrono::Duration::hours(1))
let expires = (Utc::now() + ChronoDuration::hours(1))
.format("%Y-%m-%d %H:%M:%S")
.to_string();
db.insert_soar_block_rule("192.168.1.100", 1, &expires).ok();
@ -1300,7 +1258,7 @@ mod tests {
let db = test_db();
db.seed_default_playbooks().ok();
let expires = (chrono::Utc::now() + chrono::Duration::hours(1))
let expires = (Utc::now() + ChronoDuration::hours(1))
.format("%Y-%m-%d %H:%M:%S")
.to_string();
db.insert_soar_block_rule("10.0.0.1", 1, &expires).ok();
@ -1334,7 +1292,7 @@ mod tests {
protocol: 6,
geoip_country: None,
is_repeat_offender: false,
sources: vec![crate::model::event::DetectionSource::ML],
sources: vec![DetectionSource::ML],
ae_score: 0.0,
anomaly_score: 0.0,
c2_score: 0.0,
@ -1369,7 +1327,7 @@ mod tests {
protocol: 6,
geoip_country: None,
is_repeat_offender: false,
sources: vec![crate::model::event::DetectionSource::ML],
sources: vec![DetectionSource::ML],
ae_score: 0.0,
anomaly_score: 0.0,
c2_score: 0.0,
@ -1414,7 +1372,7 @@ mod tests {
protocol: 6,
geoip_country: None,
is_repeat_offender: false,
sources: vec![crate::model::event::DetectionSource::ML],
sources: vec![DetectionSource::ML],
ae_score: 0.0,
anomaly_score: 0.0,
c2_score: 0.0,
@ -1490,7 +1448,7 @@ mod tests {
protocol: 6,
geoip_country: country.map(|s| s.to_string()),
is_repeat_offender: repeat,
sources: vec![crate::model::event::DetectionSource::ML],
sources: vec![DetectionSource::ML],
ae_score: 0.0,
anomaly_score: 0.0,
c2_score: 0.0,

View File

@ -1,8 +1,10 @@
use std::sync::Arc;
use macros::log;
use tokio::task::JoinHandle;
use tokio::time::{self, Duration};
use crate::core::playbook_service::ip_version_from_str;
use crate::core::soar::engine::SoarEngine;
use crate::interface::port::access_control::AccessControlPort;
use crate::interface::port::soar::SoarPort;
@ -32,7 +34,7 @@ impl TtlScheduler {
}
/// Spawn a background tokio task that runs the TTL sweep every 60 seconds.
pub fn start(self) -> tokio::task::JoinHandle<()> {
pub fn start(self) -> JoinHandle<()> {
tokio::spawn(async move {
log!(SoarLog::EngineStarted); // TTL scheduler uses same log channel
let mut interval = time::interval(Duration::from_secs(60));
@ -93,7 +95,7 @@ impl TtlScheduler {
}
// Also remove from acl_rules DB table (the auto-added entry)
let ip_version = crate::core::playbook_service::ip_version_from_str(source_ip);
let ip_version = ip_version_from_str(source_ip);
if let Err(e) = self.db.delete_acl_rule(ip_version, "source", "blacklist", source_ip, 0) {
log!(SoarError::AclCleanupFailed(e));
}

View File

@ -1,11 +1,14 @@
use std::sync::Arc;
use macros::log;
use serde_json::Value;
use tokio::task::JoinHandle;
use tokio::time::{self, Duration};
use tracing::{error, info};
use crate::interface::port::repository::RepositoryPort;
use crate::interface::port::stats::StatsPort;
use crate::model::error::Error;
use crate::model::log::system::SystemLog;
/// Background service that periodically aggregates statistics from SOAR/ML tables
/// and writes them to the settings table for the Report engine to consume.
@ -20,18 +23,18 @@ impl StatsAggregator {
}
/// Spawn a background task that runs aggregation every hour.
pub fn start(self) -> tokio::task::JoinHandle<()> {
pub fn start(self) -> JoinHandle<()> {
tokio::spawn(async move {
info!("Stats aggregator started (1h interval)");
log!(SystemLog::StatsAggregatorStarted);
// Run immediately on startup
if let Err(e) = self.aggregate() {
error!("Initial stats aggregation failed: {}", e);
log!(SystemLog::InitialStatsAggregationFailed(e.to_string()));
}
let mut interval = time::interval(Duration::from_secs(3600));
loop {
interval.tick().await;
if let Err(e) = self.aggregate() {
error!("Stats aggregation failed: {}", e);
log!(SystemLog::StatsAggregationFailed(e.to_string()));
}
}
})
@ -62,7 +65,7 @@ impl StatsAggregator {
let breakdown = self.stats.weekly_threat_breakdown(days)?;
let breakdown_json: serde_json::Map<String, serde_json::Value> = breakdown
.into_iter()
.map(|(k, v)| (k, serde_json::Value::Number(v.into())))
.map(|(k, v)| (k, Value::Number(v.into())))
.collect();
self.repo.set_setting(
"weekly_threat_breakdown",
@ -133,10 +136,12 @@ impl StatsAggregator {
self.repo.set_setting("weekly_geo_distribution", "[]")?;
}
info!(
"Stats aggregated: {} threats, {} blocks, {} unblocks, {} active rules",
threats_count, blocks_count, unblocks_count, active_rules
);
log!(SystemLog::StatsAggregated(
threats_count,
blocks_count,
unblocks_count,
active_rules,
));
Ok(())
}

View File

@ -1,30 +1,49 @@
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use aya::Ebpf;
use aya::maps::{MapData, ProgramArray};
use macros::log;
use parking_lot::{Mutex, RwLock};
use sd_notify::NotifyState;
use tokio::signal::ctrl_c;
use tokio::sync::broadcast::{Receiver, error::RecvError};
use tokio::sync::mpsc::{self, Sender};
use tokio::sync::oneshot;
use tokio::time::{interval, sleep};
use crate::adapter::persistence::Database;
use crate::core::acl_service::AclService;
use crate::core::auth::jwt::JwtService;
use crate::core::config_service::ConfigService;
use crate::core::correlation::engine::CorrelationEngine;
use crate::core::detection::beaconing::BeaconingDetector;
use crate::core::detection::orchestrator::DetectionOrchestrator;
use crate::core::dns_filter_service::DnsFilterService;
use crate::core::ebpf::EbpfServices;
use crate::core::email::scheduler::ReportScheduler;
use crate::core::ml::drift_detector::DriftDetector;
use crate::core::ml::model_watcher::ModelWatcher;
use crate::core::notification_service::NotificationService;
use crate::core::playbook_service::PlaybookService;
use crate::core::rate_limit_service::RateLimitService;
use crate::core::soar::engine::SoarEngine;
use crate::core::soar::scheduler::TtlScheduler;
use crate::core::stats_aggregator::StatsAggregator;
use crate::infrastructure::app_config::AppConfig;
use crate::infrastructure::app_services::AppServices;
use crate::infrastructure::audit_logger::AuditLogger;
use crate::infrastructure::communication_manager::CommunicationManager;
use crate::infrastructure::geoip::GeoIpService;
use crate::infrastructure::http_server::HttpServerParams;
use crate::infrastructure::http_server::{self, HttpServerParams};
use crate::infrastructure::secret_store::SecretStore;
use crate::infrastructure::service_factory::ServiceFactory;
use crate::infrastructure::suricata_manager::SuricataManager;
use crate::infrastructure::suricata_monitor::SuricataMonitor;
use crate::interface::port::audit::AuditPort;
use crate::interface::port::repository::RepositoryPort;
use crate::interface::port::stats::StatsPort;
use crate::model::detection::ml_detection::AlertMessage;
use crate::model::error::Error;
use crate::model::error::system::SystemError;
@ -33,6 +52,7 @@ use crate::model::log::detection::DetectionLog;
use crate::model::log::ml::MLLog;
use crate::model::log::system::SystemLog;
use crate::model::system::config::MLInferenceConfig;
use crate::model::system::health::EbpfHealth;
use crate::model::system::readiness::ReadinessState;
/// API-triggered shutdown mode.
@ -45,13 +65,13 @@ pub enum ShutdownMode {
/// Handle for triggering shutdown from HTTP endpoints.
/// Uses a parking_lot::Mutex<Option<oneshot::Sender>> so it can be shared as app_data.
pub struct ShutdownHandle {
tx: parking_lot::Mutex<Option<tokio::sync::oneshot::Sender<ShutdownMode>>>,
tx: Mutex<Option<oneshot::Sender<ShutdownMode>>>,
}
impl ShutdownHandle {
fn new(tx: tokio::sync::oneshot::Sender<ShutdownMode>) -> Self {
fn new(tx: oneshot::Sender<ShutdownMode>) -> Self {
Self {
tx: parking_lot::Mutex::new(Some(tx)),
tx: Mutex::new(Some(tx)),
}
}
@ -92,9 +112,9 @@ pub struct System {
pub drift_detector: Arc<parking_lot::Mutex<DriftDetector>>,
pub shutdown_handle: Option<Arc<ShutdownHandle>>,
_ingress_program_array: Option<ProgramArray<MapData>>,
pub ebpf_health: Arc<parking_lot::RwLock<crate::model::system::health::EbpfHealth>>,
pub suricata_manager: Arc<crate::infrastructure::suricata_manager::SuricataManager>,
suricata_shutdown: Option<tokio::sync::oneshot::Sender<()>>,
pub ebpf_health: Arc<RwLock<EbpfHealth>>,
pub suricata_manager: Arc<SuricataManager>,
suricata_shutdown: Option<oneshot::Sender<()>>,
}
impl System {
@ -200,15 +220,13 @@ impl System {
}
// Start audit logger (subscribe to AuditEvent + DriftDetectedEvent, persist to DB)
let audit_logger = Arc::new(AuditLogger::new(
self.db.clone() as Arc<dyn crate::interface::port::audit::AuditPort>
));
let audit_logger = Arc::new(AuditLogger::new(self.db.clone() as Arc<dyn AuditPort>));
audit_logger.start(&self.comm);
// Start stats aggregator (writes weekly_* settings for Report engine)
let stats_aggregator = crate::core::stats_aggregator::StatsAggregator::new(
self.db.clone() as Arc<dyn crate::interface::port::stats::StatsPort>,
self.db.clone() as Arc<dyn crate::interface::port::repository::RepositoryPort>,
let stats_aggregator = StatsAggregator::new(
self.db.clone() as Arc<dyn StatsPort>,
self.db.clone() as Arc<dyn RepositoryPort>,
);
stats_aggregator.start();
@ -222,12 +240,8 @@ impl System {
}
// Start detection orchestrator (dedup + enrichment + source attribution)
let (detection_tx, detection_rx) = tokio::sync::mpsc::channel::<DetectionEvent>(1024);
let orchestrator = crate::core::detection::orchestrator::DetectionOrchestrator::new(
detection_rx,
self.comm.clone(),
self.geoip.clone(),
);
let (detection_tx, detection_rx) = mpsc::channel::<DetectionEvent>(1024);
let orchestrator = DetectionOrchestrator::new(detection_rx, self.comm.clone(), self.geoip.clone());
orchestrator.start();
// Clone detection_tx for correlation engine and beaconing detector
@ -237,14 +251,12 @@ impl System {
// Start cross-flow correlation engine (botnet, scan, lateral movement detection)
let correlation_alert_rx = self.app_services.ml_alert.subscribe_to_alerts();
let correlation_engine =
crate::core::correlation::engine::CorrelationEngine::new(correlation_alert_rx, correlation_detection_tx);
let correlation_engine = CorrelationEngine::new(correlation_alert_rx, correlation_detection_tx);
correlation_engine.start();
// Start temporal beaconing detector (CV-based C2 periodicity detection)
let beaconing_alert_rx = self.app_services.ml_alert.subscribe_to_alerts();
let beaconing_detector =
crate::core::detection::beaconing::BeaconingDetector::new(beaconing_alert_rx, beaconing_detection_tx);
let beaconing_detector = BeaconingDetector::new(beaconing_alert_rx, beaconing_detection_tx);
beaconing_detector.start();
// Bridge ML alerts → DetectionEvent (thin adapter, no enrichment)
@ -253,7 +265,7 @@ impl System {
});
// Start model hot-reload watcher (monitors models/ for .onnx changes)
let model_watcher = crate::core::ml::model_watcher::ModelWatcher::new(
let model_watcher = ModelWatcher::new(
self.app_services.ml_engine.inference_pipeline().clone(),
self.app_config.clone(),
self.inference_config.clone(),
@ -261,7 +273,7 @@ impl System {
model_watcher.start();
// Initialize force_https flag from DB setting
let force_https = Arc::new(std::sync::atomic::AtomicBool::new(
let force_https = Arc::new(AtomicBool::new(
self.db
.get_setting("force_https")
.ok()
@ -273,29 +285,21 @@ impl System {
// Build per-subsystem readiness flags for /health/ready
let readiness_state = Arc::new(ReadinessState::new());
// DB is connected (System::new succeeded), ML models loaded (AppServices::new succeeded)
readiness_state
.db_connected
.store(true, std::sync::atomic::Ordering::SeqCst);
readiness_state
.ml_model_loaded
.store(true, std::sync::atomic::Ordering::SeqCst);
readiness_state.db_connected.store(true, Ordering::SeqCst);
readiness_state.ml_model_loaded.store(true, Ordering::SeqCst);
// eBPF was attached above (self.attach_ebpf succeeded)
readiness_state
.ebpf_attached
.store(true, std::sync::atomic::Ordering::SeqCst);
readiness_state.ebpf_attached.store(true, Ordering::SeqCst);
// SOAR engine started above (self.soar_engine.start succeeded)
readiness_state
.soar_engine_running
.store(true, std::sync::atomic::Ordering::SeqCst);
readiness_state.soar_engine_running.store(true, Ordering::SeqCst);
// Create shutdown channel for API-triggered shutdown/restart
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<ShutdownMode>();
let (shutdown_tx, shutdown_rx) = oneshot::channel::<ShutdownMode>();
let shutdown_handle = Arc::new(ShutdownHandle::new(shutdown_tx));
self.shutdown_handle = Some(shutdown_handle.clone());
// Start HTTP server in background (!Send, use actix::spawn)
let setup_flag = Arc::new(std::sync::atomic::AtomicBool::new(true));
let ready_flag = Arc::new(std::sync::atomic::AtomicBool::new(false));
let setup_flag = Arc::new(AtomicBool::new(true));
let ready_flag = Arc::new(AtomicBool::new(false));
let ready_flag_for_set = ready_flag.clone();
let params = HttpServerParams {
app_config: self.app_config.clone(),
@ -321,33 +325,33 @@ impl System {
};
let ready_for_http = ready_flag_for_set.clone();
actix::spawn(async move {
if let Err(e) = crate::infrastructure::http_server::run(params).await {
if let Err(e) = http_server::run(params).await {
// HTTP server failed — mark system as NOT ready so health checks fail
ready_for_http.store(false, std::sync::atomic::Ordering::SeqCst);
ready_for_http.store(false, Ordering::SeqCst);
log!(SystemError::HttpServerError(e));
}
});
// Brief delay to catch immediate bind failures before reporting ready
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
sleep(Duration::from_millis(100)).await;
// Mark system as ready — /api/health/ready will now return {"ready": true}
ready_flag_for_set.store(true, std::sync::atomic::Ordering::SeqCst);
ready_flag_for_set.store(true, Ordering::SeqCst);
// Notify systemd that we are ready (Type=notify)
let _ = sd_notify::notify(true, &[sd_notify::NotifyState::Ready]);
let _ = sd_notify::notify(true, &[NotifyState::Ready]);
log!(SystemLog::FullInitComplete);
// Start systemd watchdog keepalive task
{
let mut usec: u64 = 0;
if sd_notify::watchdog_enabled(false, &mut usec) && usec > 0 {
let notify_interval = std::time::Duration::from_micros(usec / 2);
let notify_interval = Duration::from_micros(usec / 2);
tokio::spawn(async move {
let mut tick = tokio::time::interval(notify_interval);
let mut tick = interval(notify_interval);
loop {
tick.tick().await;
let _ = sd_notify::notify(false, &[sd_notify::NotifyState::Watchdog]);
let _ = sd_notify::notify(false, &[NotifyState::Watchdog]);
}
});
}
@ -359,12 +363,11 @@ impl System {
// Start Suricata eve.json monitor — tails the log file, translates
// alert events into DetectionEvent on the shared mpsc. No-op if the
// bridge is disabled in config.
crate::infrastructure::suricata_monitor::SuricataMonitor::new(self.app_config.clone(), suricata_detection_tx)
.start();
SuricataMonitor::new(self.app_config.clone(), suricata_detection_tx).start();
// Wait for shutdown signal (ctrl-c OR API-triggered)
tokio::select! {
_ = tokio::signal::ctrl_c() => {
_ = ctrl_c() => {
Ok(ShutdownMode::Shutdown)
}
mode = shutdown_rx => {
@ -409,7 +412,7 @@ impl System {
drift_detector: Arc<parking_lot::Mutex<DriftDetector>>,
comm: Arc<CommunicationManager>,
) {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(60));
let mut interval = interval(Duration::from_secs(60));
loop {
interval.tick().await;
let report = drift_detector.lock().check_drift();
@ -431,10 +434,7 @@ impl System {
/// Thin ML bridge: converts AlertMessage → DetectionEvent and sends to orchestrator.
/// Enrichment (GeoIP, hit count, repeat offender) is handled by the DetectionOrchestrator.
async fn bridge_ml_to_detection(
mut rx: tokio::sync::broadcast::Receiver<AlertMessage>,
tx: tokio::sync::mpsc::Sender<DetectionEvent>,
) {
async fn bridge_ml_to_detection(mut rx: Receiver<AlertMessage>, tx: Sender<DetectionEvent>) {
log!(DetectionLog::MlBridgeStarted);
loop {
@ -465,10 +465,10 @@ impl System {
break; // Orchestrator dropped
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
Err(RecvError::Lagged(n)) => {
log!(DetectionLog::MlBridgeLagged(n));
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
Err(RecvError::Closed) => {
log!(DetectionLog::MlAlertChannelClosed);
break;
}

View File

@ -1,6 +1,7 @@
use std::sync::Arc;
use macros::log;
use tokio::sync::broadcast::error::RecvError;
use crate::infrastructure::communication_manager::CommunicationManager;
use crate::interface::port::audit::AuditPort;
@ -30,10 +31,10 @@ impl AuditLogger {
Ok(event) => {
this.handle_audit_event(&event);
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
Err(RecvError::Lagged(n)) => {
log!(AuditLog::AuditLagged { count: n });
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
Err(RecvError::Closed) => {
log!(AuditLog::AuditChannelClosed);
break;
}
@ -53,10 +54,10 @@ impl AuditLogger {
Ok(event) => {
this.handle_drift_event(&event);
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
Err(RecvError::Lagged(n)) => {
log!(AuditLog::AuditLagged { count: n });
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
Err(RecvError::Closed) => {
log!(AuditLog::AuditChannelClosed);
break;
}

View File

@ -164,6 +164,8 @@ impl<S: Send + Sync + 'static> ServiceRegistrar<S> {
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use super::*;
use crate::interface::communication::command::Command;
use crate::interface::communication::event::Event;
@ -183,7 +185,7 @@ mod tests {
impl Command for TestCommand {}
struct TestCommandHandler {
received: Arc<std::sync::Mutex<Vec<String>>>,
received: Arc<Mutex<Vec<String>>>,
}
#[async_trait]
@ -226,7 +228,7 @@ mod tests {
#[tokio::test]
async fn test_command_dispatch() {
let received = Arc::new(std::sync::Mutex::new(Vec::new()));
let received = Arc::new(Mutex::new(Vec::new()));
let handler = Arc::new(TestCommandHandler {
received: received.clone(),
});
@ -305,7 +307,7 @@ mod tests {
#[tokio::test]
async fn test_service_registrar() {
let received = Arc::new(std::sync::Mutex::new(Vec::new()));
let received = Arc::new(Mutex::new(Vec::new()));
let handler = Arc::new(TestCommandHandler {
received: received.clone(),
});

View File

@ -85,7 +85,7 @@ mod tests {
let db = Arc::new(Database::new(":memory:").unwrap()) as Arc<dyn RepositoryPort>;
let cache = Arc::new(AtomicU8::new(0));
let comm = Arc::new(CommunicationManager::new());
comm.register_event_type::<crate::model::event::AuditEvent>();
comm.register_event_type::<AuditEvent>();
let handler = Arc::new(EnforceModeHandler::new(db, comm.clone(), cache));
let _ = comm
.clone()

View File

@ -1,5 +1,6 @@
use std::env::consts;
use std::sync::Arc;
use std::time::Duration;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use macros::log;
use sysinfo::{Components, Networks, System};
@ -102,8 +103,8 @@ impl SystemHealth {
egress_interface: &str,
ebpf: EbpfHealth,
) -> SystemHealthMetrics {
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
@ -162,7 +163,7 @@ impl SystemHealth {
kernel_version: System::kernel_version(),
os_name: System::name(),
os_version: System::os_version(),
architecture: std::env::consts::ARCH.to_string(),
architecture: consts::ARCH.to_string(),
total_processes: system.processes().len(),
}
}

View File

@ -1,7 +1,12 @@
use std::net::{Ipv4Addr, Ipv6Addr};
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use actix_cors::Cors;
use actix_web::dev::ServerHandle;
use actix_web::web::route;
use actix_web::{App, HttpServer, web};
use actix_web::{App, HttpResponse, HttpServer, web};
use macros::log;
use crate::adapter::http::{
acl, api_keys, audit as audit_api, auth, default, filter, health as health_api, logs as logs_api, ml,
@ -13,6 +18,7 @@ use crate::adapter::websocket::routes as ws;
use crate::core::acl_service::AclService;
use crate::core::auth::https_redirect::{ForceHttpsFlag, HttpsRedirect};
use crate::core::auth::jwt::JwtService;
use crate::core::auth::middleware::AuthMiddleware;
use crate::core::auth::setup_guard::{SetupCompleteFlag, SetupGuard};
use crate::core::config_service::ConfigService;
use crate::core::dns_filter_service::DnsFilterService;
@ -25,6 +31,7 @@ use crate::infrastructure::app_config::AppConfig;
use crate::infrastructure::app_services::AppServices;
use crate::infrastructure::communication_manager::CommunicationManager;
use crate::infrastructure::secret_store::SecretStore;
use crate::infrastructure::suricata_manager::SuricataManager;
use crate::interface::port::api_key::ApiKeyPort;
use crate::interface::port::repository::RepositoryPort;
use crate::model::config::constants::HTTP_FALLBACK_PORT;
@ -32,12 +39,10 @@ use crate::model::error::Error;
use crate::model::error::http::HttpError;
use crate::model::log::http::HttpLog;
use crate::model::system::config::MLInferenceConfig;
use macros::log;
use crate::model::system::readiness::ReadinessState;
/// Shared flag: true when all services (eBPF, ML, SOAR) are fully initialized.
pub type ReadyFlag = Arc<std::sync::atomic::AtomicBool>;
use crate::model::system::readiness::ReadinessState;
pub type ReadyFlag = Arc<AtomicBool>;
/// Parameters for starting the HTTP server, avoiding `#[cfg]` on function params.
pub struct HttpServerParams {
@ -60,7 +65,7 @@ pub struct HttpServerParams {
pub rate_limit_service: Arc<RateLimitService>,
pub force_https: ForceHttpsFlag,
pub shutdown_handle: Arc<ShutdownHandle>,
pub suricata_manager: Arc<crate::infrastructure::suricata_manager::SuricataManager>,
pub suricata_manager: Arc<SuricataManager>,
}
/// CORS configuration shared by both full and setup servers.
@ -72,7 +77,7 @@ pub struct HttpServerParams {
/// The host is parsed as an IP address — domain names like "10.malware.net"
/// are rejected because they fail IP parsing.
fn cors(allowed_origins: Vec<String>) -> actix_cors::Cors {
actix_cors::Cors::default()
Cors::default()
.allowed_origin_fn(move |origin, _req_head| {
let origin_str = origin.to_str().unwrap_or("");
if !allowed_origins.is_empty() {
@ -117,7 +122,7 @@ fn is_private_origin(origin: &str) -> bool {
}
// Try parsing as IPv4
if let Ok(ipv4) = host.parse::<std::net::Ipv4Addr>() {
if let Ok(ipv4) = host.parse::<Ipv4Addr>() {
let octets = ipv4.octets();
return octets[0] == 127 // 127.0.0.0/8
|| octets[0] == 10 // 10.0.0.0/8
@ -126,7 +131,7 @@ fn is_private_origin(origin: &str) -> bool {
}
// Try parsing as IPv6
if let Ok(ipv6) = host.parse::<std::net::Ipv6Addr>() {
if let Ok(ipv6) = host.parse::<Ipv6Addr>() {
return ipv6.is_loopback();
}
@ -143,7 +148,7 @@ pub fn start_setup_server(
jwt_service: Arc<JwtService>,
setup_complete: SetupCompleteFlag,
port: u16,
) -> Result<actix_web::dev::ServerHandle, Error> {
) -> Result<ServerHandle, Error> {
let make_app = move || {
App::new()
.wrap(cors(vec![]))
@ -155,7 +160,7 @@ pub fn start_setup_server(
.app_data(web::Data::new(setup_complete.clone()))
.service(
web::scope("/api")
.wrap(crate::core::auth::middleware::AuthMiddleware)
.wrap(AuthMiddleware)
.service(auth::initialize())
.service(setup_api::initialize())
.service(health_api::initialize()),
@ -177,7 +182,7 @@ pub fn start_setup_server(
.bind(format!("0.0.0.0:{}", HTTP_FALLBACK_PORT))
.map_err(HttpError::BindPortError)?
}
Err(e) => return Err(HttpError::BindPortError(e).into()),
Err(e) => Err(HttpError::BindPortError(e))?,
}
.run();
@ -262,7 +267,7 @@ pub async fn run(params: HttpServerParams) -> Result<(), Error> {
app.wrap(SetupGuard)
.service(
web::scope("/api")
.wrap(crate::core::auth::middleware::AuthMiddleware)
.wrap(AuthMiddleware)
.service(auth::initialize())
.service(acl::initialize())
.service(filter::initialize())
@ -293,13 +298,13 @@ pub async fn run(params: HttpServerParams) -> Result<(), Error> {
Ok(())
}
async fn health_ready(ready: web::Data<ReadyFlag>, state: web::Data<ReadinessState>) -> actix_web::HttpResponse {
async fn health_ready(ready: web::Data<ReadyFlag>, state: web::Data<ReadinessState>) -> HttpResponse {
use std::sync::atomic::Ordering::SeqCst;
let is_ready = ready.load(SeqCst);
let uptime_secs = state.started_at.elapsed().as_secs();
actix_web::HttpResponse::Ok().json(serde_json::json!({
HttpResponse::Ok().json(serde_json::json!({
"ready": is_ready,
"subsystems": {
"db_connected": state.db_connected.load(SeqCst),

View File

@ -1,3 +1,4 @@
use std::env;
use std::sync::Arc;
use aes_gcm::aead::{Aead, KeyInit, OsRng};
@ -24,10 +25,10 @@ pub struct SecretStore {
impl SecretStore {
pub fn new(db: Arc<Database>) -> Self {
let raw_key = std::env::var("NETGUARDIA_SECRETS_KEY")
let raw_key = env::var("NETGUARDIA_SECRETS_KEY")
.ok()
.filter(|k| !k.is_empty())
.or_else(|| std::env::var("NETGUARDIA_DB_KEY").ok().filter(|k| !k.is_empty()));
.or_else(|| env::var("NETGUARDIA_DB_KEY").ok().filter(|k| !k.is_empty()));
let cipher = raw_key.map(|key| {
let hk = Hkdf::<Sha256>::new(Some(b"netguardia-v1-salt"), key.as_bytes());
@ -53,7 +54,7 @@ impl SecretStore {
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
let ciphertext = cipher
.encrypt(&nonce, plaintext.as_bytes())
.map_err(|e| CryptoError::EncryptionFailed { reason: e.to_string() })?;
.map_err(CryptoError::EncryptionFailed)?;
let envelope = serde_json::json!({
"v": 1,
"alg": "aes-256-gcm",
@ -76,24 +77,18 @@ impl SecretStore {
}
fn decrypt(&self, envelope_json: &str) -> Result<String, Error> {
let env: serde_json::Value =
serde_json::from_str(envelope_json).map_err(|e| CryptoError::InvalidEnvelope { reason: e.to_string() })?;
let env: serde_json::Value = serde_json::from_str(envelope_json).map_err(CryptoError::EnvelopeParseFailed)?;
let version = env.get("v").and_then(|v| v.as_u64()).unwrap_or(0);
if version != 1 {
return Err(CryptoError::InvalidEnvelope {
reason: format!("unsupported envelope version: {version}"),
}
.into());
Err(CryptoError::UnsupportedEnvelopeVersion(version))?;
}
let alg = env.get("alg").and_then(|v| v.as_str()).unwrap_or("");
let ct_b64 = env
.get("ct")
.and_then(|v| v.as_str())
.ok_or_else(|| CryptoError::InvalidEnvelope {
reason: "missing ct field".to_string(),
})?;
.ok_or_else(|| CryptoError::MissingEnvelopeField("ct"))?;
match alg {
"none" => {
@ -101,50 +96,31 @@ impl SecretStore {
// Prevents downgrade attack where attacker replaces encrypted envelope
// with alg:none + attacker-controlled plaintext.
if self.cipher.is_some() {
return Err(CryptoError::InvalidEnvelope {
reason: "alg:none rejected in production mode (encryption key is set)".to_string(),
}
.into());
Err(CryptoError::AlgNoneRejected)?;
}
let plaintext_bytes = B64
.decode(ct_b64)
.map_err(|e| CryptoError::DecryptionFailed { reason: e.to_string() })?;
String::from_utf8(plaintext_bytes)
.map_err(|e| CryptoError::DecryptionFailed { reason: e.to_string() }.into())
let plaintext_bytes = B64.decode(ct_b64).map_err(CryptoError::DecryptionFailed)?;
Ok(String::from_utf8(plaintext_bytes).map_err(CryptoError::DecryptionFailed)?)
}
"aes-256-gcm" => {
let cipher = self.cipher.as_ref().ok_or(CryptoError::MasterKeyUnavailable)?;
let nonce_b64 =
env.get("nonce")
.and_then(|v| v.as_str())
.ok_or_else(|| CryptoError::InvalidEnvelope {
reason: "missing nonce field".to_string(),
})?;
let nonce_b64 = env
.get("nonce")
.and_then(|v| v.as_str())
.ok_or_else(|| CryptoError::MissingEnvelopeField("nonce"))?;
let nonce_bytes = B64
.decode(nonce_b64)
.map_err(|e| CryptoError::DecryptionFailed { reason: e.to_string() })?;
let nonce =
Nonce::from_exact_iter(nonce_bytes.into_iter()).ok_or_else(|| CryptoError::DecryptionFailed {
reason: "invalid nonce length".to_string(),
})?;
let nonce_bytes = B64.decode(nonce_b64).map_err(CryptoError::DecryptionFailed)?;
let nonce = Nonce::from_exact_iter(nonce_bytes.into_iter()).ok_or(CryptoError::InvalidNonceLength)?;
let ciphertext = B64
.decode(ct_b64)
.map_err(|e| CryptoError::DecryptionFailed { reason: e.to_string() })?;
let ciphertext = B64.decode(ct_b64).map_err(CryptoError::DecryptionFailed)?;
let plaintext_bytes = cipher
.decrypt(&nonce, ciphertext.as_ref())
.map_err(|e| CryptoError::DecryptionFailed { reason: e.to_string() })?;
.map_err(CryptoError::DecryptionFailed)?;
String::from_utf8(plaintext_bytes)
.map_err(|e| CryptoError::DecryptionFailed { reason: e.to_string() }.into())
Ok(String::from_utf8(plaintext_bytes).map_err(CryptoError::DecryptionFailed)?)
}
other => Err(CryptoError::InvalidEnvelope {
reason: format!("unsupported algorithm: {other}"),
}
.into()),
other => Err(CryptoError::UnsupportedAlgorithm(other))?,
}
}
}

View File

@ -1,7 +1,11 @@
use std::collections::HashMap;
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddrV4, SocketAddrV6};
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::AtomicU8;
use std::time::Duration;
use parking_lot::{Mutex, RwLock};
use aya::Ebpf;
use aya::maps::{Array, MapData, ProgramArray};
@ -29,9 +33,11 @@ use crate::core::soar::scheduler::TtlScheduler;
use crate::infrastructure::app_config::AppConfig;
use crate::infrastructure::app_services::AppServices;
use crate::infrastructure::communication_manager::CommunicationManager;
use crate::infrastructure::ebpf_preflight;
use crate::infrastructure::enforce_mode_handler::EnforceModeHandler;
use crate::infrastructure::geoip::GeoIpService;
use crate::infrastructure::secret_store::SecretStore;
use crate::infrastructure::suricata_manager::SuricataManager;
use crate::interface::communication::command_types::ChangeEnforceModeCommand;
use crate::interface::communication::query_types::GetEnforceModeQuery;
use crate::interface::port::access_control::AccessControlPort;
@ -44,11 +50,13 @@ use crate::model::detection::drift::FeatureBaselines;
use crate::model::error::Error;
use crate::model::error::ebpf::EbpfError;
use crate::model::error::misc::MiscError;
use crate::model::event::{AuditEvent, DriftDetectedEvent, ThreatDetectedEvent};
use crate::model::log::ebpf::EbpfLog;
use crate::model::log::ml::MLLog;
use crate::model::log::system::SystemLog;
use crate::model::monitoring::direction::FlowDirection;
use crate::model::system::config::MLInferenceConfig;
use crate::model::system::health::EbpfFailStage;
use crate::model::system::health::EbpfHealth;
use macros::log;
@ -72,7 +80,7 @@ pub struct AppState {
pub playbook_service: Arc<PlaybookService>,
pub rate_limit_service: Arc<RateLimitService>,
pub geoip: Option<Arc<GeoIpService>>,
pub drift_detector: Arc<parking_lot::Mutex<DriftDetector>>,
pub drift_detector: Arc<Mutex<DriftDetector>>,
pub ingress_ebpf: Option<Ebpf>,
pub egress_ebpf: Option<Ebpf>,
/// Held to keep the eBPF program array map FD alive. `None` when eBPF
@ -82,8 +90,8 @@ pub struct AppState {
/// or to `Unavailable { stage, category, reason }` when any stage fails.
/// Read by `SystemHealth` for the metrics broadcast and by HTTP handlers
/// that render runtime status to the frontend.
pub ebpf_health: Arc<parking_lot::RwLock<EbpfHealth>>,
pub suricata_manager: Arc<crate::infrastructure::suricata_manager::SuricataManager>,
pub ebpf_health: Arc<RwLock<EbpfHealth>>,
pub suricata_manager: Arc<SuricataManager>,
}
/// Maps stage name (from config.toml) to (function_name, stage_id).
@ -109,7 +117,7 @@ impl ServiceFactory {
// Prefer `models/manifest.yaml` when present (v12 BYO-model path). The manifest
// is the user-authored source of truth for features, labels, thresholds, and
// model filenames; the legacy JSON-only path is the fallback.
let manifest_path = std::path::PathBuf::from("models/manifest.yaml");
let manifest_path = PathBuf::from("models/manifest.yaml");
let (inference_config, ml_manifest): (Arc<MLInferenceConfig>, Option<ModelManifest>) = if manifest_path.exists()
{
let (cfg, manifest) = MLInferenceConfig::from_manifest_with_sidecar(&manifest_path)?;
@ -129,7 +137,7 @@ impl ServiceFactory {
// Shared eBPF health handle. Initialized Healthy; downgraded to
// Unavailable with a classified reason if any stage below fails.
let ebpf_health = Arc::new(parking_lot::RwLock::new(EbpfHealth::Healthy));
let ebpf_health = Arc::new(RwLock::new(EbpfHealth::Healthy));
// Attempt full eBPF bring-up. On any failure we classify the error,
// write it into `ebpf_health`, and fall back to an `EbpfServices`
@ -141,7 +149,7 @@ impl ServiceFactory {
{
Ok((ingress, egress, pa, services)) => (Some(ingress), Some(egress), Some(pa), Arc::new(services)),
Err((stage, err)) => {
let health = crate::infrastructure::ebpf_preflight::classify(stage, &err, None);
let health = ebpf_preflight::classify(stage, &err, None);
log!(SystemLog::EbpfBringupFailed(format!("{:?}", health)));
*ebpf_health.write() = health;
(
@ -171,9 +179,9 @@ impl ServiceFactory {
.flatten()
.and_then(|v| v.parse().ok())
.unwrap_or(3600);
let drift_detector = Arc::new(parking_lot::Mutex::new(DriftDetector::new(
let drift_detector = Arc::new(Mutex::new(DriftDetector::new(
baselines,
std::time::Duration::from_secs(drift_window_secs),
Duration::from_secs(drift_window_secs),
)));
let app_services = Arc::new(AppServices::new(
@ -206,9 +214,9 @@ impl ServiceFactory {
.build();
// Register event type channels
comm.register_event_type::<crate::model::event::ThreatDetectedEvent>();
comm.register_event_type::<crate::model::event::DriftDetectedEvent>();
comm.register_event_type::<crate::model::event::AuditEvent>();
comm.register_event_type::<ThreatDetectedEvent>();
comm.register_event_type::<DriftDetectedEvent>();
comm.register_event_type::<AuditEvent>();
// Seed default SOAR playbooks if empty
(db.as_ref() as &dyn SoarPort).seed_default_playbooks()?;
@ -294,7 +302,7 @@ impl ServiceFactory {
secret_store_port,
));
let suricata_manager = crate::infrastructure::suricata_manager::SuricataManager::new(app_config.clone());
let suricata_manager = SuricataManager::new(app_config.clone());
Ok(AppState {
app_config,
@ -333,8 +341,7 @@ impl ServiceFactory {
#[allow(clippy::type_complexity)]
fn try_build_ebpf(
app_config: &Arc<AppConfig>,
) -> Result<(Ebpf, Ebpf, ProgramArray<MapData>, EbpfServices), (crate::model::system::health::EbpfFailStage, Error)>
{
) -> Result<(Ebpf, Ebpf, ProgramArray<MapData>, EbpfServices), (EbpfFailStage, Error)> {
use crate::model::system::health::EbpfFailStage;
let mut ingress = Self::load_ebpf("ingress").map_err(|e| (EbpfFailStage::Load, e))?;
@ -357,7 +364,7 @@ impl ServiceFactory {
let bytes = match name {
"ingress" => aya::include_bytes_aligned!(concat!(env!("OUT_DIR"), "/net-guardia-ingress")),
"egress" => aya::include_bytes_aligned!(concat!(env!("OUT_DIR"), "/net-guardia-egress")),
_ => return Err(EbpfError::ProgramNotFound.into()),
_ => Err(EbpfError::ProgramNotFound)?,
};
Ok(Ebpf::load(bytes).map_err(EbpfError::EbpfNotFound)?)
}
@ -426,7 +433,7 @@ impl ServiceFactory {
.try_into()
.map_err(EbpfError::MapOperationError)?;
program.load().map_err(EbpfError::AttachProgramFailed)?;
let fd = program.fd().map_err(|_| EbpfError::UnknownError)?;
let fd = program.fd().map_err(EbpfError::ProgramFdFailed)?;
program_array.set(slot, fd, 0).map_err(EbpfError::MapOperationError)?;
Ok(())
}
@ -479,7 +486,7 @@ impl ServiceFactory {
}
Err(skb_err) => {
log!(EbpfLog::XdpAttachFailed(ifname.to_string(), skb_err.to_string()));
Err(EbpfError::AttachProgramFailed(skb_err).into())
Err(EbpfError::AttachProgramFailed(skb_err))?
}
}
}

View File

@ -1,5 +1,5 @@
use std::sync::Arc;
use std::time;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::core::ml::engine::Engine;
use crate::core::ml::flow_tracker::FlowData;
@ -49,8 +49,8 @@ impl FlowStatistics {
}
pub fn get_filtered_flows(&self, sub: &FlowSubscription) -> Vec<FlowStatsEntry> {
let now_us = time::SystemTime::now()
.duration_since(time::UNIX_EPOCH)
let now_us = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_micros() as u64)
.unwrap_or(0);
@ -102,8 +102,8 @@ impl FlowStatistics {
}
}
let now_ms = time::SystemTime::now()
.duration_since(time::UNIX_EPOCH)
let now_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);

View File

@ -16,8 +16,10 @@ use std::sync::Arc;
use std::time::Duration;
use macros::log;
use parking_lot::RwLock;
use tokio::process::{Child, Command};
use tokio::sync::oneshot;
use tokio::time::{sleep, timeout};
use crate::infrastructure::app_config::AppConfig;
use crate::model::error::Error;
@ -27,7 +29,7 @@ use crate::model::system::suricata::SuricataHealth;
pub struct SuricataManager {
config: Arc<AppConfig>,
health: Arc<parking_lot::RwLock<SuricataHealth>>,
health: Arc<RwLock<SuricataHealth>>,
}
impl SuricataManager {
@ -41,12 +43,12 @@ impl SuricataManager {
};
Arc::new(Self {
config,
health: Arc::new(parking_lot::RwLock::new(initial)),
health: Arc::new(RwLock::new(initial)),
})
}
/// Shared handle for HTTP handlers and the health broadcast.
pub fn health(&self) -> Arc<parking_lot::RwLock<SuricataHealth>> {
pub fn health(&self) -> Arc<RwLock<SuricataHealth>> {
self.health.clone()
}
@ -100,7 +102,7 @@ impl SuricataManager {
backoff,
});
*self.health.write() = SuricataHealth::Stopped { reason };
tokio::time::sleep(Duration::from_secs(backoff)).await;
sleep(Duration::from_secs(backoff)).await;
continue;
} else {
log!(SuricataLog::Stopped { reason: reason.clone() });
@ -123,11 +125,11 @@ impl SuricataManager {
fn preflight(config: &AppConfig) -> Result<(), Error> {
let bin = &config.suricata.binary_path;
if !Path::new(bin).exists() {
return Err(SuricataError::BinaryNotFound { path: bin.clone() }.into());
Err(SuricataError::BinaryNotFound(bin.clone()))?;
}
let cfg = &config.suricata.config_path;
if !Path::new(cfg).exists() {
return Err(SuricataError::ConfigNotFound { path: cfg.clone() }.into());
Err(SuricataError::ConfigNotFound(cfg.clone()))?;
}
Ok(())
}
@ -169,7 +171,7 @@ impl SuricataManager {
libc::kill(pid as libc::pid_t, libc::SIGTERM);
}
}
match tokio::time::timeout(Duration::from_secs(5), child.wait()).await {
match timeout(Duration::from_secs(5), child.wait()).await {
Ok(_) => {}
Err(_) => {
let _ = child.kill().await;

View File

@ -20,9 +20,10 @@ use std::sync::Arc;
use std::time::Duration;
use macros::log;
use tokio::fs::File;
use tokio::fs::{self, File};
use tokio::io::{AsyncBufReadExt, AsyncSeekExt, BufReader};
use tokio::sync::mpsc;
use tokio::time::sleep;
use crate::infrastructure::app_config::AppConfig;
use crate::model::event::{DetectionEvent, DetectionSource};
@ -64,14 +65,14 @@ impl SuricataMonitor {
if !Path::new(&path).exists() {
log!(SuricataLog::MonitorWaitingForFile { path: path.clone() });
while !Path::new(&path).exists() {
tokio::time::sleep(FILE_WAIT_INTERVAL).await;
sleep(FILE_WAIT_INTERVAL).await;
}
}
let mut file = match File::open(&path).await {
Ok(f) => f,
Err(_) => {
tokio::time::sleep(FILE_WAIT_INTERVAL).await;
sleep(FILE_WAIT_INTERVAL).await;
continue;
}
};
@ -88,13 +89,13 @@ impl SuricataMonitor {
match reader.read_line(&mut line).await {
Ok(0) => {
// EOF — check for rotation (file truncated or replaced).
if let Ok(meta) = tokio::fs::metadata(&path).await
if let Ok(meta) = fs::metadata(&path).await
&& meta.len() < pos
{
log!(SuricataLog::MonitorFileRotated);
break; // reopen
}
tokio::time::sleep(POLL_INTERVAL).await;
sleep(POLL_INTERVAL).await;
}
Ok(n) => {
pos += n as u64;
@ -102,7 +103,7 @@ impl SuricataMonitor {
}
Err(_) => {
// Read error — treat as rotation and reopen.
tokio::time::sleep(POLL_INTERVAL).await;
sleep(POLL_INTERVAL).await;
break;
}
}

View File

@ -5,15 +5,23 @@ mod interface;
mod model;
mod utils;
use std::env;
use std::path::PathBuf;
use std::process;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
use std::time::Duration;
use macros::log;
use sd_notify::NotifyState;
use tokio::{signal, time};
use crate::adapter::persistence::Database;
use crate::core::auth::jwt::JwtService;
use crate::core::auth::password;
use crate::core::system::System;
use crate::core::system::{ShutdownMode, System};
use crate::infrastructure::http_server;
use crate::infrastructure::secret_store::SecretStore;
use crate::interface::port::secret_store::SecretStorePort;
use crate::model::error::Error;
@ -44,16 +52,16 @@ async fn main() -> Result<(), Error> {
Logging::initialize()?;
// Handle DB encrypt/decrypt subcommands before full startup
let args: Vec<String> = std::env::args().collect();
let args: Vec<String> = env::args().collect();
if args.len() >= 2 {
let db_path = std::env::var("NETGUARDIA_DB_PATH").unwrap_or_else(|_| "net-guardia.db".to_string());
let db_path = env::var("NETGUARDIA_DB_PATH").unwrap_or_else(|_| "net-guardia.db".to_string());
match args[1].as_str() {
"--decrypt-db" => {
let key = match std::env::var("NETGUARDIA_DB_KEY") {
let key = match env::var("NETGUARDIA_DB_KEY") {
Ok(k) if !k.is_empty() => k,
_ => {
eprintln!("Error: NETGUARDIA_DB_KEY must be set for decrypt");
std::process::exit(1);
process::exit(1);
}
};
let dest = args.get(2).map(|s| s.as_str()).unwrap_or("net-guardia-decrypted.db");
@ -63,11 +71,11 @@ async fn main() -> Result<(), Error> {
return Ok(());
}
"--encrypt-db" => {
let key = match std::env::var("NETGUARDIA_DB_KEY") {
let key = match env::var("NETGUARDIA_DB_KEY") {
Ok(k) if !k.is_empty() => k,
_ => {
eprintln!("Error: NETGUARDIA_DB_KEY must be set for encrypt");
std::process::exit(1);
process::exit(1);
}
};
let dest = args.get(2).map(|s| s.as_str()).unwrap_or("net-guardia-encrypted.db");
@ -86,7 +94,7 @@ async fn main() -> Result<(), Error> {
}
Err(e) => {
eprintln!("FAIL: {}", e);
std::process::exit(2);
process::exit(2);
}
}
}
@ -95,7 +103,7 @@ async fn main() -> Result<(), Error> {
}
// Phase 1: Create DB (fast — needed for setup check and setup server)
let db_path = std::env::var("NETGUARDIA_DB_PATH").unwrap_or_else(|_| "net-guardia.db".to_string());
let db_path = env::var("NETGUARDIA_DB_PATH").unwrap_or_else(|_| "net-guardia.db".to_string());
let db = Arc::new(Database::new(&db_path)?);
// Seed default admin user if no users exist
@ -123,19 +131,13 @@ async fn main() -> Result<(), Error> {
let setup_flag = Arc::new(AtomicBool::new(false));
// Start setup server — returns handle for graceful shutdown
let handle = infrastructure::http_server::start_setup_server(
db.clone(),
secret_store,
jwt_service,
setup_flag.clone(),
8080,
)?;
let handle = http_server::start_setup_server(db.clone(), secret_store, jwt_service, setup_flag.clone(), 8080)?;
// Wait for setup completion or shutdown signal
let flag = setup_flag.clone();
let setup_done = async move {
loop {
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
time::sleep(Duration::from_millis(500)).await;
if flag.load(Ordering::SeqCst) {
return;
}
@ -146,7 +148,7 @@ async fn main() -> Result<(), Error> {
_ = setup_done => {
log!(SystemLog::SetupCompleted);
}
_ = tokio::signal::ctrl_c() => {
_ = signal::ctrl_c() => {
log!(SystemLog::ShutdownDuringSetup);
handle.stop(true).await;
return Ok(());
@ -164,24 +166,24 @@ async fn main() -> Result<(), Error> {
system.terminate().await?;
match mode {
crate::core::system::ShutdownMode::Restart => {
ShutdownMode::Restart => {
log!(SystemLog::ApiRestart);
let _ = sd_notify::notify(false, &[sd_notify::NotifyState::Reloading]);
let _ = sd_notify::notify(false, &[NotifyState::Reloading]);
// Drop System to detach eBPF XDP programs before re-exec
drop(system);
// Brief delay for kernel to release XDP/AF_XDP resources
std::thread::sleep(std::time::Duration::from_millis(500));
thread::sleep(Duration::from_millis(500));
// Re-exec self — works with or without systemd
use std::os::unix::process::CommandExt;
let exe = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("net-guardia"));
let err = std::process::Command::new(exe).args(std::env::args().skip(1)).exec(); // replaces current process
let exe = env::current_exe().unwrap_or_else(|_| PathBuf::from("net-guardia"));
let err = process::Command::new(exe).args(env::args().skip(1)).exec(); // replaces current process
// If exec fails, fall through to exit
log!(SystemError::UnexpectedError(err));
std::process::exit(1);
process::exit(1);
}
crate::core::system::ShutdownMode::Shutdown => {
ShutdownMode::Shutdown => {
log!(SystemLog::ApiShutdown);
let _ = sd_notify::notify(false, &[sd_notify::NotifyState::Stopping]);
let _ = sd_notify::notify(false, &[NotifyState::Stopping]);
}
}
Ok(())

View File

@ -1,4 +1,5 @@
use std::net::{Ipv4Addr, Ipv6Addr};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use tract_onnx::prelude::{Graph, SimplePlan, TypedFact, TypedOp};
@ -160,8 +161,8 @@ pub struct AlertMessage {
impl AlertMessage {
pub fn from_detection_result(result: &DetectionResult) -> Self {
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);

View File

@ -2,17 +2,34 @@ use macros::traceable;
traceable! {
CryptoError {
#[no_source]
#[error("Encryption failed: {reason}")]
EncryptionFailed { reason: String } => tracing::Level::ERROR,
#[error("Encryption failed: {err}")]
EncryptionFailed => tracing::Level::ERROR,
#[error("Decryption failed: {err}")]
DecryptionFailed => tracing::Level::ERROR,
#[error("Failed to parse secret envelope: {err}")]
EnvelopeParseFailed => tracing::Level::ERROR,
#[no_source]
#[error("Decryption failed: {reason}")]
DecryptionFailed { reason: String } => tracing::Level::ERROR,
#[error("Unsupported envelope version: {version}")]
UnsupportedEnvelopeVersion { version: u64 } => tracing::Level::ERROR,
#[no_source]
#[error("Invalid secret envelope: {reason}")]
InvalidEnvelope { reason: String } => tracing::Level::ERROR,
#[error("Missing envelope field: {field}")]
MissingEnvelopeField { field: String } => tracing::Level::ERROR,
#[no_source]
#[error("Envelope algorithm 'none' rejected in production mode (encryption key is set)")]
AlgNoneRejected => tracing::Level::ERROR,
#[no_source]
#[error("Invalid envelope nonce length")]
InvalidNonceLength => tracing::Level::ERROR,
#[no_source]
#[error("Unsupported envelope algorithm: {alg}")]
UnsupportedAlgorithm { alg: String } => tracing::Level::ERROR,
#[no_source]
#[error("Master key not available")]

View File

@ -2,9 +2,8 @@ use macros::traceable;
traceable! {
DatabaseError {
#[no_source]
#[error("Database error: {reason}")]
QueryFailed { reason: String } => tracing::Level::ERROR,
#[error("Database error: {err}")]
QueryFailed => tracing::Level::ERROR,
#[error("Database connection failed")]
ConnectionFailed => tracing::Level::ERROR,
@ -14,14 +13,34 @@ traceable! {
UserAlreadyExists { username: String } => tracing::Level::WARN,
#[no_source]
#[error("Audit log chain broken at id {id}: {reason}")]
AuditChainBroken { id: i64, reason: String } => tracing::Level::ERROR,
#[error("User group '{name}' already exists")]
GroupAlreadyExists { name: String } => tracing::Level::WARN,
#[no_source]
#[error("Database encryption key is incorrect or database is corrupted")]
EncryptionKeyInvalid => tracing::Level::ERROR,
#[no_source]
#[error("Cannot read database with provided key — wrong key or not encrypted")]
DatabaseNotReadable => tracing::Level::ERROR,
#[no_source]
#[error("Cannot read source database — may already be encrypted")]
SourceDatabaseNotReadable => tracing::Level::ERROR,
#[no_source]
#[error("Audit log prev_hash mismatch at id {id}: expected {expected}, found {found}")]
AuditPrevHashMismatch { id: i64, expected: String, found: String } => tracing::Level::ERROR,
#[no_source]
#[error("Audit log row_hash mismatch at id {id}: computed {computed}, stored {stored}")]
AuditRowHashMismatch { id: i64, computed: String, stored: String } => tracing::Level::ERROR,
}
}
impl From<rusqlite::Error> for DatabaseError {
fn from(e: rusqlite::Error) -> Self {
DatabaseError::QueryFailed { reason: e.to_string() }
DatabaseError::QueryFailed(e)
}
}

View File

@ -22,6 +22,9 @@ traceable! {
#[error("Failed to attach XDP program")]
AttachProgramFailed => tracing::Level::ERROR,
#[error("Failed to obtain eBPF program FD")]
ProgramFdFailed => tracing::Level::ERROR,
#[error("Failed to set umem")]
UmemSetFailed => tracing::Level::ERROR,

View File

@ -6,5 +6,8 @@ traceable! {
IOError {
#[error("Failed to create directory: {path}")]
CreateDirectoryFailed { path: PathBuf } => tracing::Level::ERROR,
#[error("Failed to write file: {path}")]
WriteFileFailed { path: PathBuf } => tracing::Level::ERROR,
}
}

View File

@ -19,17 +19,19 @@ traceable! {
#[error("Network interface '{interface}' not found")]
NetworkInterfaceNotFound { interface: String } => tracing::Level::ERROR,
#[no_source]
#[error("Failed to open GeoIP database '{path}': {reason}")]
GeoIPDatabaseError { path: String, reason: String } => tracing::Level::ERROR,
#[error("Failed to open GeoIP database '{path}': {err}")]
GeoIPDatabaseError { path: String } => tracing::Level::ERROR,
#[error("Failed to create traffic log file '{path}': {err}")]
TrafficLogCreateError { path: String } => tracing::Level::ERROR,
#[no_source]
#[error("Failed to create traffic log file '{path}': {reason}")]
TrafficLogCreateError { path: String, reason: String } => tracing::Level::ERROR,
#[error("DNS label length out of range: {len} (must be 1..64)")]
DnsLabelOutOfRange { len: usize } => tracing::Level::WARN,
#[no_source]
#[error("Invalid DNS domain name: {reason}")]
InvalidDnsName { reason: String } => tracing::Level::WARN,
#[error("DNS domain name too long: '{domain}'")]
DnsDomainTooLong { domain: String } => tracing::Level::WARN,
#[no_source]
#[error("Type mismatch during message dispatch")]

View File

@ -8,24 +8,20 @@ traceable! {
#[error("Initialize Machine Learning detection failed")]
InitializeFailed => tracing::Level::ERROR,
#[no_source]
#[error("Failed to load ONNX model from: {path:?}")]
#[error("Failed to load ONNX model from {path:?}: {err}")]
ModelLoadFailed { path: PathBuf } => tracing::Level::ERROR,
#[no_source]
#[error("Failed to load inference configuration from: {path:?}")]
#[error("Failed to load inference configuration from {path:?}: {err}")]
ConfigLoadFailed { path: PathBuf } => tracing::Level::ERROR,
#[no_source]
#[error("Failed to parse inference configuration: {reason}")]
ConfigParseFailed { reason: String } => tracing::Level::ERROR,
#[error("Failed to parse inference configuration: {err}")]
ConfigParseFailed => tracing::Level::ERROR,
#[error("Failed to flush traffic log: {err}")]
TrafficLogFlushFailed => tracing::Level::ERROR,
#[no_source]
#[error("Model manifest at {path:?} is invalid: {reason}")]
ManifestInvalid { path: PathBuf, reason: String } => tracing::Level::ERROR,
#[error("Model manifest at {path:?} is invalid: {err}")]
ManifestInvalid { path: PathBuf } => tracing::Level::ERROR,
#[no_source]
#[error("Feature count mismatch for {model:?}: manifest declares {declared}, ONNX input expects {onnx_dim}")]
@ -34,5 +30,8 @@ traceable! {
#[no_source]
#[error("Unknown feature '{name}' — not registered in FEATURE_REGISTRY")]
UnknownFeature { name: String } => tracing::Level::ERROR,
#[error("Model watcher failed: {err}")]
ModelWatcherFailed => tracing::Level::ERROR,
}
}

View File

@ -2,29 +2,27 @@ use macros::traceable;
traceable! {
NotificationError {
#[no_source]
#[error("SMTP connection failed: {reason}")]
SmtpConnectionFailed { reason: String } => tracing::Level::ERROR,
#[error("SMTP connection failed: {err}")]
SmtpConnectionFailed => tracing::Level::ERROR,
#[error("SMTP authentication failed: {err}")]
SmtpAuthFailed => tracing::Level::ERROR,
#[error("Failed to send email: {err}")]
SmtpSendFailed => tracing::Level::ERROR,
#[error("Invalid {field} email address: {err}")]
InvalidAddress { field: String } => tracing::Level::WARN,
#[error("Failed to build email message: {err}")]
MessageBuildFailed => tracing::Level::ERROR,
#[error("Telegram notification error: {err}")]
TelegramRequestFailed => tracing::Level::ERROR,
#[no_source]
#[error("SMTP authentication failed: {reason}")]
SmtpAuthFailed { reason: String } => tracing::Level::ERROR,
#[no_source]
#[error("Failed to send email: {reason}")]
SmtpSendFailed { reason: String } => tracing::Level::ERROR,
#[no_source]
#[error("Invalid email address: {reason}")]
InvalidAddress { reason: String } => tracing::Level::WARN,
#[no_source]
#[error("Failed to build email message: {reason}")]
MessageBuildFailed { reason: String } => tracing::Level::ERROR,
#[no_source]
#[error("Telegram API error: {reason}")]
TelegramApiError { reason: String } => tracing::Level::ERROR,
#[error("Telegram HTTP {status}: {body}")]
TelegramHttpError { status: u16, body: String } => tracing::Level::ERROR,
#[no_source]
#[error("Telegram authentication failed (invalid bot token)")]

View File

@ -10,15 +10,50 @@ traceable! {
#[error("Invalid TTL: {ttl_secs}s exceeds maximum of {max_secs}s")]
InvalidTtl { ttl_secs: u64, max_secs: u64 } => tracing::Level::WARN,
#[error("SOAR action failed: {action_type} — {err}")]
ActionFailed { action_type: String } => tracing::Level::ERROR,
#[no_source]
#[error("SOAR action failed: {action_type} — {reason}")]
ActionFailed { action_type: String, reason: String } => tracing::Level::ERROR,
#[error("Unknown SOAR action type: {action_type}")]
UnknownActionType { action_type: String } => tracing::Level::WARN,
#[no_source]
#[error("Rate limit config not available for SOAR action")]
RateLimitUnavailable => tracing::Level::WARN,
#[no_source]
#[error("Invalid rate limit factor: {factor} (must be 0.01..=1.0)")]
InvalidRateLimitFactor { factor: f64 } => tracing::Level::WARN,
#[no_source]
#[error("Webhook action missing required parameter: {param}")]
WebhookMissingParam { param: String } => tracing::Level::WARN,
#[no_source]
#[error("Webhook URL has no host")]
WebhookUrlNoHost => tracing::Level::WARN,
#[no_source]
#[error("Webhook DNS resolution returned no addresses for '{host}'")]
WebhookDnsEmpty { host: String } => tracing::Level::WARN,
#[no_source]
#[error("Webhook SSRF blocked: host '{host}' resolves to private IP {ip}")]
WebhookSsrfBlocked { host: String, ip: String } => tracing::Level::WARN,
#[no_source]
#[error("Webhook returned non-success HTTP status: {status}")]
WebhookHttpStatus { status: u16 } => tracing::Level::WARN,
#[no_source]
#[error("Manual unblock failed: block rule {id} not found")]
UnblockRuleNotFound { id: i64 } => tracing::Level::WARN,
#[error("Failed to clean up ACL rule after unblock: {err}")]
AclCleanupFailed => tracing::Level::WARN,
#[no_source]
#[error("Invalid playbook condition: {condition_type} — {reason}")]
InvalidCondition { condition_type: String, reason: String } => tracing::Level::WARN,
#[error("Unknown SOAR condition type: {condition_type}")]
UnknownConditionType { condition_type: String } => tracing::Level::WARN,
}
}

View File

@ -104,5 +104,56 @@ loggable! {
#[error("eBPF bring-up failed — continuing without data plane: {details}")]
EbpfBringupFailed { details: String } => tracing::Level::ERROR,
#[error("Telegram rate limited, retrying after {retry_after}s (attempt {attempt}/{max})")]
TelegramRateLimitedRetry { retry_after: u64, attempt: u32, max: u32 } => tracing::Level::WARN,
#[error("Telegram not configured, skipping alert")]
TelegramNotConfiguredSkipped => tracing::Level::DEBUG,
#[error("Telegram rate limit reached ({max_per_min}/min), dropping alert for IP {source_ip}")]
TelegramLocalRateLimitDropped { max_per_min: u32, source_ip: String } => tracing::Level::WARN,
#[error("Stats aggregator started (1h interval)")]
StatsAggregatorStarted => tracing::Level::INFO,
#[error("Initial stats aggregation failed: {error}")]
InitialStatsAggregationFailed { error: String } => tracing::Level::ERROR,
#[error("Stats aggregation failed: {error}")]
StatsAggregationFailed { error: String } => tracing::Level::ERROR,
#[error("Stats aggregated: {threats} threats, {blocks} blocks, {unblocks} unblocks, {rules} active rules")]
StatsAggregated { threats: u64, blocks: u64, unblocks: u64, rules: u64 } => tracing::Level::INFO,
#[error("Weekly report scheduler started")]
WeeklyReportSchedulerStarted => tracing::Level::INFO,
#[error("Weekly report window reached — preparing report")]
WeeklyReportWindowReached => tracing::Level::INFO,
#[error("SMTP is not configured (missing smtp_host/port/username/password). Skipping weekly report.")]
SmtpNotConfigured => tracing::Level::WARN,
#[error("Failed to read SMTP settings: {error}")]
SmtpSettingsReadFailed { error: String } => tracing::Level::ERROR,
#[error("No smtp_recipient configured. Skipping weekly report.")]
SmtpRecipientMissing => tracing::Level::WARN,
#[error("Failed to generate weekly report: {error}")]
WeeklyReportGenerationFailed { error: String } => tracing::Level::ERROR,
#[error("Weekly report sent successfully")]
WeeklyReportSent => tracing::Level::INFO,
#[error("Failed to send weekly report: {error}")]
WeeklyReportSendFailed { error: String } => tracing::Level::ERROR,
#[error("Send task panicked: {error}")]
WeeklyReportSendPanicked { error: String } => tracing::Level::ERROR,
#[error("HTML report generated at {path}")]
HtmlReportGenerated { path: String } => tracing::Level::INFO,
}
}

View File

@ -1,3 +1,5 @@
use std::fmt;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Copy, Clone, Eq, PartialEq, Hash, Debug)]
@ -7,8 +9,8 @@ pub enum Direction {
Egress,
}
impl std::fmt::Display for Direction {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
impl fmt::Display for Direction {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Direction::Ingress => write!(f, "Ingress"),
Direction::Egress => write!(f, "Egress"),

View File

@ -1,3 +1,4 @@
use chrono::{Duration as ChronoDuration, Local};
use serde::{Deserialize, Serialize};
use crate::interface::port::repository::RepositoryPort;
@ -63,10 +64,10 @@ pub struct SystemHealthSummary {
impl ReportData {
/// Build report data from database settings (aggregated by the ML pipeline).
pub fn from_database(db: &dyn RepositoryPort) -> Result<Self, Error> {
let now = chrono::Local::now();
let now = Local::now();
let period = format!(
"{} — {}",
(now - chrono::Duration::days(7)).format("%Y-%m-%d"),
(now - ChronoDuration::days(7)).format("%Y-%m-%d"),
now.format("%Y-%m-%d")
);

View File

@ -40,10 +40,7 @@ impl FromStr for ConditionType {
"ip_pattern" => Ok(Self::IpPattern),
"repeat_offender" => Ok(Self::RepeatOffender),
"frequency" => Ok(Self::Frequency),
other => Err(SoarError::InvalidCondition {
condition_type: other.to_string(),
reason: "unknown condition type".to_string(),
}),
other => Err(SoarError::UnknownConditionType(other)),
}
}
}

View File

@ -1,20 +1,23 @@
use std::sync::atomic::AtomicBool;
use std::time::Instant;
/// Per-subsystem readiness state exposed by `/health/ready`.
pub struct ReadinessState {
pub db_connected: std::sync::atomic::AtomicBool,
pub ml_model_loaded: std::sync::atomic::AtomicBool,
pub soar_engine_running: std::sync::atomic::AtomicBool,
pub ebpf_attached: std::sync::atomic::AtomicBool,
pub started_at: std::time::Instant,
pub db_connected: AtomicBool,
pub ml_model_loaded: AtomicBool,
pub soar_engine_running: AtomicBool,
pub ebpf_attached: AtomicBool,
pub started_at: Instant,
}
impl ReadinessState {
pub fn new() -> Self {
Self {
db_connected: std::sync::atomic::AtomicBool::new(false),
ml_model_loaded: std::sync::atomic::AtomicBool::new(false),
soar_engine_running: std::sync::atomic::AtomicBool::new(false),
ebpf_attached: std::sync::atomic::AtomicBool::new(false),
started_at: std::time::Instant::now(),
db_connected: AtomicBool::new(false),
ml_model_loaded: AtomicBool::new(false),
soar_engine_running: AtomicBool::new(false),
ebpf_attached: AtomicBool::new(false),
started_at: Instant::now(),
}
}
}

View File

@ -1,9 +1,11 @@
use std::env;
use std::fs;
use std::sync::OnceLock;
use tracing::Level;
use tracing_appender::rolling::{RollingFileAppender, Rotation};
use tracing_subscriber::filter::EnvFilter;
use tracing_subscriber::fmt::layer as fmt_layer;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::reload;
use tracing_subscriber::util::SubscriberInitExt;
@ -41,14 +43,14 @@ impl Logging {
let file_appender = RollingFileAppender::new(Rotation::DAILY, log_directory, "NetGuardia");
let stdout_layer = tracing_subscriber::fmt::layer()
let stdout_layer = fmt_layer()
.with_file(true)
.with_line_number(true)
.with_thread_ids(true)
.with_target(false)
.with_ansi(true);
let file_layer = tracing_subscriber::fmt::layer()
let file_layer = fmt_layer()
.with_file(false)
.with_line_number(false)
.with_thread_ids(false)
@ -56,7 +58,7 @@ impl Logging {
.with_ansi(false)
.with_writer(file_appender);
let level = std::env::var("RUST_LOG")
let level = env::var("RUST_LOG")
.ok()
.and_then(|s| s.parse::<Level>().ok())
.unwrap_or(if cfg!(debug_assertions) {

View File

@ -1,4 +1,4 @@
use std::time;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::model::monitoring::user_packet::UserPacket;
@ -9,10 +9,7 @@ pub fn parse_packet(packet_data: &[u8]) -> Option<(UserPacket, usize)> {
let eth_type = u16::from_be_bytes([packet_data[12], packet_data[13]]);
let timestamp_us = time::SystemTime::now()
.duration_since(time::UNIX_EPOCH)
.ok()?
.as_micros() as u64;
let timestamp_us = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_micros() as u64;
match eth_type {
0x0800 => parse_ipv4(packet_data, timestamp_us),