feat: v0.9 closeout — eBPF-optional, Suricata bridge, WORM audit, migration cleanup

- eBPF-optional startup: null-object services, preflight classifier,
  EbpfHealth (Healthy / Unavailable{stage, category, reason}) surfaced
  via GET /health/ebpf and WebSocket metrics; the rest of the system
  (HTTP, SOAR, ML) comes up even when XDP/AF_XDP is unavailable.
- Suricata bridge (M1–M4): subprocess supervisor with auto-restart
  backoff, eve.json tail → alert translation → DetectionEvent,
  GET /health/suricata; AF_PACKET capture mode (does not conflict with
  our AF_XDP). M5 smoke test deferred until deployment hardware.
- WORM audit log: hash-chained audit_log with prev_hash/row_hash,
  BEFORE UPDATE/DELETE triggers, verify_audit_log_chain(),
  `--verify-audit-log` CLI.
- Migration code removed (system not yet released): plaintext→encrypted
  DB auto-migration, plaintext secrets migration, ALTER TABLE retrofit
  blocks, and related log variants. Init paths for default user_groups
  kept; decrypt_to_file / encrypt_to_file ops utilities kept.
- Licensing removed: deleted license-generator/ (Ed25519 + MAC binding);
  not going commercial.
- README: kernel × NIC driver compatibility matrix (no single "minimum
  kernel version" — depends on driver).
- unwrap/expect audit (164 sites): 0 production-unsafe unwraps; all 9
  production sites are infallible literals with SAFETY comments; 155
  sites are in test modules.
- Cargo: tokio features gained process, io-util, fs, signal (for
  Suricata subprocess lifecycle).
- Frontend submodule: advanced to dbe5342 (inactivity auto-logout);
  v11 allowlist UI work held back.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
DaLaw2 2026-04-15 22:51:43 +08:00
parent 730849e02d
commit c6a77c9611
43 changed files with 1341 additions and 614 deletions

8
.gitignore vendored
View File

@ -41,7 +41,9 @@ interfaces.txt
traffic_log.csv
# Project docs (local only)
CLAUDE.md
# CLAUDE.md — tracked on dev branches; MUST be untracked before PR to master
# (see CLAUDE.md "Branch discipline" section)
# CLAUDE.md
DESIGN.md
TODOS.md
VERSION
@ -51,7 +53,9 @@ CHANGELOG.md
benchmark/
# Generated docs
docs/
# docs/ — tracked on dev branches; MUST be untracked before PR to master
# (see CLAUDE.md "Branch discipline" section)
# docs/
# SQLite database files
*.db

View File

@ -22,7 +22,7 @@ serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
# Async runtime
tokio = { version = "1.50.0", features = ["rt-multi-thread", "macros", "sync", "time"] }
tokio = { version = "1.50.0", features = ["rt-multi-thread", "macros", "sync", "time", "process", "io-util", "fs", "signal"] }
# Web framework
actix = "0.13.5"

View File

@ -45,16 +45,42 @@
## System Requirements
- Ubuntu-based operating system (Ubuntu 24.04 LTS or newer recommended)
- Dual-port network interface card (Intel i350 T2 or compatible XDP-capable NIC)
- Root/sudo access for eBPF program loading
NetGuardia requires the combination of a kernel with eBPF support and a NIC driver
that implements **AF_XDP** on that kernel. There is no single "minimum kernel
version" — it depends on which NIC driver you use.
- **Linux** with eBPF + AF_XDP support for your NIC driver. Any modern distribution
(Ubuntu 22.04+, Debian 12+, RHEL 9+, Fedora recent) is fine as long as the driver
matrix below lines up.
- **Dual-port NIC** with an AF_XDP-capable driver (see matrix).
- **Root / sudo** access for eBPF program loading.
### NIC driver / kernel matrix (AF_XDP)
| Driver | NIC family (examples) | Min kernel for AF_XDP |
|--------|-----------------------|------------------------|
| mlx5 | Mellanox ConnectX-4/5/6/7 | 5.x (early) |
| ixgbe | Intel 82599, X520, X540, X550 | 5.x |
| i40e | Intel X710, XL710, XXV710 | 5.x |
| ice | Intel E810 | 5.5+ |
| igb | **Intel i350 T2** (reference hardware) | **6.17** |
| igc | Intel I225/I226 | 6.x |
| virtio_net | QEMU/KVM virtual NICs | varies; AF_XDP is limited |
If you are using the reference Intel i350 T2, you need Linux 6.17 or newer because
igb AF_XDP support landed in that release. On a kernel older than 6.17 the system
will still build, but `ingress`/`egress` setup will fail at runtime when AF_XDP
binding is attempted — check driver support with `ethtool -i <iface>` and confirm
against the matrix above before deploying.
## Hardware Compatibility
NetGuardia is designed to work on any Ubuntu-based system meeting the following requirements:
- Network Interface: Any dual-port NIC supporting XDP native or offload mode (Intel i350 T2 recommended)
- CPU: Multi-core processor recommended for optimal performance
- Memory: 8GB RAM minimum, 16GB or more for high-traffic environments
- Network Interface: dual-port NIC with an AF_XDP-capable driver on your kernel
(see matrix above). Intel i350 T2 is the reference hardware.
- CPU: multi-core recommended; XDP scales with RX queue count.
- Memory: 8 GB minimum, 16 GB+ for high-traffic environments.
The system is not limited to embedded platforms and can be deployed on standard server hardware, virtual machines, or dedicated appliances running Ubuntu.
NetGuardia is not limited to embedded platforms — it runs on standard server
hardware, virtual machines, or dedicated appliances as long as the driver/kernel
requirement above is met.

View File

@ -1,13 +0,0 @@
[package]
name = "license-generator"
version = "0.1.0"
edition = "2024"
[dependencies]
ed25519-dalek = { version = "2", features = ["std", "rand_core"] }
base64 = "0.22"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rand = "0.9"
clap = { version = "4", features = ["derive"] }
pnet = "0.36"

View File

@ -1,217 +0,0 @@
use std::fs;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64;
use clap::{Parser, Subcommand};
use ed25519_dalek::{Signer, SigningKey, Verifier, VerifyingKey, Signature};
use pnet::datalink;
use rand::rngs::OsRng;
use serde::{Deserialize, Serialize};
#[derive(Parser)]
#[command(name = "license-generator", about = "NetGuardia license generator")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Generate a new Ed25519 keypair
Keygen {
#[arg(short, long, default_value = "license")]
prefix: String,
},
/// Issue a signed license bound to NIC MACs
Issue {
#[arg(short = 'k', long)]
private_key: String,
/// Ingress interface name (e.g. ng-ext)
#[arg(long)]
ingress: String,
/// Egress interface name (e.g. ng-int)
#[arg(long)]
egress: String,
/// Expiry date (YYYY-MM-DD)
#[arg(short, long)]
expires: String,
/// Comma-separated list of features
#[arg(short, long, default_value = "")]
features: String,
/// Output license file path
#[arg(short, long, default_value = "license.key")]
output: String,
},
/// Verify a license file
Verify {
#[arg(short = 'k', long)]
public_key: String,
#[arg(short, long)]
license: String,
},
}
#[derive(Serialize, Deserialize, Debug)]
struct LicensePayload {
ingress_mac: String,
egress_mac: String,
expires: String,
features: Vec<String>,
}
fn get_mac(ifname: &str) -> String {
for iface in datalink::interfaces() {
if iface.name == ifname {
if let Some(mac) = iface.mac {
return format!(
"{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
mac.0, mac.1, mac.2, mac.3, mac.4, mac.5
);
}
}
}
eprintln!("Interface '{}' not found or has no MAC address", ifname);
eprintln!("Available interfaces:");
for iface in datalink::interfaces() {
if let Some(mac) = iface.mac {
eprintln!(" {}{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
iface.name, mac.0, mac.1, mac.2, mac.3, mac.4, mac.5);
}
}
std::process::exit(1);
}
fn main() {
let cli = Cli::parse();
match cli.command {
Commands::Keygen { prefix } => keygen(&prefix),
Commands::Issue { private_key, ingress, egress, expires, features, output } => {
issue(&private_key, &ingress, &egress, &expires, &features, &output)
}
Commands::Verify { public_key, license } => verify(&public_key, &license),
}
}
fn keygen(prefix: &str) {
let mut csprng = OsRng;
let signing_key = SigningKey::generate(&mut csprng);
let verifying_key = signing_key.verifying_key();
let priv_hex = hex_encode(signing_key.as_bytes());
let pub_hex = hex_encode(verifying_key.as_bytes());
let priv_path = format!("{}_priv.key", prefix);
let pub_path = format!("{}_pub.key", prefix);
fs::write(&priv_path, &priv_hex).expect("Failed to write private key");
fs::write(&pub_path, &pub_hex).expect("Failed to write public key");
println!("Keypair generated:");
println!(" Private key: {}", priv_path);
println!(" Public key: {}", pub_path);
println!();
println!("Public key hex (embed in validator.rs):");
println!(" {}", pub_hex);
}
fn issue(private_key_path: &str, ingress: &str, egress: &str, expires: &str, features: &str, output: &str) {
let ingress_mac = get_mac(ingress);
let egress_mac = get_mac(egress);
println!("Detected MACs:");
println!(" {}{}", ingress, ingress_mac);
println!(" {}{}", egress, egress_mac);
let priv_hex = fs::read_to_string(private_key_path)
.expect("Failed to read private key")
.trim()
.to_string();
let priv_bytes = hex_decode(&priv_hex).expect("Invalid hex");
let priv_array: [u8; 32] = priv_bytes.try_into().expect("Key must be 32 bytes");
let signing_key = SigningKey::from_bytes(&priv_array);
let feature_list: Vec<String> = if features.is_empty() {
vec![]
} else {
features.split(',').map(|s| s.trim().to_string()).collect()
};
let payload = LicensePayload {
ingress_mac: ingress_mac.clone(),
egress_mac: egress_mac.clone(),
expires: expires.to_string(),
features: feature_list,
};
let payload_json = serde_json::to_string(&payload).expect("Failed to serialize");
let payload_b64 = BASE64.encode(payload_json.as_bytes());
let signature: Signature = signing_key.sign(payload_b64.as_bytes());
let sig_b64 = BASE64.encode(signature.to_bytes());
let license_content = format!("{}.{}", payload_b64, sig_b64);
fs::write(output, &license_content).expect("Failed to write license");
println!();
println!("License issued:");
println!(" Ingress MAC: {}", ingress_mac);
println!(" Egress MAC: {}", egress_mac);
println!(" Expires: {}", expires);
println!(" Features: {:?}", payload.features);
println!(" Output: {}", output);
}
fn verify(public_key_path: &str, license_path: &str) {
let pub_hex = fs::read_to_string(public_key_path)
.expect("Failed to read public key")
.trim()
.to_string();
let pub_bytes = hex_decode(&pub_hex).expect("Invalid hex");
let pub_array: [u8; 32] = pub_bytes.try_into().expect("Key must be 32 bytes");
let verifying_key = VerifyingKey::from_bytes(&pub_array).expect("Invalid public key");
let contents = fs::read_to_string(license_path)
.expect("Failed to read license")
.trim()
.to_string();
let parts: Vec<&str> = contents.splitn(2, '.').collect();
if parts.len() != 2 {
eprintln!("Invalid license format");
std::process::exit(1);
}
let sig_bytes = BASE64.decode(parts[1]).expect("Invalid signature");
let sig_array: [u8; 64] = sig_bytes.try_into().expect("Signature must be 64 bytes");
let signature = Signature::from_bytes(&sig_array);
match verifying_key.verify(parts[0].as_bytes(), &signature) {
Ok(()) => {
let payload_bytes = BASE64.decode(parts[0]).expect("Invalid payload");
let payload: LicensePayload = serde_json::from_slice(&payload_bytes).expect("Invalid JSON");
println!("License VALID:");
println!(" Ingress MAC: {}", payload.ingress_mac);
println!(" Egress MAC: {}", payload.egress_mac);
println!(" Expires: {}", payload.expires);
println!(" Features: {:?}", payload.features);
}
Err(e) => {
eprintln!("License INVALID: {}", e);
std::process::exit(1);
}
}
}
fn hex_encode(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{:02x}", b)).collect()
}
fn hex_decode(hex: &str) -> Result<Vec<u8>, String> {
if hex.len() % 2 != 0 {
return Err("Odd-length hex string".to_string());
}
(0..hex.len())
.step_by(2)
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16).map_err(|e| e.to_string()))
.collect()
}

@ -1 +1 @@
Subproject commit 8883b04ac722c8c3da29cc60adec0391af8b0b42
Subproject commit deaff5088aec9cc48732ce7a1be0b8f0573345a0

View File

@ -1,11 +1,14 @@
use actix_web::{HttpResponse, Responder, Scope, web};
use crate::infrastructure::health::SystemHealth;
use crate::infrastructure::suricata_manager::SuricataManager;
pub fn initialize() -> Scope {
web::scope("/health")
.route("/metrics", web::get().to(get_current_metrics))
.route("/status", web::get().to(get_health_status))
.route("/ebpf", web::get().to(get_ebpf_health))
.route("/suricata", web::get().to(get_suricata_health))
}
async fn get_current_metrics(health: web::Data<SystemHealth>) -> impl Responder {
@ -17,3 +20,13 @@ async fn get_health_status(health: web::Data<SystemHealth>) -> impl Responder {
let status = health.is_system_healthy().await;
HttpResponse::Ok().json(status)
}
async fn get_ebpf_health(health: web::Data<SystemHealth>) -> impl Responder {
let ebpf = health.ebpf_health().read().clone();
HttpResponse::Ok().json(ebpf)
}
async fn get_suricata_health(manager: web::Data<SuricataManager>) -> impl Responder {
let state = manager.health().read().clone();
HttpResponse::Ok().json(state)
}

View File

@ -56,14 +56,8 @@ impl Database {
pub fn new(path: &str) -> Result<Self, Error> {
let encryption_key = db_encryption_key();
// For on-disk databases with an encryption key, attempt transparent migration
// from a plaintext SQLite database to an encrypted SQLCipher database.
if path != ":memory:" {
if let Some(ref key) = encryption_key {
Self::migrate_plaintext_to_encrypted(path, key)?;
} else {
log!(MiscLog::DbEncryptionDisabled);
}
if path != ":memory:" && encryption_key.is_none() {
log!(MiscLog::DbEncryptionDisabled);
}
let manager = if path == ":memory:" {
@ -137,90 +131,6 @@ impl Database {
hex
}
/// One-time migration: if the DB file exists and is a *plaintext* SQLite database
/// (i.e. opening it with the encryption key fails, but opening without a key
/// succeeds), export it to a new encrypted file and atomically replace the original.
fn migrate_plaintext_to_encrypted(path: &str, key: &str) -> Result<(), Error> {
use std::path::Path;
let db_path = Path::new(path);
if !db_path.exists() {
return Ok(()); // brand-new DB — nothing to migrate
}
// Try opening with the key — if it works, the DB is already encrypted.
{
let conn =
rusqlite::Connection::open(path).map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?;
conn.pragma_update(None, "key", key)
.map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?;
if conn
.query_row("SELECT count(*) FROM sqlite_master", [], |_| Ok(()))
.is_ok()
{
return Ok(()); // already encrypted — nothing to do
}
}
// Try opening *without* a key — if this also fails the file is corrupted.
{
let conn =
rusqlite::Connection::open(path).map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?;
if conn
.query_row("SELECT count(*) FROM sqlite_master", [], |_| Ok(()))
.is_err()
{
log!(MiscLog::DbMigrationSkipped);
return Err(DatabaseError::QueryFailed {
reason: "Database encryption key is incorrect or database is corrupted".to_string(),
}
.into());
}
}
// The DB is plaintext and we have a key → migrate via temp file.
let tmp_path = format!("{path}.migrating");
log!(MiscLog::DbMigrationStarted);
let result = (|| -> Result<(), Error> {
let conn =
rusqlite::Connection::open(path).map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?;
// Attach a new encrypted database.
conn.execute_batch(&format!(
"ATTACH DATABASE '{}' AS encrypted KEY '{}';",
tmp_path.replace('\'', "''"),
key.replace('\'', "''"),
))
.map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?;
// Export everything from the plaintext DB into the encrypted one.
conn.query_row("SELECT sqlcipher_export('encrypted')", [], |_| Ok(()))
.map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?;
conn.execute_batch("DETACH DATABASE encrypted;")
.map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?;
Ok(())
})();
match result {
Ok(()) => {
// Atomic replace.
std::fs::rename(&tmp_path, path).map_err(|e| DatabaseError::QueryFailed {
reason: format!("Failed to replace DB file after migration: {e}"),
})?;
log!(MiscLog::DbMigrationCompleted);
Ok(())
}
Err(e) => {
// Clean up temp file; leave original untouched.
let _ = std::fs::remove_file(&tmp_path);
log!(MiscLog::DbMigrationFailed { error: e.to_string() });
Err(e)
}
}
}
/// 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> {
@ -412,27 +322,32 @@ impl Database {
retry_count INTEGER NOT NULL DEFAULT 0
);
-- Audit trail
-- Audit trail (WORM: hash-chained, triggers block UPDATE/DELETE)
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL DEFAULT (datetime('now')),
ts TEXT NOT NULL,
actor TEXT NOT NULL,
action TEXT NOT NULL,
detail TEXT NOT NULL DEFAULT '{}'
detail TEXT NOT NULL DEFAULT '{}',
prev_hash TEXT NOT NULL DEFAULT '',
row_hash TEXT NOT NULL DEFAULT ''
);
CREATE TRIGGER IF NOT EXISTS audit_log_no_update
BEFORE UPDATE ON audit_log BEGIN
SELECT RAISE(ABORT, 'audit_log is append-only (WORM)');
END;
CREATE TRIGGER IF NOT EXISTS audit_log_no_delete
BEFORE DELETE ON audit_log BEGIN
SELECT RAISE(ABORT, 'audit_log is append-only (WORM)');
END;
",
)?;
// Migration: add force_password_change column if missing (for existing DBs)
let conn_ref = &*conn;
let has_column: bool = conn_ref
.prepare("SELECT force_password_change FROM users LIMIT 0")
.is_ok();
if !has_column {
conn_ref.execute_batch("ALTER TABLE users ADD COLUMN force_password_change INTEGER NOT NULL DEFAULT 0;")?;
}
// Migration: seed default user groups if table is empty
// Seed default user groups on first install (empty table)
let group_count: i64 = conn_ref.query_row("SELECT COUNT(*) FROM user_groups", [], |row| row.get(0))?;
if group_count == 0 {
let all_permissions = serde_json::json!([
@ -489,41 +404,6 @@ impl Database {
)?;
}
// Migration: assign existing users to default groups if user_group_members is empty
let member_count: i64 = conn_ref.query_row("SELECT COUNT(*) FROM user_group_members", [], |row| row.get(0))?;
if member_count == 0 {
// Get admin group id and viewer group id
let admin_group_id: Option<i64> = conn_ref
.query_row("SELECT id FROM user_groups WHERE name = 'Administrator'", [], |row| {
row.get(0)
})
.ok();
let viewer_group_id: Option<i64> = conn_ref
.query_row("SELECT id FROM user_groups WHERE name = 'Viewer'", [], |row| row.get(0))
.ok();
if let Some(ag_id) = admin_group_id {
let mut stmt = conn_ref.prepare("SELECT id FROM users WHERE role = 'admin'")?;
let admin_ids: Vec<i64> = stmt.query_map([], |row| row.get(0))?.filter_map(|r| r.ok()).collect();
for uid in admin_ids {
conn_ref.execute(
"INSERT OR IGNORE INTO user_group_members (user_id, group_id) VALUES (?1, ?2)",
params![uid, ag_id],
)?;
}
}
if let Some(vg_id) = viewer_group_id {
let mut stmt = conn_ref.prepare("SELECT id FROM users WHERE role = 'viewer'")?;
let viewer_ids: Vec<i64> = stmt.query_map([], |row| row.get(0))?.filter_map(|r| r.ok()).collect();
for uid in viewer_ids {
conn_ref.execute(
"INSERT OR IGNORE INTO user_group_members (user_id, group_id) VALUES (?1, ?2)",
params![uid, vg_id],
)?;
}
}
}
Ok(())
}
@ -1707,15 +1587,47 @@ impl Database {
Ok(())
}
// --- Audit Log ---
// --- Audit Log (WORM, hash-chained) ---
/// Insert an audit trail entry.
/// Compute the row hash for an audit_log entry.
/// Formula: sha256_hex(ts || 0x00 || actor || 0x00 || action || 0x00 || detail || 0x00 || prev_hash)
fn audit_row_hash(ts: &str, actor: &str, action: &str, detail: &str, prev_hash: &str) -> String {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
for part in [ts, actor, action, detail, prev_hash] {
h.update(part.as_bytes());
h.update([0u8]);
}
let out = h.finalize();
let mut hex = String::with_capacity(64);
for byte in out {
use std::fmt::Write;
let _ = write!(&mut hex, "{:02x}", byte);
}
hex
}
/// Insert an audit trail entry. Runs in a transaction so the (prev_hash
/// 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 conn = self.conn()?;
conn.execute(
"INSERT INTO audit_log (actor, action, detail) VALUES (?1, ?2, ?3)",
params![actor, action, detail],
let mut conn = self.conn()?;
let ts = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
let tx = conn.transaction()?;
let prev_hash: String = tx
.query_row(
"SELECT row_hash FROM audit_log ORDER BY id DESC LIMIT 1",
[],
|row| row.get(0),
)
.unwrap_or_default();
let row_hash = Self::audit_row_hash(&ts, actor, action, detail, &prev_hash);
tx.execute(
"INSERT INTO audit_log (ts, actor, action, detail, prev_hash, row_hash) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![ts, actor, action, detail, prev_hash, row_hash],
)?;
tx.commit()?;
Ok(())
}
@ -1723,7 +1635,7 @@ impl Database {
pub fn list_audit_logs(&self) -> Result<Vec<AuditLogEntry>, Error> {
let conn = self.conn()?;
let mut stmt =
conn.prepare("SELECT id, actor, action, detail, created_at FROM audit_log ORDER BY id DESC LIMIT 200")?;
conn.prepare("SELECT id, actor, action, detail, ts FROM audit_log ORDER BY id DESC LIMIT 200")?;
let rows = stmt
.query_map([], |row| {
Ok(AuditLogEntry {
@ -1738,6 +1650,47 @@ impl Database {
.collect();
Ok(rows)
}
/// Walk the entire audit_log in id order and verify the hash chain.
/// Returns `Ok(count)` on success; returns `Err` at the first mismatch,
/// naming the offending row id and the kind of mismatch.
pub fn verify_audit_log_chain(&self) -> Result<usize, Error> {
let conn = self.conn()?;
let mut stmt =
conn.prepare("SELECT id, ts, actor, action, detail, prev_hash, row_hash FROM audit_log ORDER BY id ASC")?;
let mut rows = stmt.query([])?;
let mut expected_prev = String::new();
let mut count = 0usize;
while let Some(row) = rows.next()? {
let id: i64 = row.get(0)?;
let ts: String = row.get(1)?;
let actor: String = row.get(2)?;
let action: String = row.get(3)?;
let detail: String = row.get(4)?;
let prev_hash: String = row.get(5)?;
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());
}
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());
}
expected_prev = row_hash;
count += 1;
}
Ok(count)
}
}
/// Implement the RepositoryPort trait, proving Database satisfies the port contract.

View File

@ -174,9 +174,6 @@ impl ConfigService {
.and_then(json_value_as_string)
{
secrets.set_secret(key, &val)?;
// Clear plaintext residue from settings table to prevent
// pre-migration plaintext passwords from persisting.
let _ = self.db.set_setting(key, "");
updated.push(key.to_string());
}
}

View File

@ -39,6 +39,22 @@ impl AccessControl {
Ok(access_control)
}
/// Construct an AccessControl backed by no eBPF maps. Used when eBPF
/// failed to load at startup; every mutating call returns `EbpfError::NotLoaded`,
/// and list queries return empty maps.
pub fn unavailable() -> Self {
Self {
ipv4_src_whitelist: RwLock::new(MapWrapper::unavailable()),
ipv4_src_blacklist: RwLock::new(MapWrapper::unavailable()),
ipv4_dst_whitelist: RwLock::new(MapWrapper::unavailable()),
ipv4_dst_blacklist: RwLock::new(MapWrapper::unavailable()),
ipv6_src_whitelist: RwLock::new(MapWrapper::unavailable()),
ipv6_src_blacklist: RwLock::new(MapWrapper::unavailable()),
ipv6_dst_whitelist: RwLock::new(MapWrapper::unavailable()),
ipv6_dst_blacklist: RwLock::new(MapWrapper::unavailable()),
}
}
pub async fn get_ipv4_list(&self, direction: FlowDirection, list_type: ListType) -> HashMap<Ipv4Addr, Vec<Port>> {
let map_wrapper = match (direction, list_type) {
(FlowDirection::Source, ListType::White) => self.ipv4_src_whitelist.read().await,
@ -129,33 +145,41 @@ impl AccessControl {
}
struct MapWrapper<T> {
map: AyaHashMap<MapData, T, PortRule>,
map: Option<AyaHashMap<MapData, T, PortRule>>,
}
impl<T: NativeConvert + Pod> MapWrapper<T> {
fn new(ebpf: &mut Ebpf, map_name: &str) -> Result<Self, Error> {
let map = ebpf.take_map(map_name).ok_or(EbpfError::MapNotFound)?;
let map = AyaHashMap::try_from(map).map_err(EbpfError::MapOperationError)?;
Ok(Self { map })
Ok(Self { map: Some(map) })
}
fn unavailable() -> Self {
Self { map: None }
}
fn get_list(&self) -> HashMap<T::Native, Vec<Port>> {
self.map
.iter()
let Some(map) = self.map.as_ref() else {
return HashMap::new();
};
map.iter()
.filter_map(Result::ok)
.map(|(key, rule)| (key.into_native(), rule.to_port_vec()))
.collect()
}
fn add(&mut self, ip: T, port: Port) -> Result<(), Error> {
let Some(map) = self.map.as_mut() else {
return Err(EbpfError::NotLoaded.into());
};
if port == 0 {
self.map
.insert(ip, PortRule::new_match_all(), 0)
map.insert(ip, PortRule::new_match_all(), 0)
.map_err(EbpfError::MapOperationError)?;
return Ok(());
}
let mut rule = self.map.get(&ip, 0).unwrap_or_else(|_| PortRule::new_empty());
let mut rule = map.get(&ip, 0).unwrap_or_else(|_| PortRule::new_empty());
if rule.is_match_all() {
return Ok(());
@ -165,29 +189,32 @@ impl<T: NativeConvert + Pod> MapWrapper<T> {
Err(EbpfError::RuleReachLimit)?;
}
self.map.insert(ip, rule, 0).map_err(EbpfError::MapOperationError)?;
map.insert(ip, rule, 0).map_err(EbpfError::MapOperationError)?;
Ok(())
}
fn remove(&mut self, ip: T, port: Port) -> Result<(), Error> {
let Some(map) = self.map.as_mut() else {
return Err(EbpfError::NotLoaded.into());
};
if port == 0 {
self.map.remove(&ip).map_err(EbpfError::MapOperationError)?;
map.remove(&ip).map_err(EbpfError::MapOperationError)?;
return Ok(());
}
let mut rule = self.map.get(&ip, 0).map_err(|_| EbpfError::IpDoesNotExist)?;
let mut rule = map.get(&ip, 0).map_err(|_| EbpfError::IpDoesNotExist)?;
if rule.is_match_all() {
self.map.remove(&ip).map_err(EbpfError::MapOperationError)?;
map.remove(&ip).map_err(EbpfError::MapOperationError)?;
return Ok(());
}
rule.remove_port(port);
if rule.is_empty() {
self.map.remove(&ip).map_err(EbpfError::MapOperationError)?;
map.remove(&ip).map_err(EbpfError::MapOperationError)?;
} else {
self.map.insert(ip, rule, 0).map_err(EbpfError::MapOperationError)?;
map.insert(ip, rule, 0).map_err(EbpfError::MapOperationError)?;
}
Ok(())
}

View File

@ -20,8 +20,8 @@ struct GeoIndex {
}
pub struct GeoBlock {
geo_block_v4: RwLock<LpmTrie<MapData, u32, u8>>,
geo_block_v6: RwLock<LpmTrie<MapData, u128, u8>>,
geo_block_v4: RwLock<Option<LpmTrie<MapData, u32, u8>>>,
geo_block_v6: RwLock<Option<LpmTrie<MapData, u128, u8>>>,
blocked_countries: RwLock<HashSet<String>>,
index: Arc<GeoIndex>,
}
@ -43,18 +43,39 @@ impl GeoBlock {
let index = Self::build_index(&reader)?;
Ok(Self {
geo_block_v4: RwLock::new(v4_trie),
geo_block_v6: RwLock::new(v6_trie),
geo_block_v4: RwLock::new(Some(v4_trie)),
geo_block_v6: RwLock::new(Some(v6_trie)),
blocked_countries: RwLock::new(HashSet::new()),
index: Arc::new(index),
})
}
/// Construct a GeoBlock with no eBPF trie backing. Attempts to still load
/// the GeoIP index so the frontend can list what *would* be enforced;
/// mutating calls (`block_countries`, `unblock_countries`) return
/// `EbpfError::NotLoaded`.
pub fn unavailable(app_config: &AppConfig) -> Self {
let index = Reader::open_readfile(&app_config.misc.geoip_db_name)
.ok()
.and_then(|reader| Self::build_index(&reader).ok())
.unwrap_or(GeoIndex {
v4: StdHashMap::new(),
v6: StdHashMap::new(),
});
Self {
geo_block_v4: RwLock::new(None),
geo_block_v6: RwLock::new(None),
blocked_countries: RwLock::new(HashSet::new()),
index: Arc::new(index),
}
}
/// Build index from MaxMind DB at startup. One-time cost.
fn build_index(reader: &Reader<Vec<u8>>) -> Result<GeoIndex, Error> {
let mut v4: StdHashMap<String, Vec<(u32, u32)>> = StdHashMap::new();
let mut v6: StdHashMap<String, Vec<(u128, u32)>> = StdHashMap::new();
// SAFETY: "0.0.0.0/0" is a valid IPv4 CIDR literal, parse is infallible.
let ipv4_all: IpNetwork = "0.0.0.0/0".parse().unwrap();
if let Ok(iter) = reader.within(ipv4_all, Default::default()) {
for result in iter {
@ -73,6 +94,7 @@ impl GeoBlock {
}
}
// SAFETY: "::/0" is a valid IPv6 CIDR literal, parse is infallible.
let ipv6_all: IpNetwork = "::/0".parse().unwrap();
if let Ok(iter) = reader.within(ipv6_all, Default::default()) {
for result in iter {
@ -145,10 +167,14 @@ impl GeoBlock {
}
// Lock, clear, insert
let mut v4_trie = self.geo_block_v4.write();
let mut v6_trie = self.geo_block_v6.write();
Self::clear_trie_v4(&mut v4_trie);
Self::clear_trie_v6(&mut v6_trie);
let mut v4_guard = self.geo_block_v4.write();
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()),
};
Self::clear_trie_v4(v4_trie);
Self::clear_trie_v6(v6_trie);
let mut count = 0u64;
for (key, val) in &v4_entries {

View File

@ -66,6 +66,23 @@ impl EbpfServices {
})
}
/// Build an EbpfServices with every eBPF-backed subservice in the
/// "unavailable" state. Used when eBPF failed to load at startup.
/// Queries return empty results; mutating calls return `EbpfError::NotLoaded`.
pub fn unavailable(app_config: Arc<AppConfig>) -> Self {
Self {
xsk_manager: Arc::new(XskManager::unavailable(app_config.clone())),
access_control: Arc::new(AccessControl::unavailable()),
protocol_filter: Arc::new(ProtocolFilter::unavailable()),
dns_filter: Arc::new(DnsFilter::new()),
geo_block: Arc::new(GeoBlock::unavailable(&app_config)),
rate_limit: Arc::new(RateLimitConfig::unavailable()),
drop_monitor: Arc::new(DropMonitor::new()),
drop_ring_buf: Mutex::new(None),
shutdowns: SegQueue::new(),
}
}
pub async fn run(self: Arc<Self>, ml_engine: Arc<Engine>) -> Result<(), Error> {
let xsk_manager = self.xsk_manager.clone();
xsk_manager.run(Some(ml_engine), Some(self.dns_filter.clone()), &self.shutdowns)?;

View File

@ -40,6 +40,21 @@ impl ProtocolFilter {
Ok(service)
}
/// Construct a ProtocolFilter backed by no eBPF maps.
pub fn unavailable() -> Self {
Self {
ipv4_http_service: RwLock::new(HttpServiceWrapper::unavailable()),
ipv6_http_service: RwLock::new(HttpServiceWrapper::unavailable()),
ssh_white_list_enable: RwLock::new(WhiteListControl::unavailable()),
ipv4_ssh_service: RwLock::new(EntryMap::unavailable()),
ipv6_ssh_service: RwLock::new(EntryMap::unavailable()),
ipv4_ssh_white_list: RwLock::new(EntryMap::unavailable()),
ipv6_ssh_white_list: RwLock::new(EntryMap::unavailable()),
ipv4_ssh_black_list: RwLock::new(EntryMap::unavailable()),
ipv6_ssh_black_list: RwLock::new(EntryMap::unavailable()),
}
}
pub async fn get_ipv4_http_service(&self) -> HashMap<SocketAddrV4, Vec<HttpMethod>> {
self.ipv4_http_service.read().await.get_http_method()
}
@ -178,48 +193,63 @@ impl ProtocolFilter {
}
struct WhiteListControl {
map: AyaArray<MapData, PlaceHolder>,
map: Option<AyaArray<MapData, PlaceHolder>>,
}
impl WhiteListControl {
fn new(ebpf: &mut Ebpf, map_name: &str) -> Result<Self, Error> {
let map = ebpf.take_map(map_name).ok_or(EbpfError::MapNotFound)?;
let map = AyaArray::try_from(map).map_err(EbpfError::MapOperationError)?;
Ok(Self { map })
Ok(Self { map: Some(map) })
}
fn unavailable() -> Self {
Self { map: None }
}
fn is_white_list_enable(&self) -> bool {
match self.map.get(&0, 0) {
let Some(map) = self.map.as_ref() else {
return false;
};
match map.get(&0, 0) {
Ok(status) => status != 0,
Err(_) => false,
}
}
fn enable_white_list(&mut self) -> Result<(), Error> {
self.map.set(0, 1_u8, 0).map_err(EbpfError::MapOperationError)?;
let map = self.map.as_mut().ok_or(EbpfError::NotLoaded)?;
map.set(0, 1_u8, 0).map_err(EbpfError::MapOperationError)?;
Ok(())
}
fn disable_white_list(&mut self) -> Result<(), Error> {
self.map.set(0, 0_u8, 0).map_err(EbpfError::MapOperationError)?;
let map = self.map.as_mut().ok_or(EbpfError::NotLoaded)?;
map.set(0, 0_u8, 0).map_err(EbpfError::MapOperationError)?;
Ok(())
}
}
struct HttpServiceWrapper<T> {
map: AyaHashMap<MapData, T, HttpMethodBitmap>,
map: Option<AyaHashMap<MapData, T, HttpMethodBitmap>>,
}
impl<T: NativeConvert + Pod> HttpServiceWrapper<T> {
fn new(ebpf: &mut Ebpf, map_name: &str) -> Result<Self, Error> {
let map = ebpf.take_map(map_name).ok_or(EbpfError::MapNotFound)?;
let map = AyaHashMap::try_from(map).map_err(EbpfError::MapOperationError)?;
Ok(Self { map })
Ok(Self { map: Some(map) })
}
fn unavailable() -> Self {
Self { map: None }
}
fn get_http_method(&self) -> HashMap<T::Native, Vec<HttpMethod>> {
self.map
.iter()
let Some(map) = self.map.as_ref() else {
return HashMap::new();
};
map.iter()
.filter_map(Result::ok)
.map(|(key, value)| {
let address = key.into_native();
@ -229,25 +259,25 @@ impl<T: NativeConvert + Pod> HttpServiceWrapper<T> {
}
fn add_http_service(&mut self, address: T::Native, http_method: Vec<HttpMethod>) -> Result<(), Error> {
let map = self.map.as_mut().ok_or(EbpfError::NotLoaded)?;
let address = T::from_native(address);
let ebpf_method = HttpMethod::convert_to_bitmap(http_method);
self.map
.insert(address, ebpf_method, 0)
map.insert(address, ebpf_method, 0)
.map_err(EbpfError::MapOperationError)?;
Ok(())
}
fn remove_http_service(&mut self, address: T::Native, removed_http_method: Vec<HttpMethod>) -> Result<(), Error> {
let map = self.map.as_mut().ok_or(EbpfError::NotLoaded)?;
let address = T::from_native(address);
if let Ok(current_http_method) = self.map.get(&address, 0) {
if let Ok(current_http_method) = map.get(&address, 0) {
let mut http_method = HttpMethod::convert_from_bitmap(current_http_method);
http_method.retain(|method| !removed_http_method.contains(method));
if http_method.is_empty() {
self.map.remove(&address).map_err(EbpfError::MapOperationError)?;
map.remove(&address).map_err(EbpfError::MapOperationError)?;
} else {
let new_http_method = HttpMethod::convert_to_bitmap(http_method);
self.map
.insert(address, new_http_method, 0)
map.insert(address, new_http_method, 0)
.map_err(EbpfError::MapOperationError)?;
}
Ok(())
@ -258,33 +288,41 @@ impl<T: NativeConvert + Pod> HttpServiceWrapper<T> {
}
struct EntryMap<T> {
map: AyaHashMap<MapData, T, PlaceHolder>,
map: Option<AyaHashMap<MapData, T, PlaceHolder>>,
}
impl<T: NativeConvert + Pod> EntryMap<T> {
fn new(ebpf: &mut Ebpf, map_name: &str) -> Result<Self, Error> {
let map = ebpf.take_map(map_name).ok_or(EbpfError::MapNotFound)?;
let map = AyaHashMap::try_from(map).map_err(EbpfError::MapOperationError)?;
Ok(Self { map })
Ok(Self { map: Some(map) })
}
fn unavailable() -> Self {
Self { map: None }
}
fn get_all(&self) -> Vec<T::Native> {
self.map
.keys()
let Some(map) = self.map.as_ref() else {
return Vec::new();
};
map.keys()
.filter_map(Result::ok)
.map(|key| key.into_native())
.collect()
}
fn add(&mut self, key: T::Native) -> Result<(), Error> {
let map = self.map.as_mut().ok_or(EbpfError::NotLoaded)?;
let key = T::from_native(key);
self.map.insert(key, 0_u8, 0).map_err(EbpfError::MapOperationError)?;
map.insert(key, 0_u8, 0).map_err(EbpfError::MapOperationError)?;
Ok(())
}
fn remove(&mut self, key: T::Native) -> Result<(), Error> {
let map = self.map.as_mut().ok_or(EbpfError::NotLoaded)?;
let key = T::from_native(key);
self.map.remove(&key).map_err(EbpfError::MapOperationError)?;
map.remove(&key).map_err(EbpfError::MapOperationError)?;
Ok(())
}
}

View File

@ -6,7 +6,7 @@ use crate::model::error::Error;
use crate::model::error::ebpf::EbpfError;
pub struct RateLimitConfig {
config_map: Mutex<Array<MapData, u64>>,
config_map: Mutex<Option<Array<MapData, u64>>>,
}
impl RateLimitConfig {
@ -14,82 +14,66 @@ impl RateLimitConfig {
let map = ebpf.take_map("RATE_LIMIT_CONFIG").ok_or(EbpfError::MapNotFound)?;
let config_map = Array::try_from(map).map_err(EbpfError::MapOperationError)?;
Ok(Self {
config_map: Mutex::new(config_map),
config_map: Mutex::new(Some(config_map)),
})
}
pub fn set_packet_rate(&self, rate: u64) -> Result<(), Error> {
self.config_map
.lock()
.set(0, rate, 0)
.map_err(EbpfError::MapOperationError)?;
pub fn unavailable() -> Self {
Self {
config_map: Mutex::new(None),
}
}
fn set_at(&self, index: u32, value: u64) -> Result<(), Error> {
let mut guard = self.config_map.lock();
let map = guard.as_mut().ok_or(EbpfError::NotLoaded)?;
map.set(index, value, 0).map_err(EbpfError::MapOperationError)?;
Ok(())
}
fn get_at(&self, index: u32) -> Result<u64, Error> {
let guard = self.config_map.lock();
let map = guard.as_ref().ok_or(EbpfError::NotLoaded)?;
map.get(&index, 0).map_err(|e| EbpfError::MapOperationError(e).into())
}
pub fn set_packet_rate(&self, rate: u64) -> Result<(), Error> {
self.set_at(0, rate)
}
pub fn set_syn_rate(&self, rate: u64) -> Result<(), Error> {
self.config_map
.lock()
.set(1, rate, 0)
.map_err(EbpfError::MapOperationError)?;
Ok(())
self.set_at(1, rate)
}
pub fn set_udp_rate(&self, rate: u64) -> Result<(), Error> {
self.config_map
.lock()
.set(2, rate, 0)
.map_err(EbpfError::MapOperationError)?;
Ok(())
self.set_at(2, rate)
}
pub fn set_dns_rate(&self, rate: u64) -> Result<(), Error> {
self.config_map
.lock()
.set(3, rate, 0)
.map_err(EbpfError::MapOperationError)?;
Ok(())
self.set_at(3, rate)
}
pub fn set_window_ns(&self, ns: u64) -> Result<(), Error> {
self.config_map
.lock()
.set(4, ns, 0)
.map_err(EbpfError::MapOperationError)?;
Ok(())
self.set_at(4, ns)
}
pub fn get_packet_rate(&self) -> Result<u64, Error> {
self.config_map
.lock()
.get(&0, 0)
.map_err(|e| EbpfError::MapOperationError(e).into())
self.get_at(0)
}
pub fn get_syn_rate(&self) -> Result<u64, Error> {
self.config_map
.lock()
.get(&1, 0)
.map_err(|e| EbpfError::MapOperationError(e).into())
self.get_at(1)
}
pub fn get_udp_rate(&self) -> Result<u64, Error> {
self.config_map
.lock()
.get(&2, 0)
.map_err(|e| EbpfError::MapOperationError(e).into())
self.get_at(2)
}
pub fn get_dns_rate(&self) -> Result<u64, Error> {
self.config_map
.lock()
.get(&3, 0)
.map_err(|e| EbpfError::MapOperationError(e).into())
self.get_at(3)
}
pub fn get_window_ns(&self) -> Result<u64, Error> {
self.config_map
.lock()
.get(&4, 0)
.map_err(|e| EbpfError::MapOperationError(e).into())
self.get_at(4)
}
}

View File

@ -61,8 +61,8 @@ impl BufferPool {
pub struct XskManager {
app_config: Arc<AppConfig>,
xsk_map: Mutex<XskMap<MapData>>,
egress_xsk_map: Mutex<XskMap<MapData>>,
xsk_map: Mutex<Option<XskMap<MapData>>>,
egress_xsk_map: Mutex<Option<XskMap<MapData>>>,
}
impl XskManager {
@ -77,17 +77,32 @@ impl XskManager {
Ok(Self {
app_config,
xsk_map: Mutex::new(xsk_map),
egress_xsk_map: Mutex::new(egress_xsk_map),
xsk_map: Mutex::new(Some(xsk_map)),
egress_xsk_map: Mutex::new(Some(egress_xsk_map)),
})
}
pub fn unavailable(app_config: Arc<AppConfig>) -> Self {
Self {
app_config,
xsk_map: Mutex::new(None),
egress_xsk_map: Mutex::new(None),
}
}
pub fn run(
&self,
ml_engine: Option<Arc<Engine>>,
dns_filter: Option<Arc<DnsFilter>>,
shutdowns: &SegQueue<oneshot::Sender<()>>,
) -> Result<(), Error> {
// If eBPF failed to load, there are no XSK maps to bind and no queues
// to start — skip silently. AF_XDP would have no maps to attach sockets
// to, and ML sees no packets, which is the designed behaviour.
if self.xsk_map.lock().is_none() || self.egress_xsk_map.lock().is_none() {
return Ok(());
}
let network = self.app_config.network.clone();
let combined_queue_count = network.combined_queue_count;
@ -117,8 +132,10 @@ impl XskManager {
None,
)?;
let mut xsk_map = self.xsk_map.lock();
let mut egress_xsk_map = self.egress_xsk_map.lock();
let mut xsk_guard = self.xsk_map.lock();
let mut egress_guard = self.egress_xsk_map.lock();
let xsk_map = xsk_guard.as_mut().ok_or(EbpfError::NotLoaded)?;
let egress_xsk_map = egress_guard.as_mut().ok_or(EbpfError::NotLoaded)?;
let ingress_fd = ingress_xsk.rx.fd().as_raw_fd();
xsk_map
@ -130,8 +147,8 @@ impl XskManager {
.set(queue_id, egress_fd, 0)
.map_err(EbpfError::AfXdpSetFailed)?;
drop(xsk_map);
drop(egress_xsk_map);
drop(xsk_guard);
drop(egress_guard);
let ingress_shutdown = ingress_xsk.run(ingress_to_egress_tx, egress_to_ingress_rx)?;
shutdowns.push(ingress_shutdown);

View File

@ -43,8 +43,7 @@ impl SmtpClient {
_ => return Ok(None),
};
// Try secret store first, fall back to settings
let password = Self::resolve_smtp_password(db, secrets)?;
let password = Self::resolve_smtp_password(secrets)?;
let password = match password {
Some(v) if !v.is_empty() => v,
_ => return Ok(None),
@ -92,13 +91,9 @@ impl SmtpClient {
_ => return Ok(None),
};
// Try secret store first, fall back to settings via SoarPort
let password = match secrets.and_then(|ss| ss.get_secret("smtp_password").ok().flatten()) {
Some(pw) if !pw.is_empty() => pw,
_ => match db.get_setting("smtp_password")? {
Some(v) if !v.is_empty() && v != "__encrypted__" => v,
_ => return Ok(None),
},
_ => return Ok(None),
};
let port: u16 = port_str.parse().unwrap_or(587);
@ -122,21 +117,10 @@ impl SmtpClient {
}
/// Resolve SMTP password: try secret store first, fall back to settings.
fn resolve_smtp_password(
db: &dyn RepositoryPort,
secrets: Option<&dyn SecretStorePort>,
) -> Result<Option<String>, Error> {
if let Some(ss) = secrets
&& let Some(pw) = ss.get_secret("smtp_password")?
&& !pw.is_empty()
{
return Ok(Some(pw));
}
// Fallback: read from settings (pre-migration or no secret store)
let val = db.get_setting("smtp_password")?;
match val {
Some(ref v) if v == "__encrypted__" => Ok(None),
other => Ok(other),
fn resolve_smtp_password(secrets: Option<&dyn SecretStorePort>) -> Result<Option<String>, Error> {
match secrets {
Some(ss) => Ok(ss.get_secret("smtp_password")?.filter(|pw| !pw.is_empty())),
None => Ok(None),
}
}

View File

@ -80,8 +80,8 @@ pub struct System {
pub soar_engine: Arc<SoarEngine>,
pub ttl_scheduler: Option<TtlScheduler>,
pub report_scheduler: Option<ReportScheduler>,
pub ingress_ebpf: Ebpf,
pub egress_ebpf: Ebpf,
pub ingress_ebpf: Option<Ebpf>,
pub egress_ebpf: Option<Ebpf>,
pub acl_service: Arc<AclService>,
pub config_service: Arc<ConfigService>,
pub dns_filter_service: Arc<DnsFilterService>,
@ -91,7 +91,10 @@ pub struct System {
pub geoip: Option<Arc<GeoIpService>>,
pub drift_detector: Arc<parking_lot::Mutex<DriftDetector>>,
pub shutdown_handle: Option<Arc<ShutdownHandle>>,
_ingress_program_array: ProgramArray<MapData>,
_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<()>>,
}
impl System {
@ -122,6 +125,9 @@ impl System {
drift_detector: state.drift_detector,
shutdown_handle: None,
_ingress_program_array: state._ingress_program_array,
ebpf_health: state.ebpf_health,
suricata_manager: state.suricata_manager,
suricata_shutdown: None,
})
}
@ -141,16 +147,42 @@ impl System {
attacks: self.inference_config.num_attack_types()
});
ServiceFactory::aya_log_init(&mut self.ingress_ebpf, &mut self.egress_ebpf)?;
log!(SystemLog::InitializeComplete);
self.attach_ebpf()?;
// aya_log_init + attach_xdp only make sense if the eBPF objects
// loaded. When eBPF is unavailable we skip both; the rest of the
// system runs normally and the eBPF health broadcast tells the UI why.
// aya_log_init failure is logged but non-fatal — the kernel programs
// still run, we just lose the in-kernel log channel.
if let (Some(ingress), Some(egress)) = (self.ingress_ebpf.as_mut(), self.egress_ebpf.as_mut()) {
if let Err(e) = ServiceFactory::aya_log_init(ingress, egress) {
use crate::infrastructure::ebpf_preflight;
use crate::model::system::health::EbpfFailStage;
let health = ebpf_preflight::classify(EbpfFailStage::LoggerInit, &e, None);
log!(SystemLog::EbpfBringupFailed(format!("{:?}", health)));
}
log!(SystemLog::InitializeComplete);
self.attach_ebpf()?;
} else {
log!(SystemLog::InitializeComplete);
}
// Subscribe to ML alerts BEFORE starting services to avoid race condition
let ml_alert_rx = self.app_services.ml_alert.subscribe_to_alerts();
let ebpf_services = self.ebpf_services.clone();
let app_services = self.app_services.clone();
ebpf_services.run(app_services.ml_engine.clone()).await?;
// AF_XDP socket bind + drop-ring-buf consumer. If eBPF maps are
// unavailable the call already returns Ok(()) without doing anything.
// When maps exist but bind fails (e.g. igb on kernel < 6.17), record
// the classified reason and continue — the ML engine will see no
// packets, same as a network that is simply quiet.
if let Err(e) = ebpf_services.run(app_services.ml_engine.clone()).await {
use crate::infrastructure::ebpf_preflight;
use crate::model::system::health::EbpfFailStage;
let iface = self.app_config.network.ingress_ifname.as_str();
let health = ebpf_preflight::classify(EbpfFailStage::AfXdpBind, &e, Some(iface));
log!(SystemLog::EbpfBringupFailed(format!("{:?}", health)));
*self.ebpf_health.write() = health;
}
app_services.run().await?;
// Start SOAR engine
@ -201,6 +233,7 @@ impl System {
// Clone detection_tx for correlation engine and beaconing detector
let correlation_detection_tx = detection_tx.clone();
let beaconing_detection_tx = detection_tx.clone();
let suricata_detection_tx = detection_tx.clone();
// Start cross-flow correlation engine (botnet, scan, lateral movement detection)
let correlation_alert_rx = self.app_services.ml_alert.subscribe_to_alerts();
@ -284,6 +317,7 @@ impl System {
rate_limit_service: self.rate_limit_service.clone(),
force_https,
shutdown_handle: shutdown_handle.clone(),
suricata_manager: self.suricata_manager.clone(),
};
let ready_for_http = ready_flag_for_set.clone();
actix::spawn(async move {
@ -319,6 +353,18 @@ impl System {
}
}
// Start Suricata subprocess supervisor (no-op if disabled in config).
self.suricata_shutdown = Some(self.suricata_manager.clone().run());
// 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();
// Wait for shutdown signal (ctrl-c OR API-triggered)
tokio::select! {
_ = tokio::signal::ctrl_c() => {
@ -330,11 +376,14 @@ impl System {
}
}
pub async fn terminate(&self) -> Result<(), Error> {
pub async fn terminate(&mut self) -> Result<(), Error> {
let ebpf_services = self.ebpf_services.clone();
let app_services = self.app_services.clone();
log!(SystemLog::Terminating);
if let Some(tx) = self.suricata_shutdown.take() {
let _ = tx.send(());
}
ebpf_services.terminate();
app_services.terminate();
log!(SystemLog::TerminateComplete);
@ -430,19 +479,44 @@ impl System {
}
}
/// Attempt to attach the XDP programs to the configured interfaces.
/// If either attach fails, record the reason in `ebpf_health` and
/// continue — the rest of the system keeps running.
fn attach_ebpf(&mut self) -> Result<(), Error> {
let ingress_ifname = self.app_config.network.ingress_ifname.clone();
let egress_ifname = self.app_config.network.egress_ifname.clone();
ServiceFactory::set_memory_limit()?;
let ingress_mode = ServiceFactory::attach_xdp(&mut self.ingress_ebpf, &ingress_ifname, true)?;
let egress_mode = ServiceFactory::attach_xdp(&mut self.egress_ebpf, &egress_ifname, false)?;
let (ingress, egress) = match (self.ingress_ebpf.as_mut(), self.egress_ebpf.as_mut()) {
(Some(i), Some(e)) => (i, e),
_ => return Ok(()),
};
if let Err(e) = self.db.set_setting("xdp_ingress_mode", &ingress_mode) {
log!(SystemError::XdpModeStoreFailed(e));
}
if let Err(e) = self.db.set_setting("xdp_egress_mode", &egress_mode) {
log!(SystemError::XdpModeStoreFailed(e));
let ingress_result = ServiceFactory::attach_xdp(ingress, &ingress_ifname, true);
let egress_result = ServiceFactory::attach_xdp(egress, &egress_ifname, false);
match (ingress_result, egress_result) {
(Ok(ingress_mode), Ok(egress_mode)) => {
if let Err(e) = self.db.set_setting("xdp_ingress_mode", &ingress_mode) {
log!(SystemError::XdpModeStoreFailed(e));
}
if let Err(e) = self.db.set_setting("xdp_egress_mode", &egress_mode) {
log!(SystemError::XdpModeStoreFailed(e));
}
}
(ingress_res, egress_res) => {
use crate::infrastructure::ebpf_preflight;
use crate::model::system::health::EbpfFailStage;
let (err, iface) = match (&ingress_res, &egress_res) {
(Err(e), _) => (e, ingress_ifname.as_str()),
(_, Err(e)) => (e, egress_ifname.as_str()),
_ => unreachable!("at least one branch is Err here"),
};
let health = ebpf_preflight::classify(EbpfFailStage::XdpAttach, err, Some(iface));
log!(SystemLog::EbpfBringupFailed(format!("{:?}", health)));
*self.ebpf_health.write() = health;
}
}
Ok(())

View File

@ -1,7 +1,9 @@
use crate::adapter::persistence::Database;
use crate::model::error::Error;
use crate::model::error::system::SystemError;
use crate::model::system::config::{HttpConfig, InferenceConfig, MiscConfig, NetworkConfig, PipelineConfig};
use crate::model::system::config::{
HttpConfig, InferenceConfig, MiscConfig, NetworkConfig, PipelineConfig, SuricataConfig,
};
pub struct AppConfig {
pub http: HttpConfig,
@ -9,6 +11,7 @@ pub struct AppConfig {
pub inference: InferenceConfig,
pub misc: MiscConfig,
pub pipeline: PipelineConfig,
pub suricata: SuricataConfig,
}
impl AppConfig {
@ -74,6 +77,13 @@ impl AppConfig {
("dns_max_domains_per_request", "1000".into()),
// HTTPS redirect
("force_https", "false".into()),
// Suricata bridge
("suricata_enabled", "false".into()),
("suricata_binary_path", "/usr/bin/suricata".into()),
("suricata_config_path", "/etc/netguardia/suricata.yaml".into()),
("suricata_eve_log_path", "/var/log/netguardia/eve.json".into()),
("suricata_auto_restart_on_crash", "true".into()),
("suricata_restart_backoff_secs", "10".into()),
];
for (key, value) in defaults {
@ -129,6 +139,14 @@ impl AppConfig {
ingress: vec!["access_control".into(), "rate_limit".into(), "service".into()],
egress: vec![],
},
suricata: SuricataConfig {
enabled: false,
binary_path: "/usr/bin/suricata".into(),
config_path: "/etc/netguardia/suricata.yaml".into(),
eve_log_path: "/var/log/netguardia/eve.json".into(),
auto_restart_on_crash: true,
restart_backoff_secs: 10,
},
}
}
@ -278,6 +296,34 @@ impl AppConfig {
config.misc.geoip_db_name = v;
}
// Suricata bridge
if let Ok(Some(v)) = db.get_setting("suricata_enabled") {
config.suricata.enabled = v == "true" || v == "1";
}
if let Ok(Some(v)) = db.get_setting("suricata_binary_path")
&& !v.is_empty()
{
config.suricata.binary_path = v;
}
if let Ok(Some(v)) = db.get_setting("suricata_config_path")
&& !v.is_empty()
{
config.suricata.config_path = v;
}
if let Ok(Some(v)) = db.get_setting("suricata_eve_log_path")
&& !v.is_empty()
{
config.suricata.eve_log_path = v;
}
if let Ok(Some(v)) = db.get_setting("suricata_auto_restart_on_crash") {
config.suricata.auto_restart_on_crash = v == "true" || v == "1";
}
if let Ok(Some(v)) = db.get_setting("suricata_restart_backoff_secs")
&& let Ok(n) = v.parse::<u64>()
{
config.suricata.restart_backoff_secs = n;
}
// Pipeline (stored as comma-separated)
if let Ok(Some(v)) = db.get_setting("pipeline_ingress") {
config.pipeline.ingress = if v.is_empty() {

View File

@ -20,6 +20,7 @@ use crate::model::error::misc::MiscError;
use crate::model::error::system::SystemError;
use crate::model::log::system::SystemLog;
use crate::model::system::config::MLInferenceConfig;
use crate::model::system::health::EbpfHealth;
/// Application-level service orchestrator.
/// Holds all runtime services (health monitoring, ML inference, flow statistics)
@ -38,8 +39,9 @@ impl AppServices {
app_config: Arc<AppConfig>,
inference_config: Arc<MLInferenceConfig>,
drift_detector: Arc<parking_lot::Mutex<DriftDetector>>,
ebpf_health: Arc<parking_lot::RwLock<EbpfHealth>>,
) -> Result<Self, Error> {
let health = SystemHealth::new(app_config.clone())?;
let health = SystemHealth::new(app_config.clone(), ebpf_health)?;
let batch_size = app_config.inference.inference_batch_size;
let ml_models = Arc::new(MLModels::load_models(&app_config, &inference_config, batch_size)?);

View File

@ -0,0 +1,173 @@
//! Classifier for eBPF bring-up failures.
//!
//! Takes a raw error plus interface/stage context and produces an
//! `EbpfHealth::Unavailable { stage, category, reason }` suitable for the
//! frontend status display.
//!
//! Classification is best-effort: we inspect `std::io::ErrorKind` where we
//! have one, then fall back to substring matching on the rendered error
//! string. The produced `reason` always includes the interface name,
//! host kernel release, and the NIC driver where those are obtainable,
//! so the operator can diagnose directly from the UI without shelling in.
use std::fs;
use crate::model::error::Error;
use crate::model::system::health::{EbpfFailCategory, EbpfFailStage, EbpfHealth};
/// Read the running kernel release from `/proc/sys/kernel/osrelease`.
/// Returns the trimmed value, or `"unknown"` if the file cannot be read.
pub fn kernel_release() -> String {
fs::read_to_string("/proc/sys/kernel/osrelease")
.ok()
.map(|s| s.trim().to_string())
.unwrap_or_else(|| "unknown".to_string())
}
/// Look up the driver name bound to a network interface via
/// `/sys/class/net/<ifname>/device/driver`. Returns the basename of
/// the symlink target, or `"unknown"` if the interface has no driver
/// (e.g., virtual or renamed) or the path is not readable.
pub fn interface_driver(ifname: &str) -> String {
let link = format!("/sys/class/net/{}/device/driver", ifname);
match fs::read_link(&link) {
Ok(target) => target
.file_name()
.and_then(|s| s.to_str())
.map(|s| s.to_string())
.unwrap_or_else(|| "unknown".to_string()),
Err(_) => "unknown".to_string(),
}
}
/// Build an `EbpfHealth::Unavailable` from an error and stage context.
///
/// `ifname` is optional because some stages (Load, LoggerInit, PipelineSetup)
/// fail before any interface is involved.
pub fn classify(stage: EbpfFailStage, err: &Error, ifname: Option<&str>) -> EbpfHealth {
let raw = err.to_string();
let category = categorize(&raw);
let kernel = kernel_release();
let driver = ifname.map(interface_driver);
let mut reason = format!("stage={:?}: {}", stage, raw);
reason.push_str(&format!(" (kernel {}", kernel));
if let Some(iface) = ifname {
reason.push_str(&format!(", interface {}", iface));
if let Some(drv) = driver.as_deref() {
reason.push_str(&format!(", driver {}", drv));
}
}
reason.push(')');
// For the igb-before-6.17 case, augment the reason with a targeted hint.
if matches!(category, EbpfFailCategory::AfXdpUnsupported)
&& driver.as_deref() == Some("igb")
&& !kernel_meets_igb_af_xdp(&kernel)
{
reason.push_str(". The igb driver supports AF_XDP only on kernel 6.17 or newer.");
}
EbpfHealth::Unavailable {
stage,
category,
reason,
}
}
/// Heuristic categorization based on the rendered error string.
/// Kept intentionally shallow — aya does not currently expose structured
/// enums for all kernel errno paths, so substring matching is the realistic
/// fallback.
fn categorize(raw: &str) -> EbpfFailCategory {
let lower = raw.to_lowercase();
if lower.contains("permission denied") || lower.contains("operation not permitted") || lower.contains("eperm") {
return EbpfFailCategory::Permission;
}
if lower.contains("no such device")
|| lower.contains("enodev")
|| lower.contains("no such file or directory")
&& (lower.contains("/sys/class/net") || lower.contains("interface"))
{
return EbpfFailCategory::InterfaceNotFound;
}
if lower.contains("operation not supported") || lower.contains("eopnotsupp") || lower.contains("enotsup") {
// The same errno covers both "driver does not support XDP" and
// "driver does not support AF_XDP". Disambiguate by XDP vs XSK/AF_XDP
// mention in the message when possible.
if lower.contains("xsk") || lower.contains("af_xdp") || lower.contains("afxdp") || lower.contains("bind") {
return EbpfFailCategory::AfXdpUnsupported;
}
return EbpfFailCategory::XdpUnsupported;
}
if lower.contains("cannot allocate memory") || lower.contains("enomem") || lower.contains("rlimit") {
return EbpfFailCategory::MemlockExhausted;
}
if lower.contains("invalid argument")
&& (lower.contains("verifier") || lower.contains("bpf_prog_load") || lower.contains("program load"))
{
return EbpfFailCategory::VerifierRejected;
}
if lower.contains("no such file")
&& (lower.contains("net-guardia-ingress") || lower.contains("net-guardia-egress") || lower.contains(".o"))
{
return EbpfFailCategory::ObjectNotFound;
}
EbpfFailCategory::Unknown
}
/// Parse a kernel release string like "6.17.4-generic" and return true
/// if it is >= 6.17. We only care about the first two numeric components.
fn kernel_meets_igb_af_xdp(release: &str) -> bool {
// Pull leading "MAJOR.MINOR" out of strings like "6.12.0-124.45.1.el10_1.x86_64".
let mut parts = release.split(|c: char| !c.is_ascii_digit()).filter(|s| !s.is_empty());
let Some(major_str) = parts.next() else {
return false;
};
let Some(minor_str) = parts.next() else {
return false;
};
let Ok(major): Result<u32, _> = major_str.parse() else {
return false;
};
let Ok(minor): Result<u32, _> = minor_str.parse() else {
return false;
};
major > 6 || (major == 6 && minor >= 17)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn kernel_version_matrix() {
assert!(kernel_meets_igb_af_xdp("6.17.0-generic"));
assert!(kernel_meets_igb_af_xdp("6.18.1"));
assert!(kernel_meets_igb_af_xdp("7.0.0"));
assert!(!kernel_meets_igb_af_xdp("6.16.9-generic"));
assert!(!kernel_meets_igb_af_xdp("6.12.0-124.45.1.el10_1.x86_64"));
assert!(!kernel_meets_igb_af_xdp("5.15.0"));
assert!(!kernel_meets_igb_af_xdp("nonsense"));
}
#[test]
fn categorizes_permission_errors() {
assert!(matches!(categorize("Permission denied (os error 13)"), EbpfFailCategory::Permission));
assert!(matches!(categorize("Operation not permitted"), EbpfFailCategory::Permission));
}
#[test]
fn categorizes_af_xdp_vs_xdp() {
assert!(matches!(
categorize("bind failed: Operation not supported (os error 95)"),
EbpfFailCategory::AfXdpUnsupported
));
assert!(matches!(
categorize("xdp attach: Operation not supported"),
EbpfFailCategory::XdpUnsupported
));
}
}

View File

@ -24,6 +24,7 @@ impl GeoIpService {
pub fn with_cache_size<P: AsRef<Path>>(db_path: P, cache_size: usize) -> Result<Self, MaxMindDbError> {
let reader = Reader::open_readfile(db_path)?;
// SAFETY: 10000 is a non-zero literal, NonZeroUsize::new is infallible.
let cache_capacity = NonZeroUsize::new(cache_size).unwrap_or_else(|| NonZeroUsize::new(10000).unwrap());
Ok(Self {

View File

@ -10,8 +10,8 @@ use crate::infrastructure::app_config::AppConfig;
use crate::model::error::Error;
use crate::model::log::health::Health;
use crate::model::system::health::{
ConfiguredNetworkStats, CpuCoreInfo, CpuDetails, LoadAverage, MemoryUsage, NetworkStats, SystemHealthMetrics,
SystemHealthStatus, SystemInfo,
ConfiguredNetworkStats, CpuCoreInfo, CpuDetails, EbpfHealth, LoadAverage, MemoryUsage, NetworkStats,
SystemHealthMetrics, SystemHealthStatus, SystemInfo,
};
pub struct SystemHealth {
@ -21,10 +21,11 @@ pub struct SystemHealth {
broadcast_tx: broadcast::Sender<SystemHealthMetrics>,
ingress_interface: String,
egress_interface: String,
ebpf_health: Arc<parking_lot::RwLock<EbpfHealth>>,
}
impl SystemHealth {
pub fn new(config: Arc<AppConfig>) -> Result<Self, Error> {
pub fn new(config: Arc<AppConfig>, ebpf_health: Arc<parking_lot::RwLock<EbpfHealth>>) -> Result<Self, Error> {
let (broadcast_tx, _) = broadcast::channel(100);
let health = SystemHealth {
@ -34,6 +35,7 @@ impl SystemHealth {
broadcast_tx,
ingress_interface: config.network.ingress_ifname.clone(),
egress_interface: config.network.egress_ifname.clone(),
ebpf_health,
};
Ok(health)
@ -71,12 +73,14 @@ impl SystemHealth {
let networks = self.networks.read().await;
let components = self.components.read().await;
let ebpf = self.ebpf_health.read().clone();
let metrics = Self::collect_metrics(
&system,
&networks,
&components,
&self.ingress_interface,
&self.egress_interface,
ebpf,
);
drop(system);
@ -96,6 +100,7 @@ impl SystemHealth {
components: &Components,
ingress_interface: &str,
egress_interface: &str,
ebpf: EbpfHealth,
) -> SystemHealthMetrics {
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@ -148,6 +153,7 @@ impl SystemHealth {
network_stats,
load_average,
temperature,
ebpf,
}
}
@ -236,15 +242,24 @@ impl SystemHealth {
let networks = self.networks.read().await;
let components = self.components.read().await;
let ebpf = self.ebpf_health.read().clone();
Self::collect_metrics(
&system,
&networks,
&components,
&self.ingress_interface,
&self.egress_interface,
ebpf,
)
}
/// Returns a handle to the shared eBPF health state. Consumers (HTTP
/// handlers, setup wizard, frontend) can read the current eBPF state
/// without going through the full metrics broadcast.
pub fn ebpf_health(&self) -> &Arc<parking_lot::RwLock<EbpfHealth>> {
&self.ebpf_health
}
pub fn subscribe_to_metrics(&self) -> broadcast::Receiver<SystemHealthMetrics> {
self.broadcast_tx.subscribe()
}

View File

@ -60,6 +60,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>,
}
/// CORS configuration shared by both full and setup servers.
@ -221,6 +222,7 @@ pub async fn run(params: HttpServerParams) -> Result<(), Error> {
let rate_limit_service = params.rate_limit_service;
let force_https = params.force_https;
let shutdown_handle = params.shutdown_handle;
let suricata_manager = params.suricata_manager;
let port = app_config.http.http_server_bind_port;
HttpServer::new(move || {
@ -255,7 +257,8 @@ pub async fn run(params: HttpServerParams) -> Result<(), Error> {
.app_data(web::Data::from(dns_filter_service.clone()))
.app_data(web::Data::from(notification_service.clone()))
.app_data(web::Data::from(playbook_service.clone()))
.app_data(web::Data::from(rate_limit_service.clone()));
.app_data(web::Data::from(rate_limit_service.clone()))
.app_data(web::Data::from(suricata_manager.clone()));
app.wrap(SetupGuard)
.service(
web::scope("/api")

View File

@ -2,6 +2,7 @@ pub mod app_config;
pub mod app_services;
pub mod audit_logger;
pub mod communication_manager;
pub mod ebpf_preflight;
pub mod enforce_mode_handler;
pub mod geoip;
pub mod health;
@ -9,3 +10,5 @@ pub mod http_server;
pub mod secret_store;
pub mod service_factory;
pub mod statistics;
pub mod suricata_manager;
pub mod suricata_monitor;

View File

@ -148,48 +148,6 @@ impl SecretStore {
}
}
/// Idempotent startup migration: moves plaintext secrets from settings/notification_config
/// into the encrypted `app_secrets` table.
pub fn migrate_plaintext_secrets(&self) -> Result<(), Error> {
// Check if migration already done
if let Some(v) = self.db.get_setting("secrets_migrated")?
&& v == "true"
{
log!(CryptoLog::MigrationSkipped);
return Ok(());
}
let mut count = 0usize;
// 1. Migrate smtp_password
if let Some(password) = self.db.get_setting("smtp_password")?
&& password != "__encrypted__"
&& !password.is_empty()
{
self.set_secret("smtp_password", &password)?;
self.db.set_setting("smtp_password", "__encrypted__")?;
log!(CryptoLog::SecretMigrated("smtp_password".to_string()));
count += 1;
}
// 2. Migrate telegram_bot_token from notification_config JSON
if let Some(json_str) = self.db.get_notification_config("telegram")?
&& let Ok(mut config) = serde_json::from_str::<serde_json::Value>(&json_str)
&& let Some(token) = config.get("bot_token").and_then(|v| v.as_str()).map(|s| s.to_string())
&& token != "__encrypted__"
&& !token.is_empty()
{
self.set_secret("telegram_bot_token", &token)?;
config["bot_token"] = serde_json::Value::String("__encrypted__".to_string());
self.db.set_notification_config("telegram", &config.to_string())?;
log!(CryptoLog::SecretMigrated("telegram_bot_token".to_string()));
count += 1;
}
self.db.set_setting("secrets_migrated", "true")?;
log!(CryptoLog::MigrationComplete(count));
Ok(())
}
}
#[cfg(test)]

View File

@ -47,6 +47,7 @@ use crate::model::log::ebpf::EbpfLog;
use crate::model::log::system::SystemLog;
use crate::model::monitoring::direction::FlowDirection;
use crate::model::system::config::MLInferenceConfig;
use crate::model::system::health::EbpfHealth;
use macros::log;
/// Holds all Arc-wrapped services that make up the running application.
@ -70,10 +71,17 @@ pub struct AppState {
pub rate_limit_service: Arc<RateLimitService>,
pub geoip: Option<Arc<GeoIpService>>,
pub drift_detector: Arc<parking_lot::Mutex<DriftDetector>>,
pub ingress_ebpf: Ebpf,
pub egress_ebpf: Ebpf,
/// Held to keep the eBPF program array map FD alive.
pub _ingress_program_array: ProgramArray<MapData>,
pub ingress_ebpf: Option<Ebpf>,
pub egress_ebpf: Option<Ebpf>,
/// Held to keep the eBPF program array map FD alive. `None` when eBPF
/// failed to load.
pub _ingress_program_array: Option<ProgramArray<MapData>>,
/// Shared eBPF health state. Populated to `Healthy` on successful bring-up,
/// 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>,
}
/// Maps stage name (from config.toml) to (function_name, stage_id).
@ -96,36 +104,39 @@ impl ServiceFactory {
AppConfig::seed_defaults(&db)?;
let app_config = Arc::new(AppConfig::new(&db)?);
let mut ingress_ebpf = Self::load_ebpf("ingress")?;
let mut egress_ebpf = Self::load_ebpf("egress")?;
let ingress_program_array = Self::configure_ingress_pipeline(&mut ingress_ebpf, &app_config.pipeline.ingress)?;
let inference_config = Arc::new(MLInferenceConfig::load_file(&app_config.inference.models_config_name)?);
// Write queue count to eBPF maps for symmetric hash redirect
let num_queues = app_config.network.combined_queue_count;
Self::write_num_queues(&mut ingress_ebpf, num_queues)?;
Self::write_num_queues(&mut egress_ebpf, num_queues)?;
// 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));
// Attempt full eBPF bring-up. On any failure we classify the error,
// write it into `ebpf_health`, and fall back to an `EbpfServices`
// whose eBPF-backed operations return `EbpfError::NotLoaded`. The
// rest of the system (HTTP API, SOAR, ML engine, auth) is built
// regardless so the operator can still reach the frontend and see
// the reason.
let (ingress_ebpf, egress_ebpf, ingress_program_array, ebpf_services) =
match Self::try_build_ebpf(&app_config) {
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);
log!(SystemLog::EbpfBringupFailed(format!("{:?}", health)));
*ebpf_health.write() = health;
(None, None, None, Arc::new(EbpfServices::unavailable(app_config.clone())))
}
};
// Ensure enforce_mode setting exists (default: monitor)
if db.get_setting("enforce_mode")?.is_none() {
db.set_setting("enforce_mode", "monitor")?;
}
// Create secret store and run plaintext migration before anything reads secrets
let secret_store = Arc::new(SecretStore::new(db.clone()));
secret_store.migrate_plaintext_secrets()?;
let secret_store_port: Arc<dyn SecretStorePort> = secret_store.clone();
let jwt_service = Arc::new(JwtService::new(&secret_store_port, app_config.http.jwt_expiry_hours)?);
let ebpf_services = Arc::new(EbpfServices::new(
app_config.clone(),
&mut ingress_ebpf,
&mut egress_ebpf,
)?);
// Initialize ML drift detector from inference config baselines
let baselines = FeatureBaselines::from_inference_config(&inference_config);
let drift_window_secs: u64 = db
@ -143,6 +154,7 @@ impl ServiceFactory {
app_config.clone(),
inference_config.clone(),
drift_detector.clone(),
ebpf_health.clone(),
)?);
// Create AtomicU8 enforce-level cache (Monitor=0, MlOnly=1, Enforce=2)
@ -255,6 +267,9 @@ impl ServiceFactory {
secret_store_port,
));
let suricata_manager =
crate::infrastructure::suricata_manager::SuricataManager::new(app_config.clone());
Ok(AppState {
app_config,
inference_config,
@ -278,11 +293,40 @@ impl ServiceFactory {
ingress_ebpf,
egress_ebpf,
_ingress_program_array: ingress_program_array,
ebpf_health,
suricata_manager,
})
}
// --- eBPF loading helpers ---
/// Attempt the full eBPF bring-up chain: load both .o files, configure the
/// ingress pipeline, write queue counts, and hand out map handles to the
/// services. Returns the original stage on the first failure so the
/// classifier can render targeted diagnostics.
#[allow(clippy::type_complexity)]
fn try_build_ebpf(
app_config: &Arc<AppConfig>,
) -> Result<(Ebpf, Ebpf, ProgramArray<MapData>, EbpfServices), (crate::model::system::health::EbpfFailStage, Error)>
{
use crate::model::system::health::EbpfFailStage;
let mut ingress = Self::load_ebpf("ingress").map_err(|e| (EbpfFailStage::Load, e))?;
let mut egress = Self::load_ebpf("egress").map_err(|e| (EbpfFailStage::Load, e))?;
let pipeline = Self::configure_ingress_pipeline(&mut ingress, &app_config.pipeline.ingress)
.map_err(|e| (EbpfFailStage::PipelineSetup, e))?;
let num_queues = app_config.network.combined_queue_count;
Self::write_num_queues(&mut ingress, num_queues).map_err(|e| (EbpfFailStage::PipelineSetup, e))?;
Self::write_num_queues(&mut egress, num_queues).map_err(|e| (EbpfFailStage::PipelineSetup, e))?;
let services = EbpfServices::new(app_config.clone(), &mut ingress, &mut egress)
.map_err(|e| (EbpfFailStage::MapsBind, e))?;
Ok((ingress, egress, pipeline, services))
}
fn load_ebpf(name: &str) -> Result<Ebpf, Error> {
let bytes = match name {
"ingress" => aya::include_bytes_aligned!(concat!(env!("OUT_DIR"), "/net-guardia-ingress")),

View File

@ -0,0 +1,183 @@
//! Suricata subprocess manager — M1 scope.
//!
//! Responsibilities:
//! - Spawn Suricata as a child process bound to the ingress interface via AF_PACKET,
//! writing eve.json to the configured log path.
//! - Track liveness; update `SuricataHealth` shared state.
//! - On crash, restart with configured backoff (if enabled in config).
//! - On shutdown signal, send SIGTERM first then wait briefly, then SIGKILL
//! if the child still hasn't exited.
//!
//! M2 will add eve.json tail + parse; M3 will add SOAR translation. This module
//! does not read eve.json itself — downstream consumers tail the log path.
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use macros::log;
use tokio::process::{Child, Command};
use tokio::sync::oneshot;
use crate::infrastructure::app_config::AppConfig;
use crate::model::error::Error;
use crate::model::error::suricata::SuricataError;
use crate::model::log::suricata::SuricataLog;
use crate::model::system::suricata::SuricataHealth;
pub struct SuricataManager {
config: Arc<AppConfig>,
health: Arc<parking_lot::RwLock<SuricataHealth>>,
}
impl SuricataManager {
pub fn new(config: Arc<AppConfig>) -> Arc<Self> {
let initial = if config.suricata.enabled {
SuricataHealth::Stopped {
reason: "not yet started".to_string(),
}
} else {
SuricataHealth::Disabled
};
Arc::new(Self {
config,
health: Arc::new(parking_lot::RwLock::new(initial)),
})
}
/// Shared handle for HTTP handlers and the health broadcast.
pub fn health(&self) -> Arc<parking_lot::RwLock<SuricataHealth>> {
self.health.clone()
}
/// Supervisor loop. Returns a `oneshot::Sender` — dropping or sending on it
/// initiates graceful shutdown (SIGTERM → wait → SIGKILL).
pub fn run(self: Arc<Self>) -> oneshot::Sender<()> {
let (shutdown_tx, shutdown_rx) = oneshot::channel();
if !self.config.suricata.enabled {
log!(SuricataLog::Disabled);
return shutdown_tx;
}
tokio::spawn(async move {
self.supervisor_loop(shutdown_rx).await;
});
shutdown_tx
}
async fn supervisor_loop(self: Arc<Self>, mut shutdown_rx: oneshot::Receiver<()>) {
loop {
// Pre-flight: validate binary + config exist before spawning.
if let Err(e) = Self::preflight(&self.config) {
*self.health.write() = SuricataHealth::Stopped {
reason: e.to_string(),
};
return;
}
let mut child = match self.spawn_child() {
Ok(c) => c,
Err(e) => {
*self.health.write() = SuricataHealth::Stopped {
reason: e.to_string(),
};
return;
}
};
let pid = child.id().unwrap_or(0);
*self.health.write() = SuricataHealth::Running { pid };
log!(SuricataLog::Started { pid });
tokio::select! {
exit = child.wait() => {
let reason = match exit {
Ok(status) => format!("exited with {}", status),
Err(e) => format!("wait error: {}", e),
};
if self.config.suricata.auto_restart_on_crash {
let backoff = self.config.suricata.restart_backoff_secs;
log!(SuricataLog::CrashedRestartPending {
reason: reason.clone(),
backoff,
});
*self.health.write() = SuricataHealth::Stopped { reason };
tokio::time::sleep(Duration::from_secs(backoff)).await;
continue;
} else {
log!(SuricataLog::Stopped { reason: reason.clone() });
*self.health.write() = SuricataHealth::Stopped { reason };
return;
}
}
_ = &mut shutdown_rx => {
log!(SuricataLog::ShutdownRequested);
Self::graceful_stop(&mut child).await;
*self.health.write() = SuricataHealth::Stopped {
reason: "shutdown".to_string(),
};
return;
}
}
}
}
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());
}
let cfg = &config.suricata.config_path;
if !Path::new(cfg).exists() {
return Err(SuricataError::ConfigNotFound { path: cfg.clone() }.into());
}
Ok(())
}
fn spawn_child(&self) -> Result<Child, Error> {
let sc = &self.config.suricata;
let iface = &self.config.network.ingress_ifname;
log!(SuricataLog::Spawning {
binary: sc.binary_path.clone(),
config: sc.config_path.clone(),
iface: iface.clone(),
});
let mut cmd = Command::new(&sc.binary_path);
cmd.arg("-c")
.arg(&sc.config_path)
.arg("--af-packet")
.arg(iface)
.arg("-l")
// Log dir is the parent of the configured eve.json path.
.arg(
Path::new(&sc.eve_log_path)
.parent()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| ".".to_string()),
)
.kill_on_drop(true);
cmd.spawn().map_err(|e| SuricataError::SpawnFailed(e).into())
}
/// Send SIGTERM, wait up to 5s, then SIGKILL if still alive.
async fn graceful_stop(child: &mut Child) {
if let Some(pid) = child.id() {
// SAFETY: SIGTERM to a known child pid. pid was obtained from tokio::process::Child
// and is valid as long as we haven't reaped it, which we haven't.
unsafe {
libc::kill(pid as libc::pid_t, libc::SIGTERM);
}
}
match tokio::time::timeout(Duration::from_secs(5), child.wait()).await {
Ok(_) => {}
Err(_) => {
let _ = child.kill().await;
}
}
}
}

View File

@ -0,0 +1,180 @@
//! Suricata eve.json tail + parse (M2) + translate to DetectionEvent (M3).
//!
//! Runs as a tokio background task. Waits for the eve.json file to appear
//! (Suricata may take a few seconds after spawn to create it), then tails
//! new lines and parses each as JSON. Only `event_type=alert` lines are
//! forwarded; everything else (flow/stats/fileinfo) is ignored for v0.9.
//!
//! Forwarded events land on the shared `detection_tx` mpsc — the same
//! channel the ML + correlation + beaconing detectors feed. The detection
//! orchestrator handles dedup, GeoIP enrichment, and publishes the final
//! `ThreatDetectedEvent` that SOAR consumes.
//!
//! File rotation is handled by detecting a shrunken file length on next
//! poll — we reopen from offset 0. Suricata itself rotates eve.json on
//! SIGHUP; we don't send SIGHUP in v0.9 so rotation will be rare.
use std::io::SeekFrom;
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use macros::log;
use tokio::fs::File;
use tokio::io::{AsyncBufReadExt, AsyncSeekExt, BufReader};
use tokio::sync::mpsc;
use crate::infrastructure::app_config::AppConfig;
use crate::model::event::{DetectionEvent, DetectionSource};
use crate::model::log::suricata::SuricataLog;
/// Fixed poll interval for new eve.json content. eve.json is line-appended
/// so a short interval yields low latency; 200ms is well under any human
/// reaction time and negligible CPU cost.
const POLL_INTERVAL: Duration = Duration::from_millis(200);
/// How long to wait between checks while the file does not yet exist.
const FILE_WAIT_INTERVAL: Duration = Duration::from_secs(1);
pub struct SuricataMonitor {
config: Arc<AppConfig>,
detection_tx: mpsc::Sender<DetectionEvent>,
}
impl SuricataMonitor {
pub fn new(config: Arc<AppConfig>, detection_tx: mpsc::Sender<DetectionEvent>) -> Arc<Self> {
Arc::new(Self { config, detection_tx })
}
/// Spawn the tail loop. No-op if the bridge is disabled.
pub fn start(self: Arc<Self>) {
if !self.config.suricata.enabled {
return;
}
tokio::spawn(async move {
self.tail_loop().await;
});
}
async fn tail_loop(self: Arc<Self>) {
let path = self.config.suricata.eve_log_path.clone();
loop {
// Wait until the file exists — Suricata spawns asynchronously and
// may take a few seconds to create eve.json.
if !Path::new(&path).exists() {
log!(SuricataLog::MonitorWaitingForFile { path: path.clone() });
while !Path::new(&path).exists() {
tokio::time::sleep(FILE_WAIT_INTERVAL).await;
}
}
let mut file = match File::open(&path).await {
Ok(f) => f,
Err(_) => {
tokio::time::sleep(FILE_WAIT_INTERVAL).await;
continue;
}
};
// Seek to end so we only see new content from this point. Suricata
// writes a large volume at startup that we don't want to replay.
let mut pos: u64 = file.seek(SeekFrom::End(0)).await.unwrap_or_default();
log!(SuricataLog::MonitorAttached { path: path.clone() });
let mut reader = BufReader::new(file);
let mut line = String::new();
loop {
line.clear();
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
&& meta.len() < pos
{
log!(SuricataLog::MonitorFileRotated);
break; // reopen
}
tokio::time::sleep(POLL_INTERVAL).await;
}
Ok(n) => {
pos += n as u64;
self.handle_line(line.trim_end()).await;
}
Err(_) => {
// Read error — treat as rotation and reopen.
tokio::time::sleep(POLL_INTERVAL).await;
break;
}
}
}
}
}
async fn handle_line(&self, raw: &str) {
if raw.is_empty() {
return;
}
let v: serde_json::Value = match serde_json::from_str(raw) {
Ok(v) => v,
Err(_) => return,
};
if v.get("event_type").and_then(|x| x.as_str()) != Some("alert") {
return;
}
let Some(event) = Self::translate_alert(&v) else {
return;
};
// mpsc is bounded; if the orchestrator is backed up, drop rather than
// block the tail (eve.json will fill the disk if we block).
let _ = self.detection_tx.try_send(event);
}
/// Map a Suricata alert JSON object to a DetectionEvent. Returns None if
/// the event lacks the fields we need.
fn translate_alert(v: &serde_json::Value) -> Option<DetectionEvent> {
let src_ip = v.get("src_ip")?.as_str()?.to_string();
let dest_ip = v.get("dest_ip")?.as_str()?.to_string();
let proto_str = v.get("proto").and_then(|x| x.as_str()).unwrap_or("");
let protocol: u8 = match proto_str {
"TCP" => 6,
"UDP" => 17,
"ICMP" => 1,
_ => 0,
};
let alert = v.get("alert")?;
let sid = alert.get("signature_id").and_then(|x| x.as_u64()).unwrap_or(0) as u32;
let signature = alert.get("signature").and_then(|x| x.as_str()).unwrap_or("").to_string();
// Suricata severity: 1=high, 2=medium, 3=low, 4=informational.
// Map to confidence in [0.5, 1.0] so high-severity alerts tend to trip
// SOAR condition thresholds.
let severity = alert.get("severity").and_then(|x| x.as_u64()).unwrap_or(3);
let confidence = match severity {
1 => 0.95,
2 => 0.80,
3 => 0.65,
_ => 0.50,
};
log!(SuricataLog::AlertForwarded {
sid,
src: src_ip.clone(),
dst: dest_ip.clone(),
signature,
});
Some(DetectionEvent {
source: DetectionSource::Suricata,
attack_type: "suricata_alert".to_string(),
confidence,
source_ip: src_ip,
dest_ip,
protocol,
packet_count: 0,
flow_duration_us: 0,
ae_score: 0.0,
anomaly_score: 0.0,
c2_score: 0.0,
})
}
}

View File

@ -76,6 +76,20 @@ async fn main() -> Result<(), Error> {
println!("Done. Encrypted database written to {}", dest);
return Ok(());
}
"--verify-audit-log" => {
println!("Verifying audit_log hash chain in {}", db_path);
let db = Database::new(&db_path)?;
match db.verify_audit_log_chain() {
Ok(count) => {
println!("OK: {} audit_log rows verified, chain intact.", count);
return Ok(());
}
Err(e) => {
eprintln!("FAIL: {}", e);
std::process::exit(2);
}
}
}
_ => {}
}
}

View File

@ -12,6 +12,10 @@ traceable! {
#[no_source]
#[error("User '{username}' already exists")]
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,
}
}

View File

@ -75,5 +75,9 @@ traceable! {
#[error("eBPF rollback failed during ACL update: {err}")]
RollbackFailed => tracing::Level::ERROR,
#[no_source]
#[error("eBPF data plane is not loaded on this run — operation unavailable")]
NotLoaded => tracing::Level::WARN,
}
}

View File

@ -9,6 +9,7 @@ pub mod misc;
pub mod ml;
pub mod notification;
pub mod soar;
pub mod suricata;
pub mod system;
use serde::{Deserialize, Serialize};
@ -24,6 +25,7 @@ use crate::model::error::misc::MiscError;
use crate::model::error::ml::MLError;
use crate::model::error::notification::NotificationError;
use crate::model::error::soar::SoarError;
use crate::model::error::suricata::SuricataError;
use crate::model::error::system::SystemError;
#[derive(Clone, Debug, thiserror::Error, Serialize, Deserialize)]
@ -51,6 +53,8 @@ pub enum Error {
#[error("{0}")]
Soar(SoarError),
#[error("{0}")]
Suricata(SuricataError),
#[error("{0}")]
System(SystemError),
}
@ -120,6 +124,12 @@ impl From<SoarError> for Error {
}
}
impl From<SuricataError> for Error {
fn from(error: SuricataError) -> Self {
Self::Suricata(error)
}
}
impl From<McpError> for Error {
fn from(error: McpError) -> Self {
Self::Mcp(error)

View File

@ -0,0 +1,28 @@
use macros::traceable;
use tracing;
traceable! {
SuricataError {
#[no_source]
#[error("Suricata binary not found at '{path}'")]
BinaryNotFound { path: String } => tracing::Level::ERROR,
#[no_source]
#[error("Suricata config not found at '{path}'")]
ConfigNotFound { path: String } => tracing::Level::ERROR,
#[error("Failed to spawn Suricata subprocess")]
SpawnFailed => tracing::Level::ERROR,
#[no_source]
#[error("Suricata subprocess exited: {reason}")]
SubprocessExited { reason: String } => tracing::Level::WARN,
#[error("Failed to open eve.json stream at '{path}'")]
EveOpenFailed { path: String } => tracing::Level::ERROR,
#[no_source]
#[error("Failed to parse eve.json line: {reason}")]
EveParseFailed { reason: String } => tracing::Level::WARN,
}
}

View File

@ -11,6 +11,7 @@ pub enum DetectionSource {
ML,
Correlation,
Beaconing,
Suricata,
}
impl fmt::Display for DetectionSource {
@ -19,6 +20,7 @@ impl fmt::Display for DetectionSource {
DetectionSource::ML => write!(f, "ML"),
DetectionSource::Correlation => write!(f, "Correlation"),
DetectionSource::Beaconing => write!(f, "Beaconing"),
DetectionSource::Suricata => write!(f, "Suricata"),
}
}
}

View File

@ -7,14 +7,5 @@ loggable! {
#[error("Envelope encryption disabled — no master key (dev mode)")]
EnvelopeDisabled => tracing::Level::WARN,
#[error("Migrated secret: {key}")]
SecretMigrated { key: String } => tracing::Level::INFO,
#[error("Secret migration complete: {count} secrets encrypted")]
MigrationComplete { count: usize } => tracing::Level::INFO,
#[error("Secret migration skipped — already done")]
MigrationSkipped => tracing::Level::DEBUG,
}
}

View File

@ -5,17 +5,5 @@ loggable! {
MiscLog {
#[error("NETGUARDIA_DB_KEY is not set — database will NOT be encrypted (dev mode)")]
DbEncryptionDisabled => tracing::Level::WARN,
#[error("Database file exists but is neither valid plaintext nor valid encrypted — skipping migration")]
DbMigrationSkipped => tracing::Level::ERROR,
#[error("Migrating plaintext database to encrypted format")]
DbMigrationStarted => tracing::Level::INFO,
#[error("Database migration to encrypted format completed successfully")]
DbMigrationCompleted => tracing::Level::INFO,
#[error("Database encryption migration failed — keeping original plaintext DB: {error}")]
DbMigrationFailed { error: String } => tracing::Level::ERROR,
}
}

View File

@ -7,4 +7,5 @@ pub mod http;
pub mod misc;
pub mod ml;
pub mod soar;
pub mod suricata;
pub mod system;

View File

@ -0,0 +1,36 @@
use macros::loggable;
use tracing;
loggable! {
SuricataLog {
#[error("Suricata bridge disabled by config")]
Disabled => tracing::Level::INFO,
#[error("Spawning Suricata: {binary} -c {config} -i {iface}")]
Spawning { binary: String, config: String, iface: String } => tracing::Level::INFO,
#[error("Suricata subprocess started (pid={pid})")]
Started { pid: u32 } => tracing::Level::INFO,
#[error("Suricata subprocess exited unexpectedly: {reason}. Restart in {backoff}s")]
CrashedRestartPending { reason: String, backoff: u64 } => tracing::Level::WARN,
#[error("Suricata subprocess stopped: {reason}")]
Stopped { reason: String } => tracing::Level::INFO,
#[error("Suricata subprocess sent SIGTERM for graceful shutdown")]
ShutdownRequested => tracing::Level::INFO,
#[error("Suricata eve.json monitor waiting for file: {path}")]
MonitorWaitingForFile { path: String } => tracing::Level::INFO,
#[error("Suricata eve.json monitor attached to {path}")]
MonitorAttached { path: String } => tracing::Level::INFO,
#[error("Suricata eve.json rotated — reopening")]
MonitorFileRotated => tracing::Level::INFO,
#[error("Suricata alert forwarded: sid={sid} {src}->{dst} {signature}")]
AlertForwarded { sid: u32, src: String, dst: String, signature: String } => tracing::Level::DEBUG,
}
}

View File

@ -101,5 +101,8 @@ loggable! {
#[error("ML drift detected: {count} features drifted, max deviation {deviation:.2}σ")]
DriftDetected { count: usize, deviation: f64 } => tracing::Level::WARN,
#[error("eBPF bring-up failed — continuing without data plane: {details}")]
EbpfBringupFailed { details: String } => tracing::Level::ERROR,
}
}

View File

@ -75,6 +75,16 @@ pub struct PipelineConfig {
pub egress: Vec<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct SuricataConfig {
pub enabled: bool,
pub binary_path: String,
pub config_path: String,
pub eve_log_path: String,
pub auto_restart_on_crash: bool,
pub restart_backoff_secs: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MLInferenceConfig {
pub ae_feature_names: Vec<String>,

View File

@ -11,6 +11,76 @@ pub struct SystemHealthMetrics {
pub network_stats: ConfiguredNetworkStats,
pub load_average: Option<LoadAverage>,
pub temperature: Option<f32>,
pub ebpf: EbpfHealth,
}
/// Runtime health of the eBPF/XDP data plane.
///
/// `Healthy` means both ingress and egress XDP programs are attached and AF_XDP
/// sockets are bound. `Unavailable` means one of the eBPF setup stages failed;
/// the rest of the system continues to run but any eBPF-backed operation
/// (access control rules, geo block, rate limit, DNS filter, packet capture)
/// will return `EbpfError::NotLoaded` when invoked.
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "state", rename_all = "snake_case")]
pub enum EbpfHealth {
Healthy,
Unavailable {
stage: EbpfFailStage,
category: EbpfFailCategory,
/// Human-readable explanation, including interface, kernel version,
/// driver name, and the raw error from the kernel where available.
reason: String,
},
}
impl EbpfHealth {
#[allow(dead_code)] // exposed to HTTP handlers in task #2; keep alongside the type
pub fn is_healthy(&self) -> bool {
matches!(self, EbpfHealth::Healthy)
}
}
/// Which stage of eBPF bring-up failed.
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum EbpfFailStage {
/// `aya::Ebpf::load(...)` — reading the compiled BPF object file.
Load,
/// `aya_log::EbpfLogger::init(...)` — wiring the kernel-to-userspace log channel.
LoggerInit,
/// Pipeline program array setup (tail-call dispatch table).
PipelineSetup,
/// `EbpfServices::new(...)` — taking map handles for the userspace services.
MapsBind,
/// `xdp.attach(ifname, ...)` — attaching the XDP program to the NIC.
XdpAttach,
/// AF_XDP socket bind for packet capture.
AfXdpBind,
}
/// Category of why eBPF bring-up failed. Used by frontend to render
/// targeted guidance (permission vs. driver vs. interface).
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum EbpfFailCategory {
/// EPERM / EACCES — process lacks CAP_BPF / CAP_NET_ADMIN / CAP_SYS_ADMIN.
Permission,
/// ENODEV / interface name does not resolve.
InterfaceNotFound,
/// Interface exists but XDP native/SKB attach refused by driver.
XdpUnsupported,
/// AF_XDP bind rejected — driver does not implement AF_XDP on this kernel.
/// Common case: Intel i350 (igb) on kernel < 6.17.
AfXdpUnsupported,
/// ENOMEM / RLIMIT_MEMLOCK exhausted.
MemlockExhausted,
/// BPF verifier rejected the program (kernel feature missing or bug).
VerifierRejected,
/// BPF object file missing or malformed.
ObjectNotFound,
/// Catch-all for errors we could not classify.
Unknown,
}
#[derive(Debug, Clone, Serialize)]

View File

@ -2,3 +2,4 @@ pub mod config;
pub mod health;
pub mod rate_limit_settings;
pub mod readiness;
pub mod suricata;

View File

@ -0,0 +1,27 @@
use serde::Serialize;
/// Runtime health of the Suricata subprocess bridge.
///
/// `Disabled`: Suricata bridge is off by config — no process is launched.
/// `Running`: subprocess is alive and eve.json tail is active.
/// `Stopped`: subprocess exited (crashed or graceful) and no auto-restart
/// is pending, or the bridge was shut down. `reason` carries context.
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "state", rename_all = "snake_case")]
pub enum SuricataHealth {
Disabled,
Running {
/// OS process id of the Suricata child. Useful for operator debugging.
pid: u32,
},
Stopped {
reason: String,
},
}
impl SuricataHealth {
#[allow(dead_code)]
pub fn is_running(&self) -> bool {
matches!(self, SuricataHealth::Running { .. })
}
}