mirror of
https://github.com/DaLaw2/NetGuardia.git
synced 2026-08-24 14:10:28 +09:00
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:
parent
730849e02d
commit
c6a77c9611
8
.gitignore
vendored
8
.gitignore
vendored
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
42
README.md
42
README.md
@ -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.
|
||||
|
||||
|
||||
@ -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"
|
||||
@ -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
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(())
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)?;
|
||||
|
||||
@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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(())
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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)?);
|
||||
|
||||
173
net-guardia/src/infrastructure/ebpf_preflight.rs
Normal file
173
net-guardia/src/infrastructure/ebpf_preflight.rs
Normal 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
|
||||
));
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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)]
|
||||
|
||||
@ -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")),
|
||||
|
||||
183
net-guardia/src/infrastructure/suricata_manager.rs
Normal file
183
net-guardia/src/infrastructure/suricata_manager.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
180
net-guardia/src/infrastructure/suricata_monitor.rs
Normal file
180
net-guardia/src/infrastructure/suricata_monitor.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
28
net-guardia/src/model/error/suricata.rs
Normal file
28
net-guardia/src/model/error/suricata.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
@ -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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,4 +7,5 @@ pub mod http;
|
||||
pub mod misc;
|
||||
pub mod ml;
|
||||
pub mod soar;
|
||||
pub mod suricata;
|
||||
pub mod system;
|
||||
|
||||
36
net-guardia/src/model/log/suricata.rs
Normal file
36
net-guardia/src/model/log/suricata.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>,
|
||||
|
||||
@ -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)]
|
||||
|
||||
@ -2,3 +2,4 @@ pub mod config;
|
||||
pub mod health;
|
||||
pub mod rate_limit_settings;
|
||||
pub mod readiness;
|
||||
pub mod suricata;
|
||||
|
||||
27
net-guardia/src/model/system/suricata.rs
Normal file
27
net-guardia/src/model/system/suricata.rs
Normal 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 { .. })
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user