mirror of
https://github.com/DaLaw2/NetGuardia.git
synced 2026-08-24 14:10:28 +09:00
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:
parent
86358074b9
commit
3acd2ebf96
@ -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);
|
||||
|
||||
@ -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!({
|
||||
|
||||
@ -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!({
|
||||
|
||||
@ -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)?;
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)));
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"})))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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() => {
|
||||
|
||||
@ -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() => {
|
||||
|
||||
@ -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! {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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! {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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()));
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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:?}");
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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));
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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."
|
||||
|
||||
@ -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(¶ms_str).unwrap_or(serde_json::Value::Null),
|
||||
params: serde_json::from_str(¶ms_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
|
||||
|
||||
@ -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())
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -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(())
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
});
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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))?,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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))?
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(())
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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")]
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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")]
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)")]
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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")
|
||||
);
|
||||
|
||||
|
||||
@ -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)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user