feat: architecture, detection, security, SOAR, operations (#18)

* feat: Phase 2-5 — architecture, detection, security, SOAR, operations

Architecture:
- Hexagonal port traits (10 modules migrated from Arc<Database>)
- Domain model types moved to model/ directory
- Constants centralized + 7 made runtime-configurable via DB
- Dead Error/Log variants cleaned up, SystemLog split

Detection (Phase 5):
- Detection orchestrator with dedup + enrichment + source attribution
- Cross-flow correlation engine: botnet, scan, lateral movement (T9)
- Temporal beaconing detector: CV-based C2 periodicity (T10)
- LRU flow eviction replacing O(n) min_by_key scan (T12)

Security hardening:
- 7 fixes: alg:none, config secret leak, HTTPS open redirect,
  log traversal, HKDF salt, SOAR whitelist+cooldown, operator validation
- 4 memory safety fixes: LRU dedup, frequency cleanup, drift cap, clock
- Envelope encryption for secrets (AES-256-GCM + HKDF)
- 17 new tests (SecretStore + SOAR conditions)

SOAR (Phase 3):
- Multi-condition playbooks (5 condition types, AND logic)
- Playbook update API (PUT + toggle endpoints)

Operations (Phase 4):
- Dynamic log level, system control APIs (shutdown/restart)
- HTTP config hot reload, spawn_blocking for CPU-bound work
- CLI encrypt-db / decrypt-db commands
- Audit log API

Log level audit:
- 16 variants adjusted (noisy hot-path → TRACE/DEBUG)
- 5 dead variants removed

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address Copilot review — 6 issues from PR #18

1. Botnet detector source_ip was set to victim dst_ip, causing SOAR
   to block the victim instead of the attacker
2. HTTPS redirect host header injection: validate host is private IP,
   localhost, or .local hostname before constructing redirect URL
3. smtp_password plaintext residue: clear settings table after writing
   to SecretStore to prevent pre-migration plaintext from persisting
4. install.sh: add apt-get update before install on Debian/Ubuntu
5. download_log OOM risk: add 50MB file size limit before reading
6. update_config restart trigger: check return value, report if
   shutdown already in progress instead of claiming success

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address Agent Team review — security, perf, correctness

Security:
- S1: Add RBAC permission check for /api/logs/ and /api/audit/ endpoints
  (previously any authenticated user could access)
- S2/S3: Remove report_dir and log_dir from configurable settings to
  prevent arbitrary directory write via config API
- A2: Pin DNS-resolved IPs in webhook reqwest client to prevent DNS
  rebinding TOCTOU attack (resolve() instead of re-resolving)

Performance:
- P7: Add 50K key cap to FrequencyTracker to prevent unbounded growth
  under DDoS (was unbounded, worst case 1.6GB)
- P9: Increase ML alert broadcast capacity 100 → 1024 to prevent lost
  alerts during DDoS spikes (3 subscribers contend on 100-slot buffer)
- P2: Reduce FLOW_MAX_PERIODS 10000 → 1000 (saves 144KB/flow, feature
  extraction only uses aggregate stats)
- P1: Remove unnecessary FlowKey clone on hot path (~1.9MB/s saved)
- P5: Beaconing detector: split analyze_and_alert into read-lock scan
  + selective write-lock update (reduces DashMap contention)

Correctness:
- A4: Capture correlation counts inside DashMap guard before dropping,
  eliminating TOCTOU in logged values (botnet, scan, lateral)
- A6: Log warning when SOAR playbook action params JSON is malformed
  instead of silently replacing with empty object

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: add trainer submodule, update frontend submodule

- Add net-guardia-trainer submodule (ParrotXray/NetGuardia-Trainer@dalaw2-dev)
- Update frontend submodule with code quality fixes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
DaLaw2 2026-04-03 15:56:33 +08:00 committed by GitHub
parent 12e0ff70cc
commit fbaef94082
156 changed files with 8554 additions and 2759 deletions

9
.gitignore vendored
View File

@ -1,3 +1,6 @@
# Claude Code
.claude/
### https://raw.github.com/github/gitignore/master/Rust.gitignore
# Generated by Cargo
@ -44,6 +47,12 @@ TODOS.md
VERSION
CHANGELOG.md
# Benchmark data/results (local only)
benchmark/
# Generated docs
docs/
# SQLite database files
*.db
*.db-shm

4
.gitmodules vendored
View File

@ -1,3 +1,7 @@
[submodule "net-guardia-frontend"]
path = net-guardia-frontend
url = https://github.com/DaLaw2/NetGuardia-FrontEnd.git
[submodule "net-guardia-trainer"]
path = net-guardia-trainer
url = https://github.com/ParrotXray/NetGuardia-Trainer.git
branch = dalaw2-dev

125
Cargo.lock generated
View File

@ -258,6 +258,41 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aead"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common",
"generic-array",
]
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures 0.2.17",
]
[[package]]
name = "aes-gcm"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
dependencies = [
"aead",
"aes",
"cipher",
"ctr",
"ghash",
"subtle",
]
[[package]]
name = "ahash"
version = "0.8.12"
@ -771,6 +806,16 @@ dependencies = [
"stacker",
]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]]
name = "clang-sys"
version = "1.8.1"
@ -969,9 +1014,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"rand_core 0.6.4",
"typenum",
]
[[package]]
name = "ctr"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
dependencies = [
"cipher",
]
[[package]]
name = "dashmap"
version = "6.1.0"
@ -1322,6 +1377,16 @@ dependencies = [
"wasip3",
]
[[package]]
name = "ghash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
dependencies = [
"opaque-debug",
"polyval",
]
[[package]]
name = "glob"
version = "0.3.3"
@ -1406,6 +1471,24 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hkdf"
version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
dependencies = [
"hmac",
]
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
]
[[package]]
name = "hostname"
version = "0.4.2"
@ -1695,6 +1778,15 @@ dependencies = [
"which",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]]
name = "ipnet"
version = "2.12.0"
@ -2188,10 +2280,12 @@ dependencies = [
"actix-cors",
"actix-web",
"actix-ws",
"aes-gcm",
"argon2",
"async-trait",
"aya",
"aya-log",
"base64",
"cargo_metadata",
"chrono",
"common",
@ -2199,6 +2293,7 @@ dependencies = [
"dashmap",
"dotenvy",
"futures-util",
"hkdf",
"ipnetwork",
"jsonwebtoken",
"lettre",
@ -2248,7 +2343,7 @@ name = "ng-cli"
version = "0.1.0"
dependencies = [
"clap",
"rand 0.9.2",
"libc",
"reqwest",
"serde",
"serde_json",
@ -2430,6 +2525,12 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "parking_lot"
version = "0.12.5"
@ -2553,6 +2654,18 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "polyval"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"opaque-debug",
"universal-hash",
]
[[package]]
name = "portable-atomic"
version = "1.13.1"
@ -3996,6 +4109,16 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "universal-hash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common",
"subtle",
]
[[package]]
name = "untrusted"
version = "0.9.0"

View File

@ -33,7 +33,7 @@ actix-ws = "0.4.0"
# Logging / tracing
tracing = "0.1.44"
tracing-appender = "0.2.4"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
tracing-subscriber = { version = "0.3.23", features = ["env-filter", "registry"] }
# ML
tract-onnx = "0.22.1"
@ -68,10 +68,10 @@ quote = "1.0.45"
syn = { version = "2.0.117", features = ["full"] }
[profile.dev]
panic = "abort"
panic = "unwind"
[profile.release]
panic = "abort"
panic = "unwind"
opt-level = 3
lto = "thin"
strip = true

View File

@ -9,7 +9,7 @@ serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
clap = { workspace = true }
rand = { workspace = true }
libc = { workspace = true }
[[bin]]
name = "ng"

View File

@ -19,28 +19,25 @@ struct Cli {
#[derive(Subcommand)]
enum Commands {
/// System health + enforce mode + uptime
/// System health + enforce mode
Status,
/// Recent threat alerts
Alerts {
#[arg(long, default_value = "20")]
limit: u32,
},
/// Add IP to blacklist
/// ML engine status
Ml,
/// Add IP to source blacklist
Block {
ip: String,
#[arg(long, default_value = "1800")]
ttl: u64,
},
/// Remove IP from blacklist
/// Remove IP from source blacklist
Unblock { ip: String },
/// List all ACL rules
Rules,
/// Generate security report
Report {
#[arg(long, default_value = "text")]
format: String,
/// List ACL rules (source blacklist by default)
Rules {
#[arg(long, default_value = "source")]
direction: String,
#[arg(long, default_value = "blacklist")]
list_type: String,
},
/// Generate security report (JSON data)
Report,
/// Get or set enforce mode
Mode {
/// Set mode to "monitor" or "enforce"
@ -48,25 +45,31 @@ enum Commands {
},
/// Authenticate and save JWT
Login,
/// MCP API key management
McpKey {
/// List SOAR active blocks
Blocks,
/// List SOAR playbooks
Playbooks,
/// List SOAR execution history
Executions,
/// API key management
ApiKey {
#[command(subcommand)]
action: McpKeyAction,
action: ApiKeyAction,
},
}
#[derive(Subcommand)]
enum McpKeyAction {
/// Generate a new MCP API key
enum ApiKeyAction {
/// Generate a new API key
Generate {
#[arg(long, default_value = "default")]
name: String,
#[arg(long, default_value = "read_only")]
level: String,
},
/// List all MCP API keys
/// List all API keys
List,
/// Revoke an MCP API key
/// Revoke an API key
Revoke { id: i64 },
}
@ -106,36 +109,36 @@ impl ApiClient {
req = req.header("Authorization", format!("Bearer {}", token.trim()));
}
let resp = req.send().await.map_err(|e| format!("Connection error: {}", e))?;
if resp.status().as_u16() == 401 {
let status = resp.status().as_u16();
if status == 401 {
return Err("Session expired. Run `ng login` to re-authenticate.".into());
}
resp.json().await.map_err(|e| format!("Parse error: {}", e))
let text = resp.text().await.map_err(|e| format!("Read error: {}", e))?;
serde_json::from_str(&text).map_err(|_| format!("Unexpected response (HTTP {}): {}", status, &text[..text.len().min(200)]))
}
async fn post(&self, path: &str, body: Value) -> Result<Value, String> {
async fn request(&self, method: reqwest::Method, path: &str, body: Option<Value>) -> Result<Value, String> {
let url = format!("{}{}", self.base_url, path);
let mut req = self.client.post(&url).json(&body);
let mut req = self.client.request(method, &url);
if let Some(token) = self.load_token() {
req = req.header("Authorization", format!("Bearer {}", token.trim()));
}
let resp = req.send().await.map_err(|e| format!("Connection error: {}", e))?;
if resp.status().as_u16() == 401 {
return Err("Session expired. Run `ng login` to re-authenticate.".into());
}
resp.json().await.map_err(|e| format!("Parse error: {}", e))
}
async fn delete(&self, path: &str) -> Result<Value, String> {
let url = format!("{}{}", self.base_url, path);
let mut req = self.client.delete(&url);
if let Some(token) = self.load_token() {
req = req.header("Authorization", format!("Bearer {}", token.trim()));
if let Some(b) = body {
req = req.json(&b);
}
let resp = req.send().await.map_err(|e| format!("Connection error: {}", e))?;
if resp.status().as_u16() == 401 {
let status = resp.status().as_u16();
if status == 401 {
return Err("Session expired. Run `ng login` to re-authenticate.".into());
}
resp.json().await.map_err(|e| format!("Parse error: {}", e))
let text = resp.text().await.map_err(|e| format!("Read error: {}", e))?;
if text.is_empty() {
if (200..300).contains(&status) {
return Ok(Value::Null);
}
return Err(format!("Empty response (HTTP {})", status));
}
serde_json::from_str(&text).map_err(|_| format!("Unexpected response (HTTP {}): {}", status, &text[..text.len().min(200)]))
}
async fn login(&self, username: &str, password: &str) -> Result<String, String> {
@ -154,42 +157,35 @@ fn dirs_next() -> PathBuf {
PathBuf::from(home).join(".ng")
}
fn format_report_text(data: &Value) -> String {
let mut out = String::new();
out.push_str("=== NetGuardia Security Report ===\n\n");
fn print_json(data: &Value) {
println!("{}", serde_json::to_string_pretty(data).unwrap_or_default());
}
if let Some(obj) = data.as_object() {
for (key, value) in obj {
let label = key.replace('_', " ");
match value {
Value::String(s) => {
out.push_str(&format!("{}: {}\n", label, s));
}
Value::Number(n) => {
out.push_str(&format!("{}: {}\n", label, n));
}
Value::Bool(b) => {
out.push_str(&format!("{}: {}\n", label, b));
}
Value::Array(arr) => {
out.push_str(&format!("{}:\n", label));
for item in arr {
out.push_str(&format!(" - {}\n", item));
}
}
Value::Object(_) => {
out.push_str(&format!("{}:\n{}\n", label, serde_json::to_string_pretty(value).unwrap_or_default()));
}
Value::Null => {
out.push_str(&format!("{}: N/A\n", label));
}
}
}
} else {
out.push_str(&serde_json::to_string_pretty(data).unwrap_or_default());
fn read_password() -> String {
// Disable echo for password input
#[cfg(unix)]
{
use std::os::unix::io::AsRawFd;
let fd = std::io::stdin().as_raw_fd();
let mut termios = unsafe { std::mem::zeroed::<libc::termios>() };
unsafe { libc::tcgetattr(fd, &mut termios) };
let old = termios;
termios.c_lflag &= !libc::ECHO;
unsafe { libc::tcsetattr(fd, libc::TCSANOW, &termios) };
let mut password = String::new();
std::io::stdin().read_line(&mut password).unwrap();
println!(); // newline after hidden input
unsafe { libc::tcsetattr(fd, libc::TCSANOW, &old) };
password.trim().to_string()
}
#[cfg(not(unix))]
{
let mut password = String::new();
std::io::stdin().read_line(&mut password).unwrap();
password.trim().to_string()
}
out
}
#[tokio::main]
@ -199,82 +195,54 @@ async fn main() {
let result = match cli.command {
Commands::Status => {
match api.get("/api/health/status").await {
Ok(data) => {
println!("{}", serde_json::to_string_pretty(&data).unwrap_or_default());
Ok(())
}
Err(e) => Err(e),
}
api.get("/api/health/status").await.map(|d| print_json(&d))
}
// Issue 8: Use limit parameter in alerts query
Commands::Alerts { limit } => {
match api.get(&format!("/api/ml/alerts?limit={}", limit)).await {
Ok(data) => {
println!("{}", serde_json::to_string_pretty(&data).unwrap_or_default());
Ok(())
}
Err(e) => Err(e),
}
Commands::Ml => {
api.get("/api/ml/status").await.map(|d| print_json(&d))
}
// Issue 9: Use ttl parameter in block request body
Commands::Block { ip, ttl } => {
let ip_ver = if ip.contains(':') { 6 } else { 4 };
let body = serde_json::json!({
"ip_version": ip_ver, "direction": "source",
"list_type": "blacklist", "ip_address": ip, "port": 0,
"ttl_secs": ttl
});
match api.post("/api/acl/add", body).await {
Ok(data) => { println!("Blocked: {}", serde_json::to_string(&data).unwrap_or_default()); Ok(()) }
Err(e) => Err(e),
}
Commands::Block { ip } => {
let is_v6 = ip.contains(':');
let ip_ver = if is_v6 { "ipv6" } else { "ipv4" };
let addr = if is_v6 { format!("[{}]:0", ip) } else { format!("{}:0", ip) };
api.request(reqwest::Method::PUT, &format!("/api/acl/{}/source/blacklist", ip_ver), Some(Value::String(addr)))
.await.map(|_| println!("Blocked: {}", ip))
}
Commands::Unblock { ip } => {
let ip_ver = if ip.contains(':') { 6 } else { 4 };
let body = serde_json::json!({
"ip_version": ip_ver, "direction": "source",
"list_type": "blacklist", "ip_address": ip, "port": 0
});
match api.post("/api/acl/delete", body).await {
Ok(data) => { println!("Unblocked: {}", serde_json::to_string(&data).unwrap_or_default()); Ok(()) }
Err(e) => Err(e),
}
let is_v6 = ip.contains(':');
let ip_ver = if is_v6 { "ipv6" } else { "ipv4" };
let addr = if is_v6 { format!("[{}]:0", ip) } else { format!("{}:0", ip) };
api.request(reqwest::Method::DELETE, &format!("/api/acl/{}/source/blacklist", ip_ver), Some(Value::String(addr)))
.await.map(|_| println!("Unblocked: {}", ip))
}
Commands::Rules => {
match api.get("/api/acl/list").await {
Ok(data) => { println!("{}", serde_json::to_string_pretty(&data).unwrap_or_default()); Ok(()) }
Err(e) => Err(e),
Commands::Rules { direction, list_type } => {
// Try both IPv4 and IPv6
let v4 = api.get(&format!("/api/acl/ipv4/{}/{}", direction, list_type)).await;
let v6 = api.get(&format!("/api/acl/ipv6/{}/{}", direction, list_type)).await;
println!("=== IPv4 {} {} ===", direction, list_type);
match v4 {
Ok(d) => print_json(&d),
Err(e) => eprintln!("{}", e),
}
println!("\n=== IPv6 {} {} ===", direction, list_type);
match v6 {
Ok(d) => print_json(&d),
Err(e) => eprintln!("{}", e),
}
Ok(())
}
// Issue 10: Use format parameter for report output
Commands::Report { format } => {
match api.post("/api/report/generate", serde_json::json!({})).await {
Ok(data) => {
if format == "json" {
println!("{}", serde_json::to_string_pretty(&data).unwrap_or_default());
} else {
print!("{}", format_report_text(&data));
}
Ok(())
}
Err(e) => Err(e),
}
Commands::Report => {
// Use /api/report/data for JSON output
api.get("/api/report/data").await.map(|d| print_json(&d))
}
Commands::Mode { mode } => {
match mode {
Some(m) => {
let body = serde_json::json!({"mode": m});
match api.post("/api/system/enforce-mode", body).await {
Ok(data) => { println!("{}", serde_json::to_string_pretty(&data).unwrap_or_default()); Ok(()) }
Err(e) => Err(e),
}
api.request(reqwest::Method::PUT, "/api/system/enforce-mode", Some(body))
.await.map(|d| print_json(&d))
}
None => {
match api.get("/api/system/enforce-mode").await {
Ok(data) => { println!("{}", serde_json::to_string_pretty(&data).unwrap_or_default()); Ok(()) }
Err(e) => Err(e),
}
api.get("/api/system/enforce-mode").await.map(|d| print_json(&d))
}
}
}
@ -285,14 +253,11 @@ async fn main() {
std::io::stdin().read_line(&mut username).unwrap();
let username = username.trim();
// Read password without echo (simple version)
print!("Password: ");
std::io::Write::flush(&mut std::io::stdout()).unwrap();
let mut password = String::new();
std::io::stdin().read_line(&mut password).unwrap();
let password = password.trim();
let password = read_password();
match api.login(username, password).await {
match api.login(username, &password).await {
Ok(token) => {
api.save_token(&token);
println!("Login successful. Token saved to ~/.ng/token");
@ -301,69 +266,62 @@ async fn main() {
Err(e) => Err(e),
}
}
Commands::McpKey { action } => {
Commands::Blocks => {
api.get("/api/soar/blocks").await.map(|d| print_json(&d))
}
Commands::Playbooks => {
api.get("/api/soar/playbooks").await.map(|d| print_json(&d))
}
Commands::Executions => {
api.get("/api/soar/executions").await.map(|d| print_json(&d))
}
Commands::ApiKey { action } => {
match action {
// Issue 13: Generate key via API so it persists
McpKeyAction::Generate { name, level } => {
let body = serde_json::json!({
"name": name,
"level": level,
});
match api.post("/api/mcp-keys/generate", body).await {
Ok(data) => {
ApiKeyAction::Generate { name, level } => {
let body = serde_json::json!({"name": name, "level": level});
api.request(reqwest::Method::POST, "/api/api-keys/generate", Some(body))
.await.map(|data| {
if let Some(key) = data.get("key").and_then(|k| k.as_str()) {
println!("Generated MCP API key: {}", key);
println!("Generated API key: {}", key);
println!("Name: {}, Level: {}", name, level);
println!("Set NETGUARDIA_MCP_KEY={} in your MCP client config", key);
println!("Set NETGUARDIA_API_KEY={} in your client config", key);
} else {
println!("{}", serde_json::to_string_pretty(&data).unwrap_or_default());
print_json(&data);
}
Ok(())
}
Err(e) => Err(e),
}
})
}
// Issue 11: List keys via API
McpKeyAction::List => {
match api.get("/api/mcp-keys").await {
Ok(data) => {
if let Some(keys) = data.as_array() {
if keys.is_empty() {
println!("No MCP keys found.");
} else {
println!("{:<6} {:<20} {:<15} {:<22} Last Used", "ID", "Name", "Level", "Created");
println!("{}", "-".repeat(80));
for key in keys {
println!("{:<6} {:<20} {:<15} {:<22} {}",
key.get("id").and_then(|v| v.as_i64()).unwrap_or(0),
key.get("name").and_then(|v| v.as_str()).unwrap_or("-"),
key.get("permission_level").and_then(|v| v.as_str()).unwrap_or("-"),
key.get("created_at").and_then(|v| v.as_str()).unwrap_or("-"),
key.get("last_used_at").and_then(|v| v.as_str()).unwrap_or("never"),
);
}
ApiKeyAction::List => {
api.get("/api/api-keys").await.map(|data| {
if let Some(keys) = data.as_array() {
if keys.is_empty() {
println!("No API keys found.");
} else {
println!("{:<6} {:<20} {:<15} {:<22} Last Used", "ID", "Name", "Level", "Created");
println!("{}", "-".repeat(80));
for key in keys {
println!("{:<6} {:<20} {:<15} {:<22} {}",
key.get("id").and_then(|v| v.as_i64()).unwrap_or(0),
key.get("name").and_then(|v| v.as_str()).unwrap_or("-"),
key.get("permission_level").and_then(|v| v.as_str()).unwrap_or("-"),
key.get("created_at").and_then(|v| v.as_str()).unwrap_or("-"),
key.get("last_used_at").and_then(|v| v.as_str()).unwrap_or("never"),
);
}
} else {
println!("{}", serde_json::to_string_pretty(&data).unwrap_or_default());
}
Ok(())
} else {
print_json(&data);
}
Err(e) => Err(e),
}
})
}
// Issue 12: Revoke key via API
McpKeyAction::Revoke { id } => {
match api.delete(&format!("/api/mcp-keys/{}", id)).await {
Ok(data) => {
ApiKeyAction::Revoke { id } => {
api.request(reqwest::Method::DELETE, &format!("/api/api-keys/{}", id), None)
.await.map(|data| {
if data.get("deleted").and_then(|v| v.as_bool()).unwrap_or(false) {
println!("Key #{} revoked successfully.", id);
} else {
println!("{}", serde_json::to_string_pretty(&data).unwrap_or_default());
print_json(&data);
}
Ok(())
}
Err(e) => Err(e),
}
})
}
}
}

View File

@ -1,36 +0,0 @@
[Http]
http_server_bind_port = 8080
jwt_expiry_hours = 24
[Network]
ingress_ifname = "ng-ext"
egress_ifname = "ng-int"
combined_queue_count = 1
channel_size = 4096
fill_queue_size = 4096
comp_queue_size = 4096
tx_queue_size = 4096
rx_queue_size = 4096
frame_size = 4096
frame_count = 4096
refresh_interval = 5
[Inference]
deep_autoencoder_name = "deep_autoencoder.onnx"
classifier_name = "classifier.onnx"
models_config_name = "inference_config.json"
max_concurrent_flows = 10000
min_packets_for_inference = 5
inference_interval_secs = 5
aggregator_window_secs = 30
inference_batch_size = 200
traffic_logging_mode = true
traffic_log_csv_path = "traffic_log.csv"
[Misc]
geoip_db_name = "net-guardia/static/geo/GeoLite2-City.mmdb"
database_path = "net-guardia.db"
[Pipeline]
ingress = ["access_control", "rate_limit", "service"]
egress = []

View File

@ -23,6 +23,7 @@ RUN dnf install -y epel-release && \
nodejs24-npm \
m4 \
make pkg-config \
openssl-devel \
&& dnf clean all
# Rust toolchain

View File

@ -18,7 +18,7 @@ WatchdogSec=30
NoNewPrivileges=false
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/opt/netguardia /var/log/netguardia
ReadWritePaths=/opt/netguardia /var/log/netguardia /var/lib/netguardia
PrivateTmp=yes
# Resource limits

View File

@ -27,8 +27,6 @@ autoinstall:
dhcp4: true
packages:
- whiptail
- jq
- curl
- net-tools
- iproute2
@ -43,14 +41,6 @@ autoinstall:
- >-
curtin in-target -- systemctl enable serial-getty@ttyS0.service
user-data:
runcmd:
# Run the setup wizard on first boot if not already configured
- |
if [ ! -f /opt/netguardia/config.toml ]; then
/opt/netguardia/bin/setup-wizard.sh
fi
final_message: |
NetGuardia image provisioning complete.
Run /opt/netguardia/bin/setup-wizard.sh to configure.
The HTTP setup wizard starts automatically on port 8080.

View File

@ -20,14 +20,18 @@ variable "ubuntu_iso_url" {
}
variable "ubuntu_iso_checksum" {
type = string
default = "sha256:none"
description = "SHA-256 checksum of the Ubuntu 24.04 Server ISO. Update before building."
type = string
description = "SHA-256 checksum of the Ubuntu 24.04 Server ISO (e.g. sha256:abcdef...). Must be provided explicitly."
validation {
condition = can(regex("^sha256:[0-9a-fA-F]{64}$", var.ubuntu_iso_checksum))
error_message = "ubuntu_iso_checksum must be a valid SHA-256 checksum in the form 'sha256:<64 hex chars>'. Do not use 'sha256:none'."
}
}
variable "netguardia_binary" {
type = string
default = "../target/release/net-guardia"
type = string
default = "../target/release/net-guardia"
description = "Path to the pre-built NetGuardia binary."
}
@ -43,8 +47,8 @@ variable "ssh_password" {
}
variable "disk_size" {
type = string
default = "20480"
type = string
default = "20480"
description = "Virtual disk size in MB."
}
@ -58,6 +62,17 @@ variable "cpus" {
default = "2"
}
variable "accelerator" {
type = string
default = "kvm"
description = "QEMU accelerator: 'kvm' (default) or 'none' for environments without KVM support."
validation {
condition = contains(["kvm", "none"], var.accelerator)
error_message = "accelerator must be 'kvm' or 'none'."
}
}
# ---------------------------------------------------------------------------
# Source: QEMU (produces QCOW2)
# ---------------------------------------------------------------------------
@ -90,7 +105,7 @@ source "qemu" "netguardia" {
vm_name = "netguardia"
net_device = "virtio-net"
disk_interface = "virtio"
accelerator = "kvm"
accelerator = var.accelerator
}
# ---------------------------------------------------------------------------
@ -138,6 +153,17 @@ build {
"source.virtualbox-iso.netguardia"
]
# ------ KVM fallback warning ------
provisioner "shell" {
inline = [
"if [ '${var.accelerator}' = 'none' ]; then",
" echo '⚠ WARNING: Building without KVM acceleration. This will be significantly slower.'",
" echo '⚠ Set accelerator=kvm for production builds.'",
"fi"
]
}
# ------ Upload artifacts ------
provisioner "file" {
@ -145,48 +171,71 @@ build {
destination = "/tmp/net-guardia"
}
provisioner "file" {
source = "../deploy/scripts/install.sh"
destination = "/tmp/install.sh"
}
provisioner "file" {
source = "../deploy/netguardia.service"
destination = "/tmp/netguardia.service"
}
provisioner "file" {
source = "../deploy/logrotate.conf"
destination = "/tmp/logrotate.conf"
}
provisioner "file" {
source = "../deploy/setup-wizard.sh"
destination = "/tmp/setup-wizard.sh"
}
provisioner "file" {
source = "../deploy/logrotate.conf"
destination = "/tmp/netguardia-logrotate.conf"
}
# ------ Install everything ------
# ------ Debug binary gate ------
provisioner "shell" {
inline = [
"set -ex",
"set -e",
"echo 'Checking binary is not a debug build...'",
"if file /tmp/net-guardia | grep -q 'not stripped'; then",
" echo 'FATAL: Binary is a debug build (not stripped). Use a release build for VM images.'",
" exit 1",
"fi",
"echo 'Binary check passed: stripped release build.'"
]
}
"# Create directories",
"sudo mkdir -p /opt/netguardia/bin",
"sudo mkdir -p /var/log/netguardia",
# ------ Install runtime dependencies (SQLCipher needs OpenSSL) ------
"# Install binary",
"sudo install -m 0755 /tmp/net-guardia /opt/netguardia/bin/net-guardia",
provisioner "shell" {
inline = [
"set -e",
"if command -v apt-get &>/dev/null; then",
" sudo DEBIAN_FRONTEND=noninteractive apt-get install -y libssl3",
"elif command -v dnf &>/dev/null; then",
" sudo dnf install -y openssl-libs",
"fi"
]
}
"# Install systemd unit",
"sudo install -m 0644 /tmp/netguardia.service /etc/systemd/system/netguardia.service",
"sudo systemctl daemon-reload",
"sudo systemctl enable netguardia.service",
# ------ Install via install.sh --local ------
provisioner "shell" {
inline = [
"set -e",
"chmod +x /tmp/install.sh",
"# Lay out deploy dir structure so install.sh can find service/logrotate files",
"sudo mkdir -p /tmp/deploy/scripts",
"cp /tmp/install.sh /tmp/deploy/scripts/install.sh",
"cp /tmp/netguardia.service /tmp/deploy/netguardia.service",
"cp /tmp/logrotate.conf /tmp/deploy/logrotate.conf",
"sudo /tmp/deploy/scripts/install.sh --local /tmp/net-guardia",
"# Install setup wizard",
"sudo install -m 0755 /tmp/setup-wizard.sh /opt/netguardia/bin/setup-wizard.sh",
"# Install logrotate config",
"sudo install -m 0644 /tmp/netguardia-logrotate.conf /etc/logrotate.d/netguardia",
"# Cleanup temp files",
"rm -f /tmp/net-guardia /tmp/netguardia.service /tmp/setup-wizard.sh /tmp/netguardia-logrotate.conf",
"# Configure first-boot setup wizard via rc.local",
"sudo tee /etc/rc.local > /dev/null << 'RCEOF'",
"#!/bin/bash",

134
deploy/scripts/install.sh Executable file
View File

@ -0,0 +1,134 @@
#!/bin/bash
# install.sh — Install NetGuardia on a fresh system.
#
# Usage:
# install.sh # Download from GitHub Release
# install.sh --local /path/to/binary # Use a pre-built local binary
#
set -euo pipefail
# ── Helpers ──────────────────────────────────────────────────────────────────
info() { printf '\033[1;34m[INFO]\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m[WARN]\033[0m %s\n' "$*"; }
fatal() { printf '\033[1;31m[FATAL]\033[0m %s\n' "$*" >&2; exit 1; }
# ── Defaults ─────────────────────────────────────────────────────────────────
LOCAL_BINARY=""
INSTALL_DIR="/opt/netguardia"
BIN_DIR="${INSTALL_DIR}/bin"
DATA_DIR="/var/lib/netguardia"
LOG_DIR="/var/log/netguardia"
SERVICE_USER="netguardia"
SERVICE_GROUP="netguardia"
GITHUB_REPO="dalaw2/NetGuardia"
# ── Parse arguments ──────────────────────────────────────────────────────────
while [[ $# -gt 0 ]]; do
case "$1" in
--local)
[[ -z "${2:-}" ]] && fatal "--local requires a path to the binary"
LOCAL_BINARY="$2"
shift 2
;;
-h|--help)
echo "Usage: $0 [--local /path/to/binary]"
exit 0
;;
*)
fatal "Unknown argument: $1"
;;
esac
done
# ── Validate local binary (if provided) ─────────────────────────────────────
if [[ -n "${LOCAL_BINARY}" ]]; then
[[ -f "${LOCAL_BINARY}" ]] || fatal "Local binary not found: ${LOCAL_BINARY}"
[[ -x "${LOCAL_BINARY}" ]] || fatal "Local binary is not executable: ${LOCAL_BINARY}"
info "Using local binary: ${LOCAL_BINARY}"
fi
# ── Must be root ─────────────────────────────────────────────────────────────
[[ "$(id -u)" -eq 0 ]] || fatal "This script must be run as root"
# ── Install runtime dependencies (SQLCipher needs OpenSSL) ──────────────────
if command -v apt-get &>/dev/null; then
info "Refreshing apt package metadata"
DEBIAN_FRONTEND=noninteractive apt-get update >/dev/null 2>&1 || warn "Could not refresh apt metadata"
info "Installing runtime dependencies (libssl)"
DEBIAN_FRONTEND=noninteractive apt-get install -y libssl3 >/dev/null 2>&1 || warn "Could not install libssl3"
elif command -v dnf &>/dev/null; then
info "Installing runtime dependencies (openssl-libs)"
dnf install -y openssl-libs >/dev/null 2>&1 || warn "Could not install openssl-libs"
fi
# ── Create system user ───────────────────────────────────────────────────────
if ! id "${SERVICE_USER}" &>/dev/null; then
info "Creating system user: ${SERVICE_USER}"
useradd --system --no-create-home --shell /usr/sbin/nologin "${SERVICE_USER}"
fi
# ── Create directories ───────────────────────────────────────────────────────
info "Creating directories"
mkdir -p "${BIN_DIR}" "${DATA_DIR}" "${LOG_DIR}"
chown "${SERVICE_USER}:${SERVICE_GROUP}" "${DATA_DIR}" "${LOG_DIR}"
# ── Obtain the binary ────────────────────────────────────────────────────────
if [[ -n "${LOCAL_BINARY}" ]]; then
# --local mode: skip download and checksum entirely
info "Installing local binary to ${BIN_DIR}/net-guardia"
install -m 0755 "${LOCAL_BINARY}" "${BIN_DIR}/net-guardia"
else
# Download from GitHub Release
info "Fetching latest release from GitHub (${GITHUB_REPO})"
LATEST_TAG=$(curl -fsSL "https://api.github.com/repos/${GITHUB_REPO}/releases/latest" \
| grep '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/')
[[ -n "${LATEST_TAG}" ]] || fatal "Could not determine latest release tag"
info "Latest release: ${LATEST_TAG}"
DOWNLOAD_URL="https://github.com/${GITHUB_REPO}/releases/download/${LATEST_TAG}/net-guardia-linux-amd64"
CHECKSUMS_URL="https://github.com/${GITHUB_REPO}/releases/download/${LATEST_TAG}/SHA256SUMS"
TMPDIR=$(mktemp -d)
trap 'rm -rf "${TMPDIR}"' EXIT
info "Downloading binary"
curl -fSL -o "${TMPDIR}/net-guardia" "${DOWNLOAD_URL}"
info "Downloading SHA256SUMS"
if ! curl -fSL -o "${TMPDIR}/SHA256SUMS" "${CHECKSUMS_URL}"; then
fatal "SHA256SUMS file not found in release — aborting"
fi
info "Verifying checksum"
(cd "${TMPDIR}" && sha256sum -c SHA256SUMS)
install -m 0755 "${TMPDIR}/net-guardia" "${BIN_DIR}/net-guardia"
fi
# ── Install systemd unit ─────────────────────────────────────────────────────
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
DEPLOY_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
if [[ -f "${DEPLOY_DIR}/netguardia.service" ]]; then
info "Installing systemd unit"
install -m 0644 "${DEPLOY_DIR}/netguardia.service" /etc/systemd/system/netguardia.service
systemctl daemon-reload
systemctl enable netguardia.service
else
warn "netguardia.service not found at ${DEPLOY_DIR}/netguardia.service — skipping"
fi
# ── Install logrotate config ─────────────────────────────────────────────────
if [[ -f "${DEPLOY_DIR}/logrotate.conf" ]]; then
info "Installing logrotate config"
install -m 0644 "${DEPLOY_DIR}/logrotate.conf" /etc/logrotate.d/netguardia
else
warn "logrotate.conf not found — skipping"
fi
# ── Done ─────────────────────────────────────────────────────────────────────
info "NetGuardia installed successfully"
info " Binary: ${BIN_DIR}/net-guardia"
info " Data: ${DATA_DIR}"
info " Logs: ${LOG_DIR}"
info " Service: systemctl start netguardia"

View File

@ -1,313 +0,0 @@
#!/usr/bin/env bash
#
# NetGuardia Interactive Setup Wizard
# Uses whiptail (falls back to dialog) for interactive configuration.
#
set -euo pipefail
# ---------------------------------------------------------------------------
# Globals
# ---------------------------------------------------------------------------
readonly LOG_DIR="/var/log/netguardia"
readonly LOG_FILE="${LOG_DIR}/setup.log"
readonly CONFIG_DIR="/opt/netguardia"
readonly CONFIG_FILE="${CONFIG_DIR}/config.toml"
readonly PASSWORD_FLAG="${CONFIG_DIR}/.admin_password_set"
readonly BACKTITLE="NetGuardia Setup Wizard"
DIALOG=""
INGRESS_NIC=""
EGRESS_NIC=""
NET_MODE=""
STATIC_IP=""
STATIC_MASK=""
STATIC_GW=""
ADMIN_PASS=""
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
log() {
local ts
ts="$(date '+%Y-%m-%d %H:%M:%S')"
echo "[${ts}] $*" >> "${LOG_FILE}"
}
die() {
log "FATAL: $*"
if [[ -n "${DIALOG}" ]]; then
"${DIALOG}" --backtitle "${BACKTITLE}" --title "Error" \
--msgbox "Setup failed:\n\n$*\n\nSee ${LOG_FILE} for details." 12 60
else
echo "FATAL: $*" >&2
fi
exit 1
}
ensure_root() {
if [[ "$(id -u)" -ne 0 ]]; then
die "This script must be run as root."
fi
}
init_logging() {
mkdir -p "${LOG_DIR}"
touch "${LOG_FILE}"
chmod 0640 "${LOG_FILE}"
log "=== NetGuardia setup wizard started ==="
}
detect_dialog() {
if command -v whiptail &>/dev/null; then
DIALOG="whiptail"
elif command -v dialog &>/dev/null; then
DIALOG="dialog"
else
die "Neither whiptail nor dialog is installed. Install whiptail and retry."
fi
log "Using dialog frontend: ${DIALOG}"
}
# ---------------------------------------------------------------------------
# Step 1 & 2: Detect and select NICs
# ---------------------------------------------------------------------------
get_interfaces() {
local -a ifaces=()
for iface in /sys/class/net/*; do
local name
name="$(basename "${iface}")"
[[ "${name}" == "lo" ]] && continue
ifaces+=("${name}")
done
if [[ ${#ifaces[@]} -lt 2 ]]; then
die "At least 2 network interfaces are required (found ${#ifaces[@]}). Connect additional NICs and retry."
fi
# Build menu items: "name description"
local -a menu_items=()
for name in "${ifaces[@]}"; do
local mac state
mac="$(cat "/sys/class/net/${name}/address" 2>/dev/null || echo "unknown")"
state="$(cat "/sys/class/net/${name}/operstate" 2>/dev/null || echo "unknown")"
menu_items+=("${name}" "MAC=${mac} state=${state}")
done
# Select ingress NIC
INGRESS_NIC=$("${DIALOG}" --backtitle "${BACKTITLE}" \
--title "Step 1: Select Ingress (External) NIC" \
--menu "Choose the network interface facing the untrusted/external network:" \
20 70 10 "${menu_items[@]}" 3>&1 1>&2 2>&3) || die "Ingress NIC selection cancelled."
log "Ingress NIC selected: ${INGRESS_NIC}"
# Build egress menu (exclude the chosen ingress NIC)
local -a egress_items=()
for ((i = 0; i < ${#menu_items[@]}; i += 2)); do
[[ "${menu_items[i]}" == "${INGRESS_NIC}" ]] && continue
egress_items+=("${menu_items[i]}" "${menu_items[i+1]}")
done
EGRESS_NIC=$("${DIALOG}" --backtitle "${BACKTITLE}" \
--title "Step 2: Select Egress (Internal) NIC" \
--menu "Choose the network interface facing the trusted/internal network:" \
20 70 10 "${egress_items[@]}" 3>&1 1>&2 2>&3) || die "Egress NIC selection cancelled."
log "Egress NIC selected: ${EGRESS_NIC}"
}
# ---------------------------------------------------------------------------
# Step 3: Configure network mode
# ---------------------------------------------------------------------------
configure_network() {
NET_MODE=$("${DIALOG}" --backtitle "${BACKTITLE}" \
--title "Step 3: Network Configuration" \
--menu "How should the management IP be configured?" \
12 60 2 \
"dhcp" "Automatic (DHCP)" \
"static" "Manual (Static IP)" \
3>&1 1>&2 2>&3) || die "Network configuration cancelled."
log "Network mode: ${NET_MODE}"
if [[ "${NET_MODE}" == "static" ]]; then
STATIC_IP=$("${DIALOG}" --backtitle "${BACKTITLE}" \
--title "Static IP Address" \
--inputbox "Enter the management IP address (e.g. 192.168.1.10):" \
10 60 "" 3>&1 1>&2 2>&3) || die "Static IP entry cancelled."
STATIC_MASK=$("${DIALOG}" --backtitle "${BACKTITLE}" \
--title "Subnet Mask" \
--inputbox "Enter the subnet prefix length (e.g. 24):" \
10 60 "24" 3>&1 1>&2 2>&3) || die "Subnet mask entry cancelled."
STATIC_GW=$("${DIALOG}" --backtitle "${BACKTITLE}" \
--title "Default Gateway" \
--inputbox "Enter the default gateway (e.g. 192.168.1.1):" \
10 60 "" 3>&1 1>&2 2>&3) || die "Gateway entry cancelled."
log "Static config: ip=${STATIC_IP}/${STATIC_MASK} gw=${STATIC_GW}"
fi
}
# ---------------------------------------------------------------------------
# Step 4: Set admin password flag
# ---------------------------------------------------------------------------
set_admin_password() {
while true; do
ADMIN_PASS=$("${DIALOG}" --backtitle "${BACKTITLE}" \
--title "Step 4: Admin Password" \
--passwordbox "Set the initial admin password (min 8 characters):" \
10 60 "" 3>&1 1>&2 2>&3) || die "Password entry cancelled."
if [[ ${#ADMIN_PASS} -lt 8 ]]; then
"${DIALOG}" --backtitle "${BACKTITLE}" --title "Invalid Password" \
--msgbox "Password must be at least 8 characters. Please try again." 8 50
continue
fi
local confirm
confirm=$("${DIALOG}" --backtitle "${BACKTITLE}" \
--title "Confirm Password" \
--passwordbox "Re-enter the admin password:" \
10 60 "" 3>&1 1>&2 2>&3) || die "Password confirmation cancelled."
if [[ "${ADMIN_PASS}" != "${confirm}" ]]; then
"${DIALOG}" --backtitle "${BACKTITLE}" --title "Mismatch" \
--msgbox "Passwords do not match. Please try again." 8 50
continue
fi
break
done
# Write flag file; actual password is set on first web login.
echo "password_pending" > "${PASSWORD_FLAG}"
chmod 0600 "${PASSWORD_FLAG}"
log "Admin password flag written to ${PASSWORD_FLAG}"
}
# ---------------------------------------------------------------------------
# Step 5: Generate config.toml
# ---------------------------------------------------------------------------
generate_config() {
log "Generating ${CONFIG_FILE}"
mkdir -p "${CONFIG_DIR}"
local bind_port=8080
cat > "${CONFIG_FILE}" <<TOML
[Http]
http_server_bind_port = ${bind_port}
jwt_expiry_hours = 24
[Network]
ingress_ifname = "${INGRESS_NIC}"
egress_ifname = "${EGRESS_NIC}"
combined_queue_count = 16
channel_size = 4096
fill_queue_size = 4096
comp_queue_size = 4096
tx_queue_size = 4096
rx_queue_size = 4096
frame_size = 4096
frame_count = 4096
refresh_interval = 5
[Inference]
deep_autoencoder_name = "deep_autoencoder.onnx"
classifier_name = "classifier.onnx"
models_config_name = "inference_config.json"
max_concurrent_flows = 10000
min_packets_for_inference = 5
inference_interval_secs = 5
aggregator_window_secs = 30
inference_batch_size = 200
traffic_logging_mode = true
traffic_log_csv_path = "traffic_log.csv"
[Misc]
geoip_db_name = "net-guardia/static/geo/GeoLite2-City.mmdb"
database_path = "net-guardia.db"
license_file = "license.key"
[Pipeline]
ingress = ["access_control", "rate_limit", "service"]
egress = []
TOML
# Append static network config as a comment block for reference
if [[ "${NET_MODE}" == "static" ]]; then
cat >> "${CONFIG_FILE}" <<TOML
# Management network (static)
# ip = "${STATIC_IP}/${STATIC_MASK}"
# gateway = "${STATIC_GW}"
TOML
fi
chmod 0644 "${CONFIG_FILE}"
log "Config written to ${CONFIG_FILE}"
}
# ---------------------------------------------------------------------------
# Step 6: Start systemd service
# ---------------------------------------------------------------------------
start_service() {
log "Enabling and starting netguardia.service"
systemctl daemon-reload
systemctl enable netguardia.service
systemctl start netguardia.service
# Brief wait then check status
sleep 2
if systemctl is-active --quiet netguardia.service; then
log "netguardia.service is active"
else
die "netguardia.service failed to start. Check 'journalctl -u netguardia' for details."
fi
}
# ---------------------------------------------------------------------------
# Step 7: Display dashboard URL
# ---------------------------------------------------------------------------
show_dashboard_url() {
local mgmt_ip
if [[ "${NET_MODE}" == "static" ]]; then
mgmt_ip="${STATIC_IP}"
else
# Try to resolve the current IP on the egress interface
mgmt_ip=$(ip -4 addr show "${EGRESS_NIC}" 2>/dev/null \
| grep -oP 'inet \K[0-9.]+' | head -1)
if [[ -z "${mgmt_ip}" ]]; then
mgmt_ip="<this-host-ip>"
fi
fi
local url="http://${mgmt_ip}:8080"
"${DIALOG}" --backtitle "${BACKTITLE}" \
--title "Setup Complete" \
--msgbox "NetGuardia is running!\n\nDashboard: ${url}\n\nLog in with the admin account.\nYou will set your password on first login.\n\nSetup log: ${LOG_FILE}" \
14 60
log "Setup complete. Dashboard URL: ${url}"
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
main() {
ensure_root
init_logging
detect_dialog
get_interfaces
configure_network
set_admin_password
generate_config
start_service
show_dashboard_url
log "=== NetGuardia setup wizard finished ==="
}
main "$@"

View File

@ -15,7 +15,7 @@ struct Args {
#[arg(long, default_value = "http://127.0.0.1:8080")]
api_url: String,
/// API key for authentication (prefer NETGUARDIA_MCP_KEY env var)
/// API key for authentication (prefer NETGUARDIA_API_KEY env var)
#[arg(long)]
api_key: Option<String>,
}
@ -97,12 +97,12 @@ impl McpServer {
{ "name": "get_stats", "description": "Traffic statistics summary", "inputSchema": { "type": "object", "properties": {} } },
{ "name": "list_alerts", "description": "Recent threat alerts with details", "inputSchema": { "type": "object", "properties": { "limit": { "type": "integer", "default": 20 } } } },
{ "name": "list_blocked_ips", "description": "Currently blocked IPs (manual + auto)", "inputSchema": { "type": "object", "properties": {} } },
{ "name": "get_geo_stats", "description": "GeoIP traffic breakdown", "inputSchema": { "type": "object", "properties": {} } },
{ "name": "get_geo_stats", "description": "List GeoIP blocked countries", "inputSchema": { "type": "object", "properties": {} } },
{ "name": "get_flow_summary", "description": "Top talkers, protocols, ports", "inputSchema": { "type": "object", "properties": {} } },
{ "name": "get_enforce_mode", "description": "Current mode (monitor/enforce)", "inputSchema": { "type": "object", "properties": {} } },
{ "name": "list_playbooks", "description": "SOAR playbook configurations", "inputSchema": { "type": "object", "properties": {} } },
{ "name": "generate_report", "description": "Generate security summary report", "inputSchema": { "type": "object", "properties": {} } },
{ "name": "block_ip", "description": "Add IP to blacklist", "inputSchema": { "type": "object", "properties": { "ip": { "type": "string" }, "ttl_secs": { "type": "integer", "default": 1800 } }, "required": ["ip"] } },
{ "name": "block_ip", "description": "Add IP to blacklist", "inputSchema": { "type": "object", "properties": { "ip": { "type": "string" } }, "required": ["ip"] } },
{ "name": "unblock_ip", "description": "Remove IP from blacklist", "inputSchema": { "type": "object", "properties": { "ip": { "type": "string" } }, "required": ["ip"] } },
{ "name": "set_enforce_mode", "description": "Toggle monitor/enforce mode", "inputSchema": { "type": "object", "properties": { "mode": { "type": "string", "enum": ["monitor", "enforce"] } }, "required": ["mode"] } },
{ "name": "add_dns_filter", "description": "Add domain to DNS blacklist", "inputSchema": { "type": "object", "properties": { "domain": { "type": "string" } }, "required": ["domain"] } },
@ -122,52 +122,41 @@ impl McpServer {
let tool_name = params.get("name").and_then(|n| n.as_str()).unwrap_or("");
let arguments = params.get("arguments").cloned().unwrap_or(Value::Object(Default::default()));
let (method, path, body) = match tool_name {
"get_health" => ("GET", "/api/health/status", None),
"get_stats" => ("GET", "/api/stats/summary", None),
"list_alerts" => ("GET", "/api/ml/alerts", None),
"list_blocked_ips" => ("GET", "/api/soar/blocks", None),
"get_geo_stats" => ("GET", "/api/stats/geo", None),
"get_flow_summary" => ("GET", "/api/stats/flows", None),
"get_enforce_mode" => ("GET", "/api/system/enforce-mode", None),
"list_playbooks" => ("GET", "/api/soar/playbooks", None),
"generate_report" => ("POST", "/api/report/generate", None),
let (method, path, body): (&str, String, Option<Value>) = match tool_name {
"get_health" => ("GET", "/api/health/status".into(), None),
"get_stats" => ("GET", "/api/stats/summary".into(), None),
"list_alerts" => ("GET", "/api/soar/executions".into(), None),
"list_blocked_ips" => ("GET", "/api/soar/blocks".into(), None),
"get_geo_stats" => ("GET", "/api/acl/geo/blocked".into(), None),
"get_flow_summary" => ("GET", "/api/stats/flows".into(), None),
"get_enforce_mode" => ("GET", "/api/system/enforce-mode".into(), None),
"list_playbooks" => ("GET", "/api/soar/playbooks".into(), None),
"generate_report" => ("POST", "/api/report/generate".into(), None),
"block_ip" => {
let ip = arguments.get("ip").and_then(|v| v.as_str()).unwrap_or("");
let body = serde_json::json!({
"ip_version": if ip.contains(':') { 6 } else { 4 },
"direction": "source",
"list_type": "blacklist",
"ip_address": ip,
"port": 0
});
("POST", "/api/acl/add", Some(body))
let is_v6 = ip.contains(':');
let ip_ver = if is_v6 { "ipv6" } else { "ipv4" };
let addr = if is_v6 { format!("[{}]:0", ip) } else { format!("{}:0", ip) };
("PUT", format!("/api/acl/{}/source/blacklist", ip_ver), Some(Value::String(addr)))
}
"unblock_ip" => {
let ip = arguments.get("ip").and_then(|v| v.as_str()).unwrap_or("");
let body = serde_json::json!({
"ip_version": if ip.contains(':') { 6 } else { 4 },
"direction": "source",
"list_type": "blacklist",
"ip_address": ip,
"port": 0
});
("POST", "/api/acl/delete", Some(body))
let is_v6 = ip.contains(':');
let ip_ver = if is_v6 { "ipv6" } else { "ipv4" };
let addr = if is_v6 { format!("[{}]:0", ip) } else { format!("{}:0", ip) };
("DELETE", format!("/api/acl/{}/source/blacklist", ip_ver), Some(Value::String(addr)))
}
"set_enforce_mode" => {
let mode = arguments.get("mode").and_then(|v| v.as_str()).unwrap_or("monitor");
let body = serde_json::json!({"mode": mode});
("POST", "/api/system/enforce-mode", Some(body))
("PUT", "/api/system/enforce-mode".into(), Some(serde_json::json!({"mode": mode})))
}
"add_dns_filter" => {
let domain = arguments.get("domain").and_then(|v| v.as_str()).unwrap_or("");
let body = serde_json::json!({"domain": domain});
("POST", "/api/filter/dns/add", Some(body))
("PUT", "/api/filter/dns/blacklist".into(), Some(serde_json::json!({"domains": [domain]})))
}
"add_geo_block" => {
let code = arguments.get("country_code").and_then(|v| v.as_str()).unwrap_or("");
let body = serde_json::json!({"codes": [code]});
("POST", "/api/acl/geo/block", Some(body))
("PUT", "/api/acl/geo/block".into(), Some(serde_json::json!({"country_codes": [code]})))
}
_ => {
return JsonRpcResponse {
@ -181,6 +170,8 @@ impl McpServer {
let url = format!("{}{}", self.api_url, path);
let mut req_builder = match method {
"PUT" => self.client.put(&url),
"DELETE" => self.client.delete(&url),
"POST" => self.client.post(&url),
_ => self.client.get(&url),
};
@ -237,9 +228,9 @@ async fn main() {
let args = Args::parse();
let api_key = args.api_key
.or_else(|| std::env::var("NETGUARDIA_MCP_KEY").ok())
.or_else(|| std::env::var("NETGUARDIA_API_KEY").ok())
.unwrap_or_else(|| {
eprintln!("Error: No API key provided. Set NETGUARDIA_MCP_KEY env var or use --api-key flag.");
eprintln!("Error: No API key provided. Set NETGUARDIA_API_KEY env var or use --api-key flag.");
std::process::exit(1);
});

@ -1 +1 @@
Subproject commit c651241916f82fcb5df4f60ddda9c46bd06fc6e0
Subproject commit 71d2d7f2d53f4afe6510b3018227aa5e28d97476

1
net-guardia-trainer Submodule

@ -0,0 +1 @@
Subproject commit 1f5cbb8b9ba69a5bd16cc15055c230715d6bb9ae

View File

@ -61,12 +61,15 @@ sysinfo = { workspace = true }
maxminddb = { workspace = true }
ipnetwork = { workspace = true }
lru = { workspace = true }
rusqlite = { workspace = true }
rusqlite = { version = "0.34", features = ["bundled-sqlcipher"] }
r2d2 = "0.8"
r2d2_sqlite = "0.27"
jsonwebtoken = { workspace = true }
argon2 = { workspace = true }
sha2 = "0.10"
aes-gcm = "0.10"
hkdf = "0.12"
base64 = { workspace = true }
sd-notify = "0.4"
rand = { workspace = true }

View File

@ -63,8 +63,7 @@ fn build_ebpf_package(package_name: &str, target_subdir: &str) {
// Find bpf-linker once, pass its path to the subprocess explicitly.
let bpf_linker = find_bpf_linker();
let bpf_linker_str = bpf_linker.to_str()
.expect("bpf-linker path is not valid UTF-8");
let bpf_linker_str = bpf_linker.to_str().expect("bpf-linker path is not valid UTF-8");
let Package { manifest_path, .. } = ebpf_package;
let ebpf_dir = manifest_path.parent().unwrap();
@ -211,8 +210,7 @@ fn build_frontend() {
return;
}
let npm = which::which("npm")
.unwrap_or_else(|_| panic!("npm not found in PATH. Install Node.js first."));
let npm = which::which("npm").unwrap_or_else(|_| panic!("npm not found in PATH. Install Node.js first."));
let status = Command::new(&npm)
.args(["install", "--include=optional"])
@ -223,8 +221,7 @@ fn build_frontend() {
panic!("npm install failed with exit code: {:?}", status.code());
}
let npx = which::which("npx")
.unwrap_or_else(|_| panic!("npx not found in PATH. Install Node.js first."));
let npx = which::which("npx").unwrap_or_else(|_| panic!("npx not found in PATH. Install Node.js first."));
let status = Command::new(&npx)
.args(["vite", "build"])
@ -248,7 +245,11 @@ fn build_frontend() {
emit_rerun_if_changed_recursive(&static_dir);
}
fn needs_frontend_rebuild(frontend_dir: &std::path::Path, out_dir: &std::path::Path, static_dir: &std::path::Path) -> bool {
fn needs_frontend_rebuild(
frontend_dir: &std::path::Path,
out_dir: &std::path::Path,
static_dir: &std::path::Path,
) -> bool {
if !out_dir.exists() || !static_dir.exists() {
return true;
}
@ -264,8 +265,12 @@ fn needs_frontend_rebuild(frontend_dir: &std::path::Path, out_dir: &std::path::P
};
let essential_items = [
"src", "public", "package.json", "vite.config.ts",
"tsconfig.json", "package-lock.json",
"src",
"public",
"package.json",
"vite.config.ts",
"tsconfig.json",
"package-lock.json",
];
for item_name in essential_items {

View File

@ -23,9 +23,9 @@ impl EbpfAccessControlAdapter {
#[async_trait]
impl AccessControlPort for EbpfAccessControlAdapter {
async fn block_ip(&self, ip: &str) -> Result<(), Error> {
let addr: IpAddr = ip.parse().map_err(|_| {
Error::from(crate::model::error::ebpf::EbpfError::InvalidIpAddress { ip: ip.to_string() })
})?;
let addr: IpAddr = ip
.parse()
.map_err(|_| Error::from(crate::model::error::ebpf::EbpfError::InvalidIpAddress { ip: ip.to_string() }))?;
match addr {
IpAddr::V4(v4) => {
let socket = SocketAddrV4::new(v4, 0);
@ -43,9 +43,9 @@ impl AccessControlPort for EbpfAccessControlAdapter {
}
async fn unblock_ip(&self, ip: &str) -> Result<(), Error> {
let addr: IpAddr = ip.parse().map_err(|_| {
Error::from(crate::model::error::ebpf::EbpfError::InvalidIpAddress { ip: ip.to_string() })
})?;
let addr: IpAddr = ip
.parse()
.map_err(|_| Error::from(crate::model::error::ebpf::EbpfError::InvalidIpAddress { ip: ip.to_string() }))?;
match addr {
IpAddr::V4(v4) => {
let socket = SocketAddrV4::new(v4, 0);

View File

@ -1,6 +1,6 @@
use std::net::{SocketAddrV4, SocketAddrV6};
use actix_web::{web, HttpResponse, Responder, Scope};
use actix_web::{HttpResponse, Responder, Scope, web};
use serde::Deserialize;
use crate::core::acl_service::AclService;
@ -25,19 +25,13 @@ pub fn initialize() -> Scope {
.route("/geo/unblock", web::delete().to(unblock_geo_countries))
}
async fn get_ipv4_list(
path: web::Path<(FlowDirection, ListType)>,
acl: web::Data<AclService>,
) -> impl Responder {
async fn get_ipv4_list(path: web::Path<(FlowDirection, ListType)>, acl: web::Data<AclService>) -> impl Responder {
let (direction, list_type) = path.into_inner();
let list = acl.access_control().get_ipv4_list(direction, list_type).await;
HttpResponse::Ok().json(list)
}
async fn get_ipv6_list(
path: web::Path<(FlowDirection, ListType)>,
acl: web::Data<AclService>,
) -> impl Responder {
async fn get_ipv6_list(path: web::Path<(FlowDirection, ListType)>, acl: web::Data<AclService>) -> impl Responder {
let (direction, list_type) = path.into_inner();
let list = acl.access_control().get_ipv6_list(direction, list_type).await;
HttpResponse::Ok().json(list)
@ -95,32 +89,24 @@ async fn get_geo_blocked(acl: web::Data<AclService>) -> impl Responder {
HttpResponse::Ok().json(serde_json::json!({"blocked_countries": acl.get_blocked_countries()}))
}
async fn block_geo_countries(
body: web::Json<CountryCodesRequest>,
acl: web::Data<AclService>,
) -> impl Responder {
async fn block_geo_countries(body: web::Json<CountryCodesRequest>, acl: web::Data<AclService>) -> impl Responder {
let codes = body.into_inner().country_codes;
match acl.block_geo_countries(&codes) {
Ok(total_prefixes) => HttpResponse::Ok().json(serde_json::json!({
"blocked_countries": acl.get_blocked_countries(),
"total_prefixes": total_prefixes,
})),
Err(e) => HttpResponse::InternalServerError()
.json(serde_json::json!({"error": e.to_string()})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
}
async fn unblock_geo_countries(
body: web::Json<CountryCodesRequest>,
acl: web::Data<AclService>,
) -> impl Responder {
async fn unblock_geo_countries(body: web::Json<CountryCodesRequest>, acl: web::Data<AclService>) -> impl Responder {
let codes = body.into_inner().country_codes;
match acl.unblock_geo_countries(&codes) {
Ok(total_prefixes) => HttpResponse::Ok().json(serde_json::json!({
"blocked_countries": acl.get_blocked_countries(),
"total_prefixes": total_prefixes,
})),
Err(e) => HttpResponse::InternalServerError()
.json(serde_json::json!({"error": e.to_string()})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
}

View File

@ -1,31 +1,31 @@
use actix_web::{web, HttpResponse, Scope};
use actix_web::{HttpResponse, Scope, web};
use serde::Deserialize;
use crate::adapter::persistence::Database;
use crate::core::auth::extractor::AuthClaims;
use crate::interface::port::api_key::ApiKeyPort;
pub fn initialize() -> Scope {
web::scope("/mcp-keys")
web::scope("/api-keys")
.route("", web::get().to(list_keys))
.route("/generate", web::post().to(generate_key))
.route("/{id}", web::delete().to(delete_key))
}
async fn list_keys(
_auth: AuthClaims,
db: web::Data<Database>,
) -> HttpResponse {
match db.list_mcp_keys() {
async fn list_keys(_auth: AuthClaims, db: web::Data<dyn ApiKeyPort>) -> HttpResponse {
match db.list_api_keys() {
Ok(keys) => {
let responses: Vec<serde_json::Value> = keys.into_iter().map(|(id, name, level, created, last_used)| {
serde_json::json!({
"id": id,
"name": name,
"permission_level": level,
"created_at": created,
"last_used_at": last_used,
let responses: Vec<serde_json::Value> = keys
.into_iter()
.map(|(id, name, level, created, last_used)| {
serde_json::json!({
"id": id,
"name": name,
"permission_level": level,
"created_at": created,
"last_used_at": last_used,
})
})
}).collect();
.collect();
HttpResponse::Ok().json(responses)
}
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
@ -40,7 +40,7 @@ struct GenerateKeyRequest {
async fn generate_key(
_auth: AuthClaims,
db: web::Data<Database>,
db: web::Data<dyn ApiKeyPort>,
body: web::Json<GenerateKeyRequest>,
) -> HttpResponse {
use rand::Rng;
@ -52,7 +52,7 @@ async fn generate_key(
.map(char::from)
.collect();
use sha2::{Sha256, Digest};
use sha2::{Digest, Sha256};
let key_hash = {
let mut hasher = Sha256::new();
hasher.update(raw_key.as_bytes());
@ -60,27 +60,26 @@ async fn generate_key(
};
let level = body.level.as_deref().unwrap_or("read_only");
if !matches!(level, "read_only" | "read_write" | "full_access") {
return HttpResponse::BadRequest().json(serde_json::json!({
"error": "Invalid permission level. Must be: read_only, read_write, or full_access"
}));
}
match db.insert_mcp_key(&key_hash, &body.name, level) {
Ok(id) => {
HttpResponse::Created().json(serde_json::json!({
"id": id,
"key": raw_key,
"name": body.name,
"permission_level": level,
}))
}
match db.insert_api_key(&key_hash, &body.name, level) {
Ok(id) => HttpResponse::Created().json(serde_json::json!({
"id": id,
"key": raw_key,
"name": body.name,
"permission_level": level,
})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
}
async fn delete_key(
_auth: AuthClaims,
db: web::Data<Database>,
path: web::Path<i64>,
) -> HttpResponse {
async fn delete_key(_auth: AuthClaims, db: web::Data<dyn ApiKeyPort>, path: web::Path<i64>) -> HttpResponse {
let id = path.into_inner();
match db.delete_mcp_key(id) {
match db.delete_api_key(id) {
Ok(true) => HttpResponse::Ok().json(serde_json::json!({"deleted": true})),
Ok(false) => HttpResponse::NotFound().json(serde_json::json!({"error": "Key not found"})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),

View File

@ -0,0 +1,29 @@
use actix_web::{HttpResponse, Scope, web};
use crate::adapter::persistence::Database;
use crate::core::auth::extractor::AuthClaims;
pub fn initialize() -> Scope {
web::scope("/audit").route("", web::get().to(list_audit_logs))
}
async fn list_audit_logs(_auth: AuthClaims, db: web::Data<Database>) -> HttpResponse {
match db.list_audit_logs() {
Ok(entries) => {
let json: Vec<serde_json::Value> = entries
.into_iter()
.map(|e| {
serde_json::json!({
"id": e.id,
"actor": e.actor,
"action": e.action,
"detail": e.detail,
"created_at": e.created_at,
})
})
.collect();
HttpResponse::Ok().json(json)
}
Err(_) => HttpResponse::Ok().json(serde_json::json!([])),
}
}

View File

@ -1,4 +1,4 @@
use actix_web::{web, HttpResponse, Responder, Scope};
use actix_web::{HttpResponse, Responder, Scope, web};
use macros::log;
use serde::Deserialize;
@ -69,21 +69,16 @@ fn validate_password(password: &str) -> Result<(), &'static str> {
/// so the response time is indistinguishable from a real user lookup.
const DUMMY_HASH: &str = "$argon2id$v=19$m=19456,t=2,p=1$dW5rbm93bg$QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUE";
async fn login(
body: web::Json<LoginRequest>,
db: web::Data<Repo>,
jwt: web::Data<JwtService>,
) -> impl Responder {
async fn login(body: web::Json<LoginRequest>, db: web::Data<Repo>, jwt: web::Data<JwtService>) -> impl Responder {
let req = body.into_inner();
// Check login lockout
match db.check_login_locked(&req.username) {
Ok(Some(remaining_secs)) => {
return HttpResponse::TooManyRequests()
.json(serde_json::json!({
"error": "Account temporarily locked due to too many failed login attempts",
"retry_after_secs": remaining_secs,
}));
return HttpResponse::TooManyRequests().json(serde_json::json!({
"error": "Account temporarily locked due to too many failed login attempts",
"retry_after_secs": remaining_secs,
}));
}
Err(_) => {}
Ok(None) => {}
@ -97,8 +92,7 @@ async fn login(
if let Err(e) = db.record_login_failure(&req.username) {
log!(AuthError::LoginFailureTrackingError(e));
}
return HttpResponse::Unauthorized()
.json(serde_json::json!({"error": "Invalid credentials"}));
return HttpResponse::Unauthorized().json(serde_json::json!({"error": "Invalid credentials"}));
}
};
@ -110,8 +104,7 @@ async fn login(
if let Err(e) = db.record_login_failure(&req.username) {
log!(AuthError::LoginFailureTrackingError(e));
}
return HttpResponse::Unauthorized()
.json(serde_json::json!({"error": "Invalid credentials"}));
return HttpResponse::Unauthorized().json(serde_json::json!({"error": "Invalid credentials"}));
}
}
@ -137,16 +130,11 @@ async fn login(
"role": role,
"force_password_change": force_password_change,
})),
Err(_) => HttpResponse::InternalServerError()
.json(serde_json::json!({"error": "Failed to create token"})),
Err(_) => HttpResponse::InternalServerError().json(serde_json::json!({"error": "Failed to create token"})),
}
}
async fn register(
auth: AuthClaims,
body: web::Json<RegisterRequest>,
db: web::Data<Repo>,
) -> impl Responder {
async fn register(auth: AuthClaims, body: web::Json<RegisterRequest>, db: web::Data<Repo>) -> impl Responder {
let reg = body.into_inner();
// Validate input
@ -159,8 +147,7 @@ async fn register(
// Validate role
if reg.role != "admin" && reg.role != "viewer" {
return HttpResponse::BadRequest()
.json(serde_json::json!({"error": "Role must be 'admin' or 'viewer'"}));
return HttpResponse::BadRequest().json(serde_json::json!({"error": "Role must be 'admin' or 'viewer'"}));
}
// Only admins can create admin accounts
@ -172,8 +159,7 @@ async fn register(
let hash = match password::hash_password(&reg.password) {
Ok(h) => h,
Err(_) => {
return HttpResponse::InternalServerError()
.json(serde_json::json!({"error": "Failed to hash password"}));
return HttpResponse::InternalServerError().json(serde_json::json!({"error": "Failed to hash password"}));
}
};
@ -182,23 +168,22 @@ async fn register(
// Auto-assign to default group based on role
let default_group_name = if reg.role == "admin" { "Administrator" } else { "Viewer" };
if let Ok(groups) = db.list_user_groups()
&& let Some((group_id, _, _, _, _)) = groups.into_iter().find(|(_, name, _, _, _)| name == default_group_name)
&& let Some((group_id, _, _, _, _)) =
groups.into_iter().find(|(_, name, _, _, _)| name == default_group_name)
&& let Err(e) = db.set_user_groups(new_user_id, &[group_id])
{
log!(AuthError::GroupAssignmentFailed(e));
}
HttpResponse::Created()
.json(serde_json::json!({"username": reg.username, "role": reg.role}))
}
Err(e) => {
HttpResponse::Conflict().json(serde_json::json!({"error": e.to_string()}))
HttpResponse::Created().json(serde_json::json!({"username": reg.username, "role": reg.role}))
}
Err(e) => HttpResponse::Conflict().json(serde_json::json!({"error": e.to_string()})),
}
}
async fn me(auth: AuthClaims, db: web::Data<Repo>) -> impl Responder {
let user_groups = db.get_user_groups(auth.sub).unwrap_or_default();
let group_names: Vec<String> = user_groups.iter()
let group_names: Vec<String> = user_groups
.iter()
.map(|(_id, name, _desc, _perms)| name.clone())
.collect();
let role = if group_names.iter().any(|n| n == "Administrator") {
@ -233,8 +218,7 @@ async fn change_password(
let user = match db.find_user(&claims.username) {
Ok(Some(u)) => u,
_ => {
return HttpResponse::InternalServerError()
.json(serde_json::json!({"error": "User not found"}));
return HttpResponse::InternalServerError().json(serde_json::json!({"error": "User not found"}));
}
};
@ -243,8 +227,7 @@ async fn change_password(
match password::verify_password(&change_req.current_password, &hash) {
Ok(true) => {}
_ => {
return HttpResponse::Unauthorized()
.json(serde_json::json!({"error": "Current password is incorrect"}));
return HttpResponse::Unauthorized().json(serde_json::json!({"error": "Current password is incorrect"}));
}
}
@ -252,64 +235,56 @@ async fn change_password(
let new_hash = match password::hash_password(&change_req.new_password) {
Ok(h) => h,
Err(_) => {
return HttpResponse::InternalServerError()
.json(serde_json::json!({"error": "Failed to hash password"}));
return HttpResponse::InternalServerError().json(serde_json::json!({"error": "Failed to hash password"}));
}
};
match db.update_user_password(claims.sub, &new_hash) {
Ok(_) => HttpResponse::Ok()
.json(serde_json::json!({"message": "Password changed successfully"})),
Err(e) => HttpResponse::InternalServerError()
.json(serde_json::json!({"error": e.to_string()})),
Ok(_) => HttpResponse::Ok().json(serde_json::json!({"message": "Password changed successfully"})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
}
// --- User Management (admin only) ---
async fn list_users(
_auth: AuthClaims,
db: web::Data<Repo>,
) -> impl Responder {
async fn list_users(_auth: AuthClaims, db: web::Data<Repo>) -> impl Responder {
match db.list_users_with_groups() {
Ok(users) => {
let result: Vec<serde_json::Value> = users.into_iter().map(|(id, username, _role, force_pw, created_at, user_groups)| {
let groups: Vec<serde_json::Value> = user_groups.iter()
.map(|(gid, name)| serde_json::json!({"id": gid, "name": name}))
.collect();
// Derive role from groups for backwards compat
let role = if user_groups.iter().any(|(_id, name)| name == "Administrator") {
"admin"
} else {
"viewer"
};
serde_json::json!({
"id": id,
"username": username,
"role": role,
"force_password_change": force_pw,
"created_at": created_at,
"groups": groups,
let result: Vec<serde_json::Value> = users
.into_iter()
.map(|(id, username, _role, force_pw, created_at, user_groups)| {
let groups: Vec<serde_json::Value> = user_groups
.iter()
.map(|(gid, name)| serde_json::json!({"id": gid, "name": name}))
.collect();
// Derive role from groups for backwards compat
let role = if user_groups.iter().any(|(_id, name)| name == "Administrator") {
"admin"
} else {
"viewer"
};
serde_json::json!({
"id": id,
"username": username,
"role": role,
"force_password_change": force_pw,
"created_at": created_at,
"groups": groups,
})
})
}).collect();
.collect();
HttpResponse::Ok().json(result)
}
Err(e) => HttpResponse::InternalServerError()
.json(serde_json::json!({"error": e.to_string()})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
}
async fn delete_user(
_auth: AuthClaims,
path: web::Path<i64>,
db: web::Data<Repo>,
) -> impl Responder {
async fn delete_user(_auth: AuthClaims, path: web::Path<i64>, db: web::Data<Repo>) -> impl Responder {
let user_id = path.into_inner();
// Can't delete self
if _auth.sub == user_id {
return HttpResponse::BadRequest()
.json(serde_json::json!({"error": "Cannot delete your own account"}));
return HttpResponse::BadRequest().json(serde_json::json!({"error": "Cannot delete your own account"}));
}
// Protect the built-in admin account
@ -322,12 +297,9 @@ async fn delete_user(
}
match db.delete_user(user_id) {
Ok(true) => HttpResponse::Ok()
.json(serde_json::json!({"message": "User deleted successfully"})),
Ok(false) => HttpResponse::NotFound()
.json(serde_json::json!({"error": "User not found"})),
Err(e) => HttpResponse::InternalServerError()
.json(serde_json::json!({"error": e.to_string()})),
Ok(true) => HttpResponse::Ok().json(serde_json::json!({"message": "User deleted successfully"})),
Ok(false) => HttpResponse::NotFound().json(serde_json::json!({"error": "User not found"})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
}
@ -341,15 +313,13 @@ async fn update_role(
// Can't change own role
if _auth.sub == user_id {
return HttpResponse::BadRequest()
.json(serde_json::json!({"error": "Cannot change your own role"}));
return HttpResponse::BadRequest().json(serde_json::json!({"error": "Cannot change your own role"}));
}
let role = match body.get("role").and_then(|v| v.as_str()) {
Some(r) if r == "admin" || r == "viewer" => r,
_ => {
return HttpResponse::BadRequest()
.json(serde_json::json!({"error": "Role must be 'admin' or 'viewer'"}));
return HttpResponse::BadRequest().json(serde_json::json!({"error": "Role must be 'admin' or 'viewer'"}));
}
};
@ -357,20 +327,16 @@ async fn update_role(
match db.find_user_by_id(user_id) {
Ok(Some(_)) => {}
Ok(None) => {
return HttpResponse::NotFound()
.json(serde_json::json!({"error": "User not found"}));
return HttpResponse::NotFound().json(serde_json::json!({"error": "User not found"}));
}
Err(e) => {
return HttpResponse::InternalServerError()
.json(serde_json::json!({"error": e.to_string()}));
return HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()}));
}
}
match db.update_user_role(user_id, role) {
Ok(_) => HttpResponse::Ok()
.json(serde_json::json!({"message": "Role updated successfully", "role": role})),
Err(e) => HttpResponse::InternalServerError()
.json(serde_json::json!({"error": e.to_string()})),
Ok(_) => HttpResponse::Ok().json(serde_json::json!({"message": "Role updated successfully", "role": role})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
}
@ -382,11 +348,14 @@ async fn reset_password(
) -> impl Responder {
let user_id = path.into_inner();
let new_password = match body.get("new_password").or_else(|| body.get("password")).and_then(|v| v.as_str()) {
let new_password = match body
.get("new_password")
.or_else(|| body.get("password"))
.and_then(|v| v.as_str())
{
Some(p) => p,
None => {
return HttpResponse::BadRequest()
.json(serde_json::json!({"error": "Password is required"}));
return HttpResponse::BadRequest().json(serde_json::json!({"error": "Password is required"}));
}
};
@ -398,72 +367,62 @@ async fn reset_password(
match db.find_user_by_id(user_id) {
Ok(Some(_)) => {}
Ok(None) => {
return HttpResponse::NotFound()
.json(serde_json::json!({"error": "User not found"}));
return HttpResponse::NotFound().json(serde_json::json!({"error": "User not found"}));
}
Err(e) => {
return HttpResponse::InternalServerError()
.json(serde_json::json!({"error": e.to_string()}));
return HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()}));
}
}
let hash = match password::hash_password(new_password) {
Ok(h) => h,
Err(_) => {
return HttpResponse::InternalServerError()
.json(serde_json::json!({"error": "Failed to hash password"}));
return HttpResponse::InternalServerError().json(serde_json::json!({"error": "Failed to hash password"}));
}
};
match db.reset_user_password(user_id, &hash) {
Ok(_) => HttpResponse::Ok()
.json(serde_json::json!({"message": "Password reset successfully"})),
Err(e) => HttpResponse::InternalServerError()
.json(serde_json::json!({"error": e.to_string()})),
Ok(_) => HttpResponse::Ok().json(serde_json::json!({"message": "Password reset successfully"})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
}
// --- User Group Management (users:admin required) ---
async fn list_groups(
_auth: AuthClaims,
db: web::Data<Repo>,
) -> impl Responder {
async fn list_groups(_auth: AuthClaims, db: web::Data<Repo>) -> impl Responder {
match db.list_user_groups() {
Ok(groups) => {
let result: Vec<serde_json::Value> = groups.into_iter().map(|(id, name, description, permissions, created_at)| {
let perms: serde_json::Value = serde_json::from_str(&permissions).unwrap_or(serde_json::json!([]));
let members: Vec<serde_json::Value> = db.get_group_members(id)
.unwrap_or_default()
.into_iter()
.map(|(uid, username)| serde_json::json!({"id": uid, "username": username}))
.collect();
serde_json::json!({
"id": id,
"name": name,
"description": description,
"permissions": perms,
"created_at": created_at,
"members": members,
let result: Vec<serde_json::Value> = groups
.into_iter()
.map(|(id, name, description, permissions, created_at)| {
let perms: serde_json::Value = serde_json::from_str(&permissions).unwrap_or(serde_json::json!([]));
let members: Vec<serde_json::Value> = db
.get_group_members(id)
.unwrap_or_default()
.into_iter()
.map(|(uid, username)| serde_json::json!({"id": uid, "username": username}))
.collect();
serde_json::json!({
"id": id,
"name": name,
"description": description,
"permissions": perms,
"created_at": created_at,
"members": members,
})
})
}).collect();
.collect();
HttpResponse::Ok().json(result)
}
Err(e) => HttpResponse::InternalServerError()
.json(serde_json::json!({"error": e.to_string()})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
}
async fn create_group(
_auth: AuthClaims,
body: web::Json<serde_json::Value>,
db: web::Data<Repo>,
) -> impl Responder {
async fn create_group(_auth: AuthClaims, body: web::Json<serde_json::Value>, db: web::Data<Repo>) -> impl Responder {
let name = match body.get("name").and_then(|v| v.as_str()) {
Some(n) if !n.is_empty() => n,
_ => {
return HttpResponse::BadRequest()
.json(serde_json::json!({"error": "Group name is required"}));
return HttpResponse::BadRequest().json(serde_json::json!({"error": "Group name is required"}));
}
};
@ -480,16 +439,11 @@ async fn create_group(
"description": description,
"permissions": serde_json::from_str::<serde_json::Value>(&permissions).unwrap_or(serde_json::json!([])),
})),
Err(e) => HttpResponse::Conflict()
.json(serde_json::json!({"error": e.to_string()})),
Err(e) => HttpResponse::Conflict().json(serde_json::json!({"error": e.to_string()})),
}
}
async fn get_group(
_auth: AuthClaims,
path: web::Path<i64>,
db: web::Data<Repo>,
) -> impl Responder {
async fn get_group(_auth: AuthClaims, path: web::Path<i64>, db: web::Data<Repo>) -> impl Responder {
let group_id = path.into_inner();
match db.get_user_group(group_id) {
@ -505,10 +459,8 @@ async fn get_group(
"members": members,
}))
}
Ok(None) => HttpResponse::NotFound()
.json(serde_json::json!({"error": "Group not found"})),
Err(e) => HttpResponse::InternalServerError()
.json(serde_json::json!({"error": e.to_string()})),
Ok(None) => HttpResponse::NotFound().json(serde_json::json!({"error": "Group not found"})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
}
@ -525,18 +477,15 @@ async fn update_group(
Ok(Some(g)) => {
// Protect built-in groups
if g.1 == "Administrator" || g.1 == "Viewer" {
return HttpResponse::Forbidden()
.json(serde_json::json!({"error": "Cannot modify built-in groups"}));
return HttpResponse::Forbidden().json(serde_json::json!({"error": "Cannot modify built-in groups"}));
}
g
}
Ok(None) => {
return HttpResponse::NotFound()
.json(serde_json::json!({"error": "Group not found"}));
return HttpResponse::NotFound().json(serde_json::json!({"error": "Group not found"}));
}
Err(e) => {
return HttpResponse::InternalServerError()
.json(serde_json::json!({"error": e.to_string()}));
return HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()}));
}
};
@ -554,34 +503,25 @@ async fn update_group(
"description": description,
"permissions": serde_json::from_str::<serde_json::Value>(&permissions).unwrap_or(serde_json::json!([])),
})),
Err(e) => HttpResponse::InternalServerError()
.json(serde_json::json!({"error": e.to_string()})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
}
async fn delete_group(
_auth: AuthClaims,
path: web::Path<i64>,
db: web::Data<Repo>,
) -> impl Responder {
async fn delete_group(_auth: AuthClaims, path: web::Path<i64>, db: web::Data<Repo>) -> impl Responder {
let group_id = path.into_inner();
// Protect built-in groups
match db.get_user_group(group_id) {
Ok(Some(g)) if g.1 == "Administrator" || g.1 == "Viewer" => {
return HttpResponse::Forbidden()
.json(serde_json::json!({"error": "Cannot delete built-in groups"}));
return HttpResponse::Forbidden().json(serde_json::json!({"error": "Cannot delete built-in groups"}));
}
_ => {}
}
match db.delete_user_group(group_id) {
Ok(true) => HttpResponse::Ok()
.json(serde_json::json!({"message": "Group deleted successfully"})),
Ok(false) => HttpResponse::NotFound()
.json(serde_json::json!({"error": "Group not found"})),
Err(e) => HttpResponse::InternalServerError()
.json(serde_json::json!({"error": e.to_string()})),
Ok(true) => HttpResponse::Ok().json(serde_json::json!({"message": "Group deleted successfully"})),
Ok(false) => HttpResponse::NotFound().json(serde_json::json!({"error": "Group not found"})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
}
@ -601,28 +541,24 @@ async fn set_user_groups(
}
Ok(Some(_)) => {}
Ok(None) => {
return HttpResponse::NotFound()
.json(serde_json::json!({"error": "User not found"}));
return HttpResponse::NotFound().json(serde_json::json!({"error": "User not found"}));
}
Err(e) => {
return HttpResponse::InternalServerError()
.json(serde_json::json!({"error": e.to_string()}));
return HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()}));
}
}
let group_ids: Vec<i64> = match body.get("group_ids").and_then(|v| v.as_array()) {
Some(arr) => arr.iter().filter_map(|v| v.as_i64()).collect(),
None => {
return HttpResponse::BadRequest()
.json(serde_json::json!({"error": "group_ids array is required"}));
return HttpResponse::BadRequest().json(serde_json::json!({"error": "group_ids array is required"}));
}
};
match db.set_user_groups(user_id, &group_ids) {
Ok(_) => HttpResponse::Ok()
.json(serde_json::json!({"message": "User groups updated successfully", "group_ids": group_ids})),
Err(e) => HttpResponse::InternalServerError()
.json(serde_json::json!({"error": e.to_string()})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
}
@ -675,6 +611,10 @@ mod tests {
// DUMMY_HASH must be parseable as a valid Argon2 hash structure
// so that timing-based username enumeration is prevented
let parsed = PasswordHash::new(DUMMY_HASH);
assert!(parsed.is_ok(), "DUMMY_HASH should be a valid Argon2 hash format, got error: {:?}", parsed.err());
assert!(
parsed.is_ok(),
"DUMMY_HASH should be a valid Argon2 hash format, got error: {:?}",
parsed.err()
);
}
}

View File

@ -1,7 +1,7 @@
use std::fmt;
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddrV4, SocketAddrV6};
use actix_web::{web, HttpResponse, Responder, Scope};
use actix_web::{HttpResponse, Responder, Scope, web};
use common::model::http_method::HttpMethod;
use serde::Deserialize;
@ -12,8 +12,7 @@ use crate::core::ebpf::protocol_filter::ProtocolFilter;
fn ok_or_error<T, E: fmt::Display>(result: Result<T, E>) -> HttpResponse {
match result {
Ok(_) => HttpResponse::Ok().finish(),
Err(e) => HttpResponse::InternalServerError()
.json(serde_json::json!({"error": e.to_string()})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
}
@ -30,13 +29,12 @@ struct DnsDomainsPayload {
}
fn dns_scope() -> Scope {
web::scope("/dns")
.service(
web::scope("/blacklist")
.route("", web::get().to(get_dns_blacklist))
.route("", web::put().to(add_dns_blacklist))
.route("", web::delete().to(remove_dns_blacklist))
)
web::scope("/dns").service(
web::scope("/blacklist")
.route("", web::get().to(get_dns_blacklist))
.route("", web::put().to(add_dns_blacklist))
.route("", web::delete().to(remove_dns_blacklist)),
)
}
async fn get_dns_blacklist(service: web::Data<DnsFilterService>) -> impl Responder {
@ -50,8 +48,7 @@ async fn add_dns_blacklist(
let domains = payload.into_inner().domains;
match service.add_domains(&domains) {
Ok(count) => HttpResponse::Ok().json(serde_json::json!({"added": count})),
Err(e) => HttpResponse::InternalServerError()
.json(serde_json::json!({"error": e.to_string()})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
}
@ -62,8 +59,7 @@ async fn remove_dns_blacklist(
let domains = payload.into_inner().domains;
match service.remove_domains(&domains) {
Ok(count) => HttpResponse::Ok().json(serde_json::json!({"removed": count})),
Err(e) => HttpResponse::InternalServerError()
.json(serde_json::json!({"error": e.to_string()})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
}
@ -122,22 +118,34 @@ async fn get_ipv6_http_service(service: web::Data<ProtocolFilter>) -> impl Respo
HttpResponse::Ok().json(service.get_ipv6_http_service().await)
}
async fn add_ipv4_http_service(payload: web::Json<(SocketAddrV4, Vec<HttpMethod>)>, service: web::Data<ProtocolFilter>) -> impl Responder {
async fn add_ipv4_http_service(
payload: web::Json<(SocketAddrV4, Vec<HttpMethod>)>,
service: web::Data<ProtocolFilter>,
) -> impl Responder {
let (addr, methods) = payload.into_inner();
ok_or_error(service.add_ipv4_http_service(addr, methods).await)
}
async fn add_ipv6_http_service(payload: web::Json<(SocketAddrV6, Vec<HttpMethod>)>, service: web::Data<ProtocolFilter>) -> impl Responder {
async fn add_ipv6_http_service(
payload: web::Json<(SocketAddrV6, Vec<HttpMethod>)>,
service: web::Data<ProtocolFilter>,
) -> impl Responder {
let (addr, methods) = payload.into_inner();
ok_or_error(service.add_ipv6_http_service(addr, methods).await)
}
async fn remove_ipv4_http_service(payload: web::Json<(SocketAddrV4, Vec<HttpMethod>)>, service: web::Data<ProtocolFilter>) -> impl Responder {
async fn remove_ipv4_http_service(
payload: web::Json<(SocketAddrV4, Vec<HttpMethod>)>,
service: web::Data<ProtocolFilter>,
) -> impl Responder {
let (addr, methods) = payload.into_inner();
ok_or_error(service.remove_ipv4_http_service(addr, methods).await)
}
async fn remove_ipv6_http_service(payload: web::Json<(SocketAddrV6, Vec<HttpMethod>)>, service: web::Data<ProtocolFilter>) -> impl Responder {
async fn remove_ipv6_http_service(
payload: web::Json<(SocketAddrV6, Vec<HttpMethod>)>,
service: web::Data<ProtocolFilter>,
) -> impl Responder {
let (addr, methods) = payload.into_inner();
ok_or_error(service.remove_ipv6_http_service(addr, methods).await)
}
@ -160,11 +168,17 @@ async fn add_ipv6_ssh_service(ip_addr: web::Json<SocketAddrV6>, service: web::Da
ok_or_error(service.add_ipv6_ssh_service(ip_addr.into_inner()).await)
}
async fn remove_ipv4_ssh_service(ip_addr: web::Json<SocketAddrV4>, service: web::Data<ProtocolFilter>) -> impl Responder {
async fn remove_ipv4_ssh_service(
ip_addr: web::Json<SocketAddrV4>,
service: web::Data<ProtocolFilter>,
) -> impl Responder {
ok_or_error(service.remove_ipv4_ssh_service(ip_addr.into_inner()).await)
}
async fn remove_ipv6_ssh_service(ip_addr: web::Json<SocketAddrV6>, service: web::Data<ProtocolFilter>) -> impl Responder {
async fn remove_ipv6_ssh_service(
ip_addr: web::Json<SocketAddrV6>,
service: web::Data<ProtocolFilter>,
) -> impl Responder {
ok_or_error(service.remove_ipv6_ssh_service(ip_addr.into_inner()).await)
}
@ -198,11 +212,17 @@ async fn add_ipv6_ssh_white_list(ip_addr: web::Json<Ipv6Addr>, service: web::Dat
ok_or_error(service.add_ipv6_ssh_white_list(ip_addr.into_inner()).await)
}
async fn remove_ipv4_ssh_white_list(ip_addr: web::Json<Ipv4Addr>, service: web::Data<ProtocolFilter>) -> impl Responder {
async fn remove_ipv4_ssh_white_list(
ip_addr: web::Json<Ipv4Addr>,
service: web::Data<ProtocolFilter>,
) -> impl Responder {
ok_or_error(service.remove_ipv4_ssh_white_list(ip_addr.into_inner()).await)
}
async fn remove_ipv6_ssh_white_list(ip_addr: web::Json<Ipv6Addr>, service: web::Data<ProtocolFilter>) -> impl Responder {
async fn remove_ipv6_ssh_white_list(
ip_addr: web::Json<Ipv6Addr>,
service: web::Data<ProtocolFilter>,
) -> impl Responder {
ok_or_error(service.remove_ipv6_ssh_white_list(ip_addr.into_inner()).await)
}
@ -224,10 +244,16 @@ async fn add_ipv6_ssh_black_list(ip_addr: web::Json<Ipv6Addr>, service: web::Dat
ok_or_error(service.add_ipv6_ssh_black_list(ip_addr.into_inner()).await)
}
async fn remove_ipv4_ssh_black_list(ip_addr: web::Json<Ipv4Addr>, service: web::Data<ProtocolFilter>) -> impl Responder {
async fn remove_ipv4_ssh_black_list(
ip_addr: web::Json<Ipv4Addr>,
service: web::Data<ProtocolFilter>,
) -> impl Responder {
ok_or_error(service.remove_ipv4_ssh_black_list(ip_addr.into_inner()).await)
}
async fn remove_ipv6_ssh_black_list(ip_addr: web::Json<Ipv6Addr>, service: web::Data<ProtocolFilter>) -> impl Responder {
async fn remove_ipv6_ssh_black_list(
ip_addr: web::Json<Ipv6Addr>,
service: web::Data<ProtocolFilter>,
) -> impl Responder {
ok_or_error(service.remove_ipv6_ssh_black_list(ip_addr.into_inner()).await)
}

View File

@ -1,4 +1,4 @@
use actix_web::{web, HttpResponse, Responder, Scope};
use actix_web::{HttpResponse, Responder, Scope, web};
use crate::infrastructure::health::SystemHealth;

View File

@ -0,0 +1,157 @@
use actix_web::{HttpResponse, Scope, web};
use serde::Serialize;
/// Hardcoded log directory — not configurable via API to prevent directory traversal.
const LOG_DIR: &str = "logs";
/// Maximum downloadable log file size (50 MB). Prevents OOM from reading huge files.
const MAX_DOWNLOAD_SIZE: u64 = 50 * 1024 * 1024;
/// Validate log filename: only alphanumeric, dots, underscores, hyphens.
/// Prevents path traversal.
fn is_valid_log_filename(name: &str) -> bool {
!name.is_empty()
&& name.len() <= 128
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-')
}
pub fn initialize() -> Scope {
web::scope("/logs")
.route("", web::get().to(list_logs))
.route("/{filename}", web::get().to(download_log))
}
#[derive(Serialize)]
struct LogFileEntry {
name: String,
size: u64,
modified: Option<u64>,
}
async fn list_logs() -> HttpResponse {
let log_dir = LOG_DIR;
let entries = match std::fs::read_dir(log_dir) {
Ok(dir) => dir
.filter_map(|e| e.ok())
.filter_map(|e| {
let name = e.file_name().to_string_lossy().to_string();
let meta = e.metadata().ok()?;
if !meta.is_file() {
return None;
}
let modified = meta
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs());
Some(LogFileEntry {
name,
size: meta.len(),
modified,
})
})
.collect::<Vec<_>>(),
Err(_) => Vec::new(),
};
HttpResponse::Ok().json(serde_json::json!({ "files": entries }))
}
async fn download_log(path: web::Path<String>) -> HttpResponse {
let filename = path.into_inner();
if !is_valid_log_filename(&filename) {
return HttpResponse::BadRequest().json(serde_json::json!({
"error": "Invalid filename: only alphanumeric, dots, underscores, hyphens allowed"
}));
}
let file_path = std::path::Path::new(LOG_DIR).join(&filename);
// Canonicalize to prevent symlink traversal
let canonical = match std::fs::canonicalize(&file_path) {
Ok(p) => p,
Err(_) => {
return HttpResponse::NotFound().json(serde_json::json!({
"error": format!("Log file '{}' not found", filename)
}));
}
};
if let Ok(log_dir_canonical) = std::fs::canonicalize(LOG_DIR)
&& !canonical.starts_with(&log_dir_canonical)
{
return HttpResponse::Forbidden().json(serde_json::json!({
"error": "Access denied: file is outside the log directory"
}));
}
// Check file size before reading to prevent OOM on large logs
match std::fs::metadata(&canonical) {
Ok(meta) if meta.len() > MAX_DOWNLOAD_SIZE => {
return HttpResponse::PayloadTooLarge().json(serde_json::json!({
"error": format!("Log file exceeds maximum download size ({}MB)", MAX_DOWNLOAD_SIZE / 1024 / 1024)
}));
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return HttpResponse::NotFound().json(serde_json::json!({
"error": format!("Log file '{}' not found", filename)
}));
}
Err(e) => {
return HttpResponse::InternalServerError().json(serde_json::json!({
"error": format!("Failed to read log file: {}", e)
}));
}
Ok(_) => {}
}
let content = match std::fs::read(&canonical) {
Ok(bytes) => bytes,
Err(e) => {
return HttpResponse::InternalServerError().json(serde_json::json!({
"error": format!("Failed to read log file: {}", e)
}));
}
};
HttpResponse::Ok()
.insert_header(("Content-Type", "application/octet-stream"))
.insert_header(("Content-Disposition", format!("attachment; filename=\"{}\"", filename)))
.body(content)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn valid_filenames() {
assert!(is_valid_log_filename("NetGuardia.2026-03-30"));
assert!(is_valid_log_filename("app.log"));
assert!(is_valid_log_filename("debug_2026-03-30.log"));
}
#[test]
fn path_traversal_blocked() {
assert!(!is_valid_log_filename("../../etc/passwd"));
assert!(!is_valid_log_filename("../secret"));
assert!(!is_valid_log_filename("/etc/shadow"));
}
#[test]
fn special_chars_blocked() {
assert!(!is_valid_log_filename("file;rm -rf"));
assert!(!is_valid_log_filename("log file.txt"));
assert!(!is_valid_log_filename(""));
}
#[test]
fn too_long_blocked() {
let long = "a".repeat(129);
assert!(!is_valid_log_filename(&long));
let exact = "a".repeat(128);
assert!(is_valid_log_filename(&exact));
}
}

View File

@ -1,20 +1,15 @@
use actix_web::{web, HttpResponse, Responder, Scope};
use actix_web::{HttpResponse, Responder, Scope, web};
use crate::core::ml::engine::Engine;
pub fn initialize() -> Scope {
web::scope("/ml")
.route("/status", web::get().to(get_status))
web::scope("/ml").route("/status", web::get().to(get_status))
}
async fn get_status(
engine: web::Data<Engine>,
) -> impl Responder {
async fn get_status(engine: web::Data<Engine>) -> impl Responder {
let trackers = engine.trackers();
let num_trackers = trackers.len();
let total_flows: usize = trackers.iter()
.map(|t| t.lock().flow_count())
.sum();
let total_flows: usize = trackers.iter().map(|t| t.lock().flow_count()).sum();
let has_traffic_logger = engine.has_traffic_logger();
HttpResponse::Ok().json(serde_json::json!({

View File

@ -1,9 +1,11 @@
pub mod acl;
pub mod api_keys;
pub mod audit;
pub mod auth;
pub mod default;
pub mod filter;
pub mod health;
pub mod mcp_keys;
pub mod logs;
pub mod ml;
pub mod notification;
pub mod rate_limit;

View File

@ -1,4 +1,4 @@
use actix_web::{web, HttpResponse, Scope};
use actix_web::{HttpResponse, Scope, web};
use serde::Deserialize;
use crate::core::auth::extractor::AuthClaims;
@ -12,10 +12,7 @@ pub fn initialize() -> Scope {
.route("/smtp/test", web::post().to(test_smtp))
}
async fn get_telegram_config(
_auth: AuthClaims,
svc: web::Data<NotificationService>,
) -> HttpResponse {
async fn get_telegram_config(_auth: AuthClaims, svc: web::Data<NotificationService>) -> HttpResponse {
match svc.get_telegram_config() {
Ok(config) => HttpResponse::Ok().json(config),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
@ -39,20 +36,14 @@ async fn set_telegram_config(
}
}
async fn test_telegram(
_auth: AuthClaims,
svc: web::Data<NotificationService>,
) -> HttpResponse {
async fn test_telegram(_auth: AuthClaims, svc: web::Data<NotificationService>) -> HttpResponse {
match svc.test_telegram().await {
Ok(()) => HttpResponse::Ok().json(serde_json::json!({"success": true, "message": "Test message sent"})),
Err(e) => HttpResponse::BadRequest().json(serde_json::json!({"success": false, "error": e.to_string()})),
}
}
async fn test_smtp(
_auth: AuthClaims,
svc: web::Data<NotificationService>,
) -> HttpResponse {
async fn test_smtp(_auth: AuthClaims, svc: web::Data<NotificationService>) -> HttpResponse {
match svc.test_smtp() {
Ok(msg) => HttpResponse::Ok().json(serde_json::json!({"success": true, "message": msg})),
Err(e) => HttpResponse::BadRequest().json(serde_json::json!({"success": false, "error": e.to_string()})),

View File

@ -1,7 +1,8 @@
use actix_web::{web, HttpResponse, Responder, Scope};
use actix_web::{HttpResponse, Responder, Scope, web};
use common::define::setting::*;
use crate::core::rate_limit_service::{RateLimitService, RateLimitSettings};
use crate::core::rate_limit_service::RateLimitService;
use crate::model::system::rate_limit_settings::RateLimitSettings;
pub fn initialize() -> Scope {
web::scope("/rate-limit")
@ -19,10 +20,7 @@ async fn get_config(service: web::Data<RateLimitService>) -> impl Responder {
})
}
async fn set_config(
settings: web::Json<RateLimitSettings>,
service: web::Data<RateLimitService>,
) -> impl Responder {
async fn set_config(settings: web::Json<RateLimitSettings>, service: web::Data<RateLimitService>) -> impl Responder {
match service.update(&settings.into_inner()) {
Ok(()) => HttpResponse::Ok().json(serde_json::json!({"status": "ok"})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),

View File

@ -1,53 +1,120 @@
use actix_web::{web, HttpResponse, Scope};
use actix_web::{HttpResponse, Scope, web};
use crate::adapter::persistence::Database;
use crate::core::auth::extractor::AuthClaims;
use crate::core::email::scheduler::SmtpClient;
use crate::core::report::engine;
use crate::infrastructure::secret_store::SecretStore;
use crate::interface::port::repository::RepositoryPort;
use crate::interface::port::secret_store::SecretStorePort;
pub fn initialize() -> Scope {
web::scope("/report")
.route("/generate", web::post().to(generate_report))
.route("/data", web::get().to(report_data))
.route("/send", web::post().to(send_report))
}
async fn generate_report(
_auth: AuthClaims,
db: web::Data<Database>,
) -> HttpResponse {
async fn generate_report(_auth: AuthClaims, db: web::Data<Database>) -> HttpResponse {
let report_dir = db
.get_setting("report_dir")
.ok()
.flatten()
.unwrap_or_else(|| "/var/lib/netguardia/reports".to_string());
if let Err(e) = std::fs::create_dir_all(&report_dir) {
return HttpResponse::InternalServerError().json(serde_json::json!({
"error": format!("Failed to create report directory: {}", e)
}));
}
let db_ref = db.get_ref();
match engine::generate_html_report(db_ref as &dyn RepositoryPort, "/tmp/netguardia-reports") {
Ok(path) => {
match std::fs::read(&path) {
Ok(content) => {
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.insert_header(("Content-Disposition", format!(
"attachment; filename=\"{}\"",
path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "report.html".into())
)))
.body(content)
}
Err(_) => {
HttpResponse::Ok().json(serde_json::json!({
"success": true,
"path": path.to_string_lossy(),
"message": "HTML report generated."
}))
}
}
}
match engine::generate_html_report(db_ref as &dyn RepositoryPort, &report_dir) {
Ok(path) => match std::fs::read(&path) {
Ok(content) => HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.insert_header((
"Content-Disposition",
format!(
"attachment; filename=\"{}\"",
path.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "report.html".into())
),
))
.body(content),
Err(_) => HttpResponse::Ok().json(serde_json::json!({
"success": true,
"path": path.to_string_lossy(),
"message": "HTML report generated."
})),
},
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
}
async fn report_data(
_auth: AuthClaims,
db: web::Data<Database>,
) -> HttpResponse {
async fn report_data(_auth: AuthClaims, db: web::Data<Database>) -> HttpResponse {
let db_ref = db.get_ref();
match engine::generate_report_json(db_ref as &dyn RepositoryPort) {
Ok(data) => HttpResponse::Ok().json(data),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
}
/// Manually trigger: generate the weekly report and send it via SMTP now.
async fn send_report(_auth: AuthClaims, db: web::Data<Database>, secrets: web::Data<SecretStore>) -> HttpResponse {
let db_ref = db.get_ref() as &dyn RepositoryPort;
let secrets_ref = secrets.get_ref() as &dyn SecretStorePort;
let smtp = match SmtpClient::from_database(db_ref, Some(secrets_ref)) {
Ok(Some(client)) => client,
Ok(None) => {
return HttpResponse::BadRequest().json(serde_json::json!({
"success": false,
"error": "SMTP not configured. Ensure smtp_host, smtp_port, smtp_username, smtp_password are set, and that the sender address (smtp_sender or smtp_username) contains '@'."
}));
}
Err(e) => {
return HttpResponse::InternalServerError().json(serde_json::json!({
"success": false,
"error": format!("Failed to read SMTP settings: {e}")
}));
}
};
let recipient = match db_ref.get_setting("smtp_recipient") {
Ok(Some(r)) if !r.is_empty() => r,
_ => {
return HttpResponse::BadRequest().json(serde_json::json!({
"success": false,
"error": "No smtp_recipient configured."
}));
}
};
let html = match crate::core::email::report::generate_weekly_report(db_ref) {
Ok(h) => h,
Err(e) => {
return HttpResponse::InternalServerError().json(serde_json::json!({
"success": false,
"error": format!("Failed to generate report: {e}")
}));
}
};
let subject = format!("NetGuardia Weekly Report — {}", chrono::Local::now().format("%Y-%m-%d"));
let send_result = tokio::task::spawn_blocking(move || smtp.send(&recipient, &subject, &html)).await;
match send_result {
Ok(Ok(())) => HttpResponse::Ok().json(serde_json::json!({
"success": true,
"message": "Report sent successfully."
})),
Ok(Err(e)) => HttpResponse::InternalServerError().json(serde_json::json!({
"success": false,
"error": format!("Failed to send report: {e}")
})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({
"success": false,
"error": format!("Send task panicked: {e}")
})),
}
}

View File

@ -1,12 +1,14 @@
use std::sync::atomic::Ordering;
use actix_web::{web, HttpResponse, Scope};
use actix_web::{HttpResponse, Scope, web};
use serde::Deserialize;
use std::sync::atomic::Ordering;
use macros::log;
use crate::adapter::persistence::Database;
use crate::core::auth::password;
use crate::core::auth::setup_guard::SetupCompleteFlag;
use crate::infrastructure::secret_store::SecretStore;
use crate::interface::port::secret_store::SecretStorePort;
use crate::model::error::system::SystemError;
pub fn initialize() -> Scope {
@ -16,9 +18,7 @@ pub fn initialize() -> Scope {
.route("/complete", web::post().to(complete_setup))
}
async fn setup_status(
setup_flag: web::Data<SetupCompleteFlag>,
) -> HttpResponse {
async fn setup_status(setup_flag: web::Data<SetupCompleteFlag>) -> HttpResponse {
let complete = setup_flag.load(Ordering::SeqCst);
HttpResponse::Ok().json(serde_json::json!({
"setup_complete": complete,
@ -28,18 +28,16 @@ async fn setup_status(
async fn list_interfaces() -> HttpResponse {
// List available network interfaces
let interfaces: Vec<serde_json::Value> = match std::fs::read_dir("/sys/class/net") {
Ok(entries) => {
entries
.filter_map(|e| e.ok())
.map(|e| {
let name = e.file_name().to_string_lossy().to_string();
serde_json::json!({
"name": name,
"is_loopback": name == "lo",
})
Ok(entries) => entries
.filter_map(|e| e.ok())
.map(|e| {
let name = e.file_name().to_string_lossy().to_string();
serde_json::json!({
"name": name,
"is_loopback": name == "lo",
})
.collect()
}
})
.collect(),
Err(_) => Vec::new(),
};
@ -74,11 +72,14 @@ struct SetupRequest {
fn is_valid_interface_name(name: &str) -> bool {
!name.is_empty()
&& name.len() <= 16
&& name.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-')
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-')
}
async fn complete_setup(
db: web::Data<Database>,
secret_store: web::Data<SecretStore>,
setup_flag: web::Data<SetupCompleteFlag>,
body: web::Json<SetupRequest>,
) -> HttpResponse {
@ -128,7 +129,7 @@ async fn complete_setup(
}
// Save configuration to database
if let Err(e) = save_config(&db, &body) {
if let Err(e) = save_config(&db, secret_store.as_ref(), &body) {
return HttpResponse::InternalServerError().json(serde_json::json!({
"error": format!("Failed to save configuration: {}", e)
}));
@ -212,7 +213,11 @@ mod tests {
}
}
fn save_config(db: &Database, req: &SetupRequest) -> Result<(), crate::model::error::Error> {
fn save_config(
db: &Database,
secrets: &dyn SecretStorePort,
req: &SetupRequest,
) -> Result<(), crate::model::error::Error> {
// Save network config
db.set_setting("ingress_interface", &req.ingress_interface)?;
db.set_setting("egress_interface", &req.egress_interface)?;
@ -221,7 +226,7 @@ fn save_config(db: &Database, req: &SetupRequest) -> Result<(), crate::model::er
db.set_setting("http_port", &port.to_string())?;
}
// Save SMTP config
// Save SMTP config (non-secret fields go to settings)
if let Some(host) = &req.smtp_host {
db.set_setting("smtp_host", host)?;
}
@ -232,18 +237,22 @@ fn save_config(db: &Database, req: &SetupRequest) -> Result<(), crate::model::er
db.set_setting("smtp_username", user)?;
}
if let Some(pass) = &req.smtp_password {
db.set_setting("smtp_password", pass)?;
// Store password through secret store (encrypted)
secrets.set_secret("smtp_password", pass)?;
db.set_setting("smtp_password", "__encrypted__")?;
}
if let Some(recipient) = &req.smtp_recipient {
db.set_setting("smtp_recipient", recipient)?;
}
// Save Telegram config
// Save Telegram config (bot_token through secret store, chat_id in JSON)
if let (Some(token), Some(chat_id)) = (&req.telegram_bot_token, &req.telegram_chat_id) {
secrets.set_secret("telegram_bot_token", token)?;
let config_json = serde_json::json!({
"bot_token": token,
"bot_token": "__encrypted__",
"chat_id": chat_id,
}).to_string();
})
.to_string();
db.set_notification_config("telegram", &config_json)?;
}

View File

@ -1,8 +1,9 @@
use actix_web::{web, HttpResponse, Scope};
use actix_web::{HttpResponse, Scope, web};
use serde::Deserialize;
use crate::core::auth::extractor::AuthClaims;
use crate::core::playbook_service::{CreatePlaybookInput, PlaybookService};
use crate::core::playbook_service::PlaybookService;
use crate::model::soar::playbook_data::{CreateConditionInput, CreatePlaybookInput};
#[derive(Deserialize)]
struct CreatePlaybookRequest {
@ -13,6 +14,7 @@ struct CreatePlaybookRequest {
condition_window_secs: Option<i64>,
cooldown_secs: Option<i64>,
actions: Vec<CreateActionRequest>,
conditions: Option<Vec<CreateConditionRequest>>,
}
#[derive(Deserialize)]
@ -21,11 +23,21 @@ struct CreateActionRequest {
params: Option<serde_json::Value>,
}
#[derive(Deserialize)]
struct CreateConditionRequest {
condition_type: String,
operator: Option<String>,
value: String,
value2: Option<String>,
}
pub fn initialize() -> Scope {
web::scope("/soar")
.route("/playbooks", web::get().to(list_playbooks))
.route("/playbooks", web::post().to(create_playbook))
.route("/playbooks/{id}", web::put().to(update_playbook))
.route("/playbooks/{id}", web::delete().to(delete_playbook))
.route("/playbooks/{id}/toggle", web::post().to(toggle_playbook))
.route("/blocks", web::get().to(list_active_blocks))
.route("/blocks/{id}/unblock", web::post().to(manual_unblock))
.route("/executions", web::get().to(list_executions))
@ -34,33 +46,51 @@ pub fn initialize() -> Scope {
.route("/whitelist/{ip}", web::delete().to(remove_whitelist))
}
async fn list_playbooks(
_auth: AuthClaims,
svc: web::Data<PlaybookService>,
) -> HttpResponse {
async fn list_playbooks(_auth: AuthClaims, svc: web::Data<PlaybookService>) -> HttpResponse {
match svc.list_playbooks() {
Ok(playbooks) => {
let responses: Vec<serde_json::Value> = playbooks.into_iter().map(|pb| {
let actions: Vec<serde_json::Value> = pb.actions.into_iter().map(|a| {
let responses: Vec<serde_json::Value> = playbooks
.into_iter()
.map(|pb| {
let actions: Vec<serde_json::Value> = pb
.actions
.into_iter()
.map(|a| {
serde_json::json!({
"id": a.id,
"action_order": a.action_order,
"action_type": a.action_type,
"params": a.params,
})
})
.collect();
let conditions: Vec<serde_json::Value> = pb
.conditions
.into_iter()
.map(|c| {
serde_json::json!({
"id": c.id,
"condition_type": c.condition_type,
"operator": c.operator,
"value": c.value,
"value2": c.value2,
})
})
.collect();
serde_json::json!({
"id": a.id,
"action_order": a.action_order,
"action_type": a.action_type,
"params": a.params,
"id": pb.id,
"name": pb.name,
"enabled": pb.enabled,
"trigger_event": pb.trigger_event,
"condition_threshold": pb.condition_threshold,
"condition_count": pb.condition_count,
"condition_window_secs": pb.condition_window_secs,
"cooldown_secs": pb.cooldown_secs,
"actions": actions,
"conditions": conditions,
})
}).collect();
serde_json::json!({
"id": pb.id,
"name": pb.name,
"enabled": pb.enabled,
"trigger_event": pb.trigger_event,
"condition_threshold": pb.condition_threshold,
"condition_count": pb.condition_count,
"condition_window_secs": pb.condition_window_secs,
"cooldown_secs": pb.cooldown_secs,
"actions": actions,
})
}).collect();
.collect();
HttpResponse::Ok().json(responses)
}
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
@ -72,12 +102,39 @@ async fn create_playbook(
svc: web::Data<PlaybookService>,
body: web::Json<CreatePlaybookRequest>,
) -> HttpResponse {
let actions: Vec<(String, String)> = body.actions.iter().map(|a| {
let params_str = a.params.as_ref()
.map(|v| serde_json::to_string(v).unwrap_or_else(|_| "{}".into()))
.unwrap_or_else(|| "{}".into());
(a.action_type.clone(), params_str)
}).collect();
let actions: Vec<(String, String)> = body
.actions
.iter()
.map(|a| {
let params_str = a
.params
.as_ref()
.map(|v| serde_json::to_string(v).unwrap_or_else(|_| "{}".into()))
.unwrap_or_else(|| "{}".into());
(a.action_type.clone(), params_str)
})
.collect();
let conditions: Vec<CreateConditionInput> = body
.conditions
.as_deref()
.unwrap_or_default()
.iter()
.map(|c| {
let default_op = match c.condition_type.as_str() {
"threshold" | "frequency" => ">=",
"source_country" | "ip_pattern" => "in",
"repeat_offender" => "==",
_ => ">=",
};
CreateConditionInput {
condition_type: c.condition_type.clone(),
operator: c.operator.clone().unwrap_or_else(|| default_op.to_string()),
value: c.value.clone(),
value2: c.value2.clone(),
}
})
.collect();
let input = CreatePlaybookInput {
name: body.name.clone(),
@ -87,6 +144,7 @@ async fn create_playbook(
condition_window_secs: body.condition_window_secs,
cooldown_secs: body.cooldown_secs.unwrap_or(300),
actions,
conditions,
};
match svc.create_playbook(&input) {
@ -95,11 +153,85 @@ async fn create_playbook(
}
}
async fn delete_playbook(
async fn update_playbook(
_auth: AuthClaims,
svc: web::Data<PlaybookService>,
path: web::Path<i64>,
body: web::Json<CreatePlaybookRequest>,
) -> HttpResponse {
let id = path.into_inner();
let actions: Vec<(String, String)> = body
.actions
.iter()
.map(|a| {
let params_str = a
.params
.as_ref()
.map(|v| serde_json::to_string(v).unwrap_or_else(|_| "{}".into()))
.unwrap_or_else(|| "{}".into());
(a.action_type.clone(), params_str)
})
.collect();
let conditions: Vec<CreateConditionInput> = body
.conditions
.as_deref()
.unwrap_or_default()
.iter()
.map(|c| {
let default_op = match c.condition_type.as_str() {
"threshold" | "frequency" => ">=",
"source_country" | "ip_pattern" => "in",
"repeat_offender" => "==",
_ => ">=",
};
CreateConditionInput {
condition_type: c.condition_type.clone(),
operator: c.operator.clone().unwrap_or_else(|| default_op.to_string()),
value: c.value.clone(),
value2: c.value2.clone(),
}
})
.collect();
let input = CreatePlaybookInput {
name: body.name.clone(),
trigger_event: body.trigger_event.clone(),
condition_threshold: body.condition_threshold,
condition_count: body.condition_count,
condition_window_secs: body.condition_window_secs,
cooldown_secs: body.cooldown_secs.unwrap_or(300),
actions,
conditions,
};
match svc.update_playbook(id, &input) {
Ok(true) => HttpResponse::Ok().json(serde_json::json!({"updated": true})),
Ok(false) => HttpResponse::NotFound().json(serde_json::json!({"error": "Playbook not found"})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
}
#[derive(Deserialize)]
struct TogglePlaybookRequest {
enabled: bool,
}
async fn toggle_playbook(
_auth: AuthClaims,
svc: web::Data<PlaybookService>,
path: web::Path<i64>,
body: web::Json<TogglePlaybookRequest>,
) -> HttpResponse {
match svc.toggle_playbook(path.into_inner(), body.enabled) {
Ok(true) => HttpResponse::Ok().json(serde_json::json!({"updated": true})),
Ok(false) => HttpResponse::NotFound().json(serde_json::json!({"error": "Playbook not found"})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
}
async fn delete_playbook(_auth: AuthClaims, svc: web::Data<PlaybookService>, path: web::Path<i64>) -> HttpResponse {
match svc.delete_playbook(path.into_inner()) {
Ok(true) => HttpResponse::Ok().json(serde_json::json!({"deleted": true})),
Ok(false) => HttpResponse::NotFound().json(serde_json::json!({"error": "Playbook not found"})),
@ -107,63 +239,56 @@ async fn delete_playbook(
}
}
async fn list_active_blocks(
_auth: AuthClaims,
svc: web::Data<PlaybookService>,
) -> HttpResponse {
async fn list_active_blocks(_auth: AuthClaims, svc: web::Data<PlaybookService>) -> HttpResponse {
match svc.list_active_blocks() {
Ok(blocks) => {
let responses: Vec<serde_json::Value> = blocks.into_iter().map(|b| {
serde_json::json!({
"id": b.id,
"source_ip": b.source_ip,
"playbook_id": b.playbook_id,
"expires_at": b.expires_at,
let responses: Vec<serde_json::Value> = blocks
.into_iter()
.map(|b| {
serde_json::json!({
"id": b.id,
"source_ip": b.source_ip,
"playbook_id": b.playbook_id,
"expires_at": b.expires_at,
})
})
}).collect();
.collect();
HttpResponse::Ok().json(responses)
}
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
}
async fn manual_unblock(
_auth: AuthClaims,
svc: web::Data<PlaybookService>,
path: web::Path<i64>,
) -> HttpResponse {
async fn manual_unblock(_auth: AuthClaims, svc: web::Data<PlaybookService>, path: web::Path<i64>) -> HttpResponse {
match svc.manual_unblock(path.into_inner()).await {
Ok(()) => HttpResponse::Ok().json(serde_json::json!({"unblocked": true})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
}
async fn list_executions(
_auth: AuthClaims,
svc: web::Data<PlaybookService>,
) -> HttpResponse {
async fn list_executions(_auth: AuthClaims, svc: web::Data<PlaybookService>) -> HttpResponse {
match svc.list_executions(100) {
Ok(executions) => {
let responses: Vec<serde_json::Value> = executions.into_iter().map(|ex| {
serde_json::json!({
"id": ex.id,
"playbook_id": ex.playbook_id,
"source_ip": ex.source_ip,
"trigger_event": ex.trigger_event,
"actions_executed": ex.actions_executed,
"created_at": ex.created_at,
let responses: Vec<serde_json::Value> = executions
.into_iter()
.map(|ex| {
serde_json::json!({
"id": ex.id,
"playbook_id": ex.playbook_id,
"source_ip": ex.source_ip,
"trigger_event": ex.trigger_event,
"actions_executed": ex.actions_executed,
"created_at": ex.created_at,
})
})
}).collect();
.collect();
HttpResponse::Ok().json(responses)
}
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
}
async fn list_whitelist(
_auth: AuthClaims,
svc: web::Data<PlaybookService>,
) -> HttpResponse {
async fn list_whitelist(_auth: AuthClaims, svc: web::Data<PlaybookService>) -> HttpResponse {
match svc.list_whitelist() {
Ok(ips) => HttpResponse::Ok().json(ips),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
@ -186,11 +311,7 @@ async fn add_whitelist(
}
}
async fn remove_whitelist(
_auth: AuthClaims,
svc: web::Data<PlaybookService>,
path: web::Path<String>,
) -> HttpResponse {
async fn remove_whitelist(_auth: AuthClaims, svc: web::Data<PlaybookService>, path: web::Path<String>) -> HttpResponse {
match svc.remove_whitelist(&path.into_inner()) {
Ok(()) => HttpResponse::Ok().json(serde_json::json!({"removed": true})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),

View File

@ -1,4 +1,4 @@
use actix_web::{web, HttpResponse, Responder, Scope};
use actix_web::{HttpResponse, Responder, Scope, web};
use crate::core::ebpf::drop_monitor::DropMonitor;
use crate::infrastructure::statistics::FlowStatistics;
@ -15,10 +15,7 @@ async fn get_all_flows(stats: web::Data<FlowStatistics>) -> impl Responder {
HttpResponse::Ok().json(stats.get_all_flows())
}
async fn get_top_flows(
stats: web::Data<FlowStatistics>,
path: web::Path<usize>,
) -> impl Responder {
async fn get_top_flows(stats: web::Data<FlowStatistics>, path: web::Path<usize>) -> impl Responder {
let n = path.into_inner();
HttpResponse::Ok().json(stats.get_top_flows(n))
}

View File

@ -1,7 +1,8 @@
use actix_web::{web, HttpResponse, Responder, Scope};
use actix_web::{HttpResponse, Responder, Scope, web};
use serde::Deserialize;
use crate::core::config_service::ConfigService;
use crate::core::system::{ShutdownHandle, ShutdownMode};
use crate::infrastructure::communication_manager::CommunicationManager;
use crate::interface::communication::command_types::ChangeEnforceModeCommand;
use crate::interface::communication::query_types::GetEnforceModeQuery;
@ -22,6 +23,10 @@ pub fn initialize() -> Scope {
.route("/xdp-mode", web::get().to(get_xdp_mode))
.route("/config", web::get().to(get_config))
.route("/config", web::put().to(update_config))
.route("/log-level", web::get().to(get_log_level))
.route("/log-level", web::put().to(set_log_level))
.route("/shutdown", web::post().to(shutdown))
.route("/restart", web::post().to(restart))
}
async fn get_boot_time() -> impl Responder {
@ -31,8 +36,7 @@ async fn get_boot_time() -> impl Responder {
async fn get_enforce_mode(comm: web::Data<CommunicationManager>) -> impl Responder {
match comm.send_query(GetEnforceModeQuery).await {
Ok(mode) => HttpResponse::Ok().json(serde_json::json!({"mode": mode})),
Err(e) => HttpResponse::InternalServerError()
.json(serde_json::json!({"error": e.to_string()})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
}
@ -41,25 +45,28 @@ async fn set_enforce_mode(
comm: web::Data<CommunicationManager>,
) -> impl Responder {
let mode = &body.mode;
if mode != "monitor" && mode != "enforce" {
if mode != "monitor" && mode != "ml_only" && mode != "enforce" {
return HttpResponse::BadRequest()
.json(serde_json::json!({"error": "Mode must be 'monitor' or 'enforce'"}));
.json(serde_json::json!({"error": "Mode must be 'monitor', 'ml_only', or 'enforce'"}));
}
match comm.send_command(ChangeEnforceModeCommand { mode: mode.clone() }).await {
Ok(_) => {
HttpResponse::Ok().json(serde_json::json!({"mode": mode}))
}
Err(e) => HttpResponse::InternalServerError()
.json(serde_json::json!({"error": e.to_string()})),
Ok(_) => HttpResponse::Ok().json(serde_json::json!({"mode": mode})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
}
async fn get_xdp_mode(db: web::Data<Repo>) -> impl Responder {
let ingress = db.get_setting("xdp_ingress_mode")
.ok().flatten().unwrap_or_else(|| "unknown".to_string());
let egress = db.get_setting("xdp_egress_mode")
.ok().flatten().unwrap_or_else(|| "unknown".to_string());
let ingress = db
.get_setting("xdp_ingress_mode")
.ok()
.flatten()
.unwrap_or_else(|| "unknown".to_string());
let egress = db
.get_setting("xdp_egress_mode")
.ok()
.flatten()
.unwrap_or_else(|| "unknown".to_string());
HttpResponse::Ok().json(serde_json::json!({
"ingress_mode": ingress,
@ -71,17 +78,73 @@ async fn get_config(svc: web::Data<ConfigService>) -> impl Responder {
HttpResponse::Ok().json(svc.get_config())
}
async fn get_log_level() -> impl Responder {
HttpResponse::Ok().json(serde_json::json!({
"level": crate::utils::logging::Logging::current_level(),
}))
}
#[derive(Deserialize)]
struct LogLevelRequest {
level: String,
}
async fn set_log_level(body: web::Json<LogLevelRequest>) -> impl Responder {
match crate::utils::logging::Logging::set_level(&body.level) {
Ok(new_level) => HttpResponse::Ok().json(serde_json::json!({
"level": new_level,
"message": "Log level updated",
})),
Err(e) => HttpResponse::BadRequest().json(serde_json::json!({"error": e})),
}
}
/// HTTP config keys that require a server restart to take effect.
const HTTP_RELOAD_KEYS: &[&str] = &["http_port", "cors_allowed_origins", "force_https"];
async fn update_config(
body: web::Json<serde_json::Value>,
svc: web::Data<ConfigService>,
handle: web::Data<ShutdownHandle>,
) -> impl Responder {
match svc.update_config(&body) {
Ok(updated) => {
HttpResponse::Ok().json(serde_json::json!({
"updated": updated,
"message": if updated.is_empty() { "No changes" } else { "Settings updated. Restart required for changes to take effect." }
}))
let needs_restart = updated.iter().any(|k| HTTP_RELOAD_KEYS.contains(&k.as_str()));
if needs_restart {
// Auto-trigger restart for HTTP config changes
let triggered = handle.trigger(ShutdownMode::Restart);
HttpResponse::Ok().json(serde_json::json!({
"updated": updated,
"message": if triggered {
"Settings updated. Server restarting to apply HTTP config changes."
} else {
"Settings updated. Restart already in progress."
},
"restarting": triggered,
}))
} else {
HttpResponse::Ok().json(serde_json::json!({
"updated": updated,
"message": if updated.is_empty() { "No changes" } else { "Settings updated" },
}))
}
}
Err(e) => HttpResponse::BadRequest().json(serde_json::json!({"error": e.to_string()})),
}
}
async fn shutdown(handle: web::Data<ShutdownHandle>) -> impl Responder {
if handle.trigger(ShutdownMode::Shutdown) {
HttpResponse::Ok().json(serde_json::json!({"message": "Shutdown initiated"}))
} else {
HttpResponse::Conflict().json(serde_json::json!({"error": "Shutdown already in progress"}))
}
}
async fn restart(handle: web::Data<ShutdownHandle>) -> impl Responder {
if handle.trigger(ShutdownMode::Restart) {
HttpResponse::Ok().json(serde_json::json!({"message": "Restart initiated"}))
} else {
HttpResponse::Conflict().json(serde_json::json!({"error": "Shutdown already in progress"}))
}
}

File diff suppressed because it is too large Load Diff

View File

@ -6,26 +6,29 @@ use parking_lot::Mutex;
use reqwest::Client;
use tracing::{debug, warn};
use crate::adapter::persistence::Database;
use crate::interface::port::notification::{AlertNotifier, AlertPayload};
use crate::model::error::notification::NotificationError;
use crate::interface::port::notification::{AlertNotifier, AlertPayload, NotificationConfigPort};
use crate::interface::port::repository::RepositoryPort;
use crate::interface::port::secret_store::SecretStorePort;
use crate::model::config::constants::TELEGRAM_MAX_RETRIES;
use crate::model::error::Error;
/// Rate limit: max 20 messages per minute.
const MAX_MESSAGES_PER_MINUTE: u32 = 20;
/// Max retries on 429 (rate limited).
const MAX_RETRIES: u32 = 2;
use crate::model::error::notification::NotificationError;
/// Telegram Bot API adapter implementing AlertNotifier.
pub struct TelegramAdapter {
client: Client,
db: Arc<Database>,
notif: Arc<dyn NotificationConfigPort>,
repo: Arc<dyn RepositoryPort>,
secrets: Option<Arc<dyn SecretStorePort>>,
/// Rate limiter: (count, window_start)
rate_state: Mutex<(u32, Instant)>,
}
impl TelegramAdapter {
pub fn new(db: Arc<Database>) -> Result<Self, Error> {
pub fn new(
notif: Arc<dyn NotificationConfigPort>,
repo: Arc<dyn RepositoryPort>,
secrets: Option<Arc<dyn SecretStorePort>>,
) -> Result<Self, Error> {
let client = Client::builder()
.timeout(Duration::from_secs(10))
.build()
@ -35,25 +38,32 @@ impl TelegramAdapter {
Ok(Self {
client,
db,
notif,
repo,
secrets,
rate_state: Mutex::new((0, Instant::now())),
})
}
/// Get bot token and chat ID from DB. Returns None if not configured.
/// If the bot_token in JSON is `"__encrypted__"`, reads from the secret store.
fn get_config(&self) -> Result<Option<(String, String)>, Error> {
match self.db.get_notification_config("telegram")? {
match self.notif.get_notification_config("telegram")? {
Some(json_str) => {
let config: serde_json::Value = serde_json::from_str(&json_str)
.map_err(|e| NotificationError::TelegramApiError {
let config: serde_json::Value =
serde_json::from_str(&json_str).map_err(|e| NotificationError::TelegramApiError {
reason: format!("Invalid telegram config JSON: {}", e),
})?;
let token = config.get("bot_token")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let chat_id = config.get("chat_id")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let mut token = config.get("bot_token").and_then(|v| v.as_str()).map(|s| s.to_string());
let chat_id = config.get("chat_id").and_then(|v| v.as_str()).map(|s| s.to_string());
// If token is the encrypted sentinel, resolve from secret store
if token.as_deref() == Some("__encrypted__") {
token = self
.secrets
.as_ref()
.and_then(|ss| ss.get_secret("telegram_bot_token").ok().flatten());
}
match (token, chat_id) {
(Some(t), Some(c)) if !t.is_empty() && !c.is_empty() => Ok(Some((t, c))),
@ -75,7 +85,14 @@ impl TelegramAdapter {
*window_start = Instant::now();
}
if *count >= MAX_MESSAGES_PER_MINUTE {
let max_per_min: u32 = self
.repo
.get_setting("telegram_max_messages_per_minute")
.ok()
.flatten()
.and_then(|v| v.parse().ok())
.unwrap_or(20);
if *count >= max_per_min {
return false;
}
@ -87,8 +104,9 @@ impl TelegramAdapter {
async fn send_message(&self, bot_token: &str, chat_id: &str, text: &str) -> Result<(), Error> {
let url = format!("https://api.telegram.org/bot{}/sendMessage", bot_token);
for attempt in 0..=MAX_RETRIES {
let resp = self.client
for attempt in 0..=TELEGRAM_MAX_RETRIES {
let resp = self
.client
.post(&url)
.json(&serde_json::json!({
"chat_id": chat_id,
@ -116,7 +134,8 @@ impl TelegramAdapter {
if body.contains("chat not found") || body.contains("CHAT_NOT_FOUND") {
return Err(NotificationError::TelegramChatNotFound {
chat_id: chat_id.to_string(),
}.into());
}
.into());
}
return Err(NotificationError::TelegramAuthError.into());
}
@ -124,20 +143,26 @@ impl TelegramAdapter {
if status.as_u16() == 429 {
// Rate limited by Telegram
let body: serde_json::Value = resp.json().await.unwrap_or_default();
let retry_after = body.get("parameters")
let retry_after = body
.get("parameters")
.and_then(|p| p.get("retry_after"))
.and_then(|r| r.as_u64())
.unwrap_or(5);
if attempt < MAX_RETRIES {
warn!("Telegram rate limited, retrying after {}s (attempt {}/{})",
retry_after, attempt + 1, MAX_RETRIES);
if attempt < TELEGRAM_MAX_RETRIES {
warn!(
"Telegram rate limited, retrying after {}s (attempt {}/{})",
retry_after,
attempt + 1,
TELEGRAM_MAX_RETRIES
);
tokio::time::sleep(Duration::from_secs(retry_after)).await;
continue;
} else {
return Err(NotificationError::TelegramRateLimited {
retry_after_secs: retry_after,
}.into());
}
.into());
}
}
@ -145,7 +170,8 @@ impl TelegramAdapter {
let body = resp.text().await.unwrap_or_default();
return Err(NotificationError::TelegramApiError {
reason: format!("HTTP {}: {}", status, body),
}.into());
}
.into());
}
unreachable!()
@ -185,8 +211,17 @@ impl AlertNotifier for TelegramAdapter {
};
if !self.check_rate_limit() {
warn!("Telegram rate limit reached ({}/min), dropping alert for IP {}",
MAX_MESSAGES_PER_MINUTE, payload.source_ip);
let max_per_min: u32 = self
.repo
.get_setting("telegram_max_messages_per_minute")
.ok()
.flatten()
.and_then(|v| v.parse().ok())
.unwrap_or(20);
warn!(
"Telegram rate limit reached ({}/min), dropping alert for IP {}",
max_per_min, payload.source_ip
);
return Ok(());
}
@ -200,7 +235,8 @@ impl AlertNotifier for TelegramAdapter {
None => {
return Err(NotificationError::NotConfigured {
channel: "telegram".to_string(),
}.into());
}
.into());
}
};
@ -208,6 +244,7 @@ impl AlertNotifier for TelegramAdapter {
&bot_token,
&chat_id,
"✅ <b>NetGuardia connected successfully</b>\n\nTelegram notifications are working.",
).await
)
.await
}
}

View File

@ -1,20 +1,16 @@
use actix_web::{web, HttpRequest, HttpResponse, Result};
use actix_ws::{handle, Message, MessageStream, Session};
use actix_web::{HttpRequest, HttpResponse, Result, web};
use actix_ws::{Message, MessageStream, Session, handle};
use futures_util::StreamExt;
use macros::log;
use tokio::sync::broadcast;
use crate::core::ml::alert::MLAlert;
use crate::model::ml_detection::AlertMessage;
use crate::model::error::http::HttpError;
use crate::model::error::misc::MiscError;
use crate::model::log::http::HttpLog;
use crate::model::ml_detection::AlertMessage;
pub async fn websocket_alert(
req: HttpRequest,
body: web::Payload,
ai: web::Data<MLAlert>,
) -> Result<HttpResponse> {
pub async fn websocket_alert(req: HttpRequest, body: web::Payload, ai: web::Data<MLAlert>) -> Result<HttpResponse> {
let (response, session, msg_stream) = handle(&req, body)?;
let broadcast_rx = ai.subscribe_to_alerts();
@ -88,4 +84,4 @@ async fn send_alert(session: &mut Session, alert: &AlertMessage) -> bool {
false
}
}
}
}

View File

@ -1,5 +1,5 @@
use actix_web::{web, HttpRequest, HttpResponse, Result};
use actix_ws::{handle, Message, MessageStream, Session};
use actix_web::{HttpRequest, HttpResponse, Result, web};
use actix_ws::{Message, MessageStream, Session, handle};
use futures_util::StreamExt;
use macros::log;
use tokio::sync::broadcast;

View File

@ -1,6 +1,6 @@
use std::time::Duration;
use actix_web::{web, HttpRequest, HttpResponse};
use actix_web::{HttpRequest, HttpResponse, web};
use actix_ws::Message;
use futures_util::StreamExt;
use tokio::time::interval;
@ -33,9 +33,7 @@ pub async fn flow_stats_ws(
actix_web::rt::spawn(async move {
let mut subscription = default_subscription();
let mut ticker = interval(Duration::from_secs(
subscription.interval_secs.unwrap_or(5),
));
let mut ticker = interval(Duration::from_secs(subscription.interval_secs.unwrap_or(5)));
loop {
tokio::select! {

View File

@ -1,5 +1,5 @@
use actix_web::{web, HttpRequest, HttpResponse, Result};
use actix_ws::{handle, Message, MessageStream, Session};
use actix_web::{HttpRequest, HttpResponse, Result, web};
use actix_ws::{Message, MessageStream, Session, handle};
use futures_util::StreamExt;
use macros::log;
use tokio::sync::broadcast;
@ -7,8 +7,8 @@ use tokio::sync::broadcast;
use crate::infrastructure::health::SystemHealth;
use crate::model::error::http::HttpError;
use crate::model::error::misc::MiscError;
use crate::model::log::http::HttpLog;
use crate::model::health::SystemHealthMetrics;
use crate::model::log::http::HttpLog;
pub async fn websocket_system_health(
req: HttpRequest,
@ -88,4 +88,4 @@ async fn send_metrics(session: &mut Session, metrics: &SystemHealthMetrics) -> b
false
}
}
}
}

View File

@ -1,12 +1,12 @@
use actix_web::{web, HttpRequest, HttpResponse, Responder, Scope};
use actix_web::{HttpRequest, HttpResponse, Responder, Scope, web};
use serde::Deserialize;
use super::{alert_websocket, drop_websocket, flow_websocket, health_websocket};
use crate::core::auth::jwt::JwtService;
use crate::core::ebpf::drop_monitor::DropMonitor;
use crate::core::ml::alert::MLAlert;
use crate::infrastructure::health::SystemHealth;
use crate::infrastructure::statistics::FlowStatistics;
use crate::core::ml::alert::MLAlert;
use super::{alert_websocket, drop_websocket, flow_websocket, health_websocket};
#[derive(Deserialize)]
struct WsQuery {
@ -60,7 +60,9 @@ async fn health_ws(
}
match health_websocket::websocket_system_health(req, stream, health).await {
Ok(response) => response,
Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": format!("WebSocket error: {}", err)})),
Err(err) => {
HttpResponse::InternalServerError().json(serde_json::json!({"error": format!("WebSocket error: {}", err)}))
}
}
}
@ -76,7 +78,9 @@ async fn alerts_ws(
}
match alert_websocket::websocket_alert(req, stream, ai).await {
Ok(response) => response,
Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": format!("WebSocket error: {}", err)})),
Err(err) => {
HttpResponse::InternalServerError().json(serde_json::json!({"error": format!("WebSocket error: {}", err)}))
}
}
}
@ -92,7 +96,9 @@ async fn flows_ws(
}
match flow_websocket::flow_stats_ws(req, stream, stats).await {
Ok(response) => response,
Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": format!("WebSocket error: {}", err)})),
Err(err) => {
HttpResponse::InternalServerError().json(serde_json::json!({"error": format!("WebSocket error: {}", err)}))
}
}
}
@ -108,6 +114,8 @@ async fn drops_ws(
}
match drop_websocket::websocket_drops(req, stream, monitor).await {
Ok(response) => response,
Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": format!("WebSocket error: {}", err)})),
Err(err) => {
HttpResponse::InternalServerError().json(serde_json::json!({"error": format!("WebSocket error: {}", err)}))
}
}
}

View File

@ -20,12 +20,12 @@ pub struct AclService {
}
impl AclService {
pub fn new(
db: Arc<dyn RepositoryPort>,
access_control: Arc<AccessControl>,
geo_block: Arc<GeoBlock>,
) -> Self {
Self { db, access_control, geo_block }
pub fn new(db: Arc<dyn RepositoryPort>, access_control: Arc<AccessControl>, geo_block: Arc<GeoBlock>) -> Self {
Self {
db,
access_control,
geo_block,
}
}
pub async fn add_ipv4(
@ -42,7 +42,11 @@ impl AclService {
&address.ip().to_string(),
address.port(),
) {
if let Err(rollback_err) = self.access_control.remove_ipv4_list(direction, list_type, address).await {
if let Err(rollback_err) = self
.access_control
.remove_ipv4_list(direction, list_type, address)
.await
{
log!(EbpfError::RollbackFailed(rollback_err));
}
return Err(e);
@ -64,7 +68,11 @@ impl AclService {
&address.ip().to_string(),
address.port(),
) {
if let Err(rollback_err) = self.access_control.remove_ipv6_list(direction, list_type, address).await {
if let Err(rollback_err) = self
.access_control
.remove_ipv6_list(direction, list_type, address)
.await
{
log!(EbpfError::RollbackFailed(rollback_err));
}
return Err(e);
@ -78,7 +86,9 @@ impl AclService {
list_type: ListType,
address: SocketAddrV4,
) -> Result<(), Error> {
self.access_control.remove_ipv4_list(direction, list_type, address).await?;
self.access_control
.remove_ipv4_list(direction, list_type, address)
.await?;
if let Err(e) = self.db.delete_acl_rule(
4,
direction_str(direction),
@ -100,7 +110,9 @@ impl AclService {
list_type: ListType,
address: SocketAddrV6,
) -> Result<(), Error> {
self.access_control.remove_ipv6_list(direction, list_type, address).await?;
self.access_control
.remove_ipv6_list(direction, list_type, address)
.await?;
if let Err(e) = self.db.delete_acl_rule(
6,
direction_str(direction),

View File

@ -1,4 +1,4 @@
use std::future::{ready, Ready};
use std::future::{Ready, ready};
use actix_web::dev::Payload;
use actix_web::{FromRequest, HttpMessage, HttpRequest};

View File

@ -0,0 +1,147 @@
use std::future::{Future, Ready, ready};
use std::pin::Pin;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use actix_web::body::EitherBody;
use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform};
use actix_web::http::header;
use actix_web::{Error as ActixError, HttpResponse, web};
/// Shared flag: when true, non-HTTPS requests are redirected.
pub type ForceHttpsFlag = Arc<AtomicBool>;
/// Validate that the host is safe to use in a redirect Location header.
/// Only allows: private IPs (RFC 1918), loopback, .local hostnames, and bare hostnames
/// without dots (e.g., "netguardia"). Rejects public IPs and arbitrary domains
/// to prevent host-header injection / open redirect attacks.
fn is_safe_redirect_host(host: &str) -> bool {
// Strip port if present (e.g., "192.168.1.1:8443" → "192.168.1.1")
let hostname = if host.starts_with('[') {
// IPv6 bracket: [::1]:8443
host.find(']').map(|i| &host[1..i]).unwrap_or(host)
} else {
host.split(':').next().unwrap_or(host)
};
// Localhost
if hostname == "localhost" || hostname == "127.0.0.1" || hostname == "::1" {
return true;
}
// .local mDNS hostnames (e.g., "netguardia.local")
if hostname.ends_with(".local") {
return true;
}
// Bare hostname without dots (e.g., "netguardia", not a public domain)
if !hostname.contains('.') && !hostname.contains(':') {
return true;
}
// Try parsing as IP — allow private ranges only
if let Ok(ip) = hostname.parse::<std::net::IpAddr>() {
return match ip {
std::net::IpAddr::V4(v4) => {
let o = v4.octets();
o[0] == 10 || (o[0] == 172 && (16..=31).contains(&o[1])) || (o[0] == 192 && o[1] == 168) || o[0] == 127
}
std::net::IpAddr::V6(v6) => v6.is_loopback() || (v6.segments()[0] & 0xfe00) == 0xfc00,
};
}
false
}
pub struct HttpsRedirect;
impl<S, B> Transform<S, ServiceRequest> for HttpsRedirect
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
B: 'static,
{
type Response = ServiceResponse<EitherBody<B>>;
type Error = ActixError;
type Transform = HttpsRedirectService<S>;
type InitError = ();
type Future = Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future {
ready(Ok(HttpsRedirectService {
service: std::rc::Rc::new(service),
}))
}
}
pub struct HttpsRedirectService<S> {
service: std::rc::Rc<S>,
}
impl<S, B> Service<ServiceRequest> for HttpsRedirectService<S>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
B: 'static,
{
type Response = ServiceResponse<EitherBody<B>>;
type Error = ActixError;
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>>>>;
fn poll_ready(&self, ctx: &mut core::task::Context<'_>) -> std::task::Poll<Result<(), Self::Error>> {
self.service.poll_ready(ctx)
}
fn call(&self, req: ServiceRequest) -> Self::Future {
let service = std::rc::Rc::clone(&self.service);
Box::pin(async move {
// Check if force_https is enabled
let force = req
.app_data::<web::Data<ForceHttpsFlag>>()
.map(|flag| flag.load(Ordering::Relaxed))
.unwrap_or(false);
if !force {
let res = service.call(req).await?.map_into_left_body();
return Ok(res);
}
// Allow health check endpoints without redirect (for load balancer probes)
let path = req.path();
if path.starts_with("/health/") {
let res = service.call(req).await?.map_into_left_body();
return Ok(res);
}
// Check X-Forwarded-Proto (set by reverse proxy / load balancer)
let proto = req
.headers()
.get("X-Forwarded-Proto")
.and_then(|v| v.to_str().ok())
.unwrap_or("http");
if proto == "https" {
let res = service.call(req).await?.map_into_left_body();
return Ok(res);
}
// Build HTTPS redirect URL.
// Validate host to prevent host-header injection / open redirect:
// only allow private IPs, localhost, and .local hostnames.
let host = req.connection_info().host().to_string();
let uri = req.uri().clone();
if !is_safe_redirect_host(&host) {
let resp = HttpResponse::BadRequest().finish();
return Ok(req.into_response(resp).map_into_right_body());
}
let redirect_url = format!("https://{}{}", host, uri);
let resp = HttpResponse::MovedPermanently()
.insert_header((header::LOCATION, redirect_url))
// HSTS: 1 year, include subdomains
.insert_header(("Strict-Transport-Security", "max-age=31536000; includeSubDomains"))
.finish();
Ok(req.into_response(resp).map_into_right_body())
})
}
}

View File

@ -1,9 +1,11 @@
use jsonwebtoken::{decode, encode, errors::ErrorKind, Algorithm, DecodingKey, EncodingKey, Header, Validation};
use std::sync::Arc;
use crate::interface::port::repository::RepositoryPort;
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode, errors::ErrorKind};
use crate::interface::port::secret_store::SecretStorePort;
use crate::model::auth::Claims;
use crate::model::error::auth::AuthError;
use crate::model::error::Error;
use crate::model::error::auth::AuthError;
pub struct JwtService {
encoding_key: EncodingKey,
@ -12,13 +14,13 @@ pub struct JwtService {
}
impl JwtService {
pub fn new(db: &dyn RepositoryPort, expiry_hours: u64) -> Result<Self, Error> {
let raw_bytes = match db.get_setting("jwt_secret")? {
pub fn new(secrets: &Arc<dyn SecretStorePort>, expiry_hours: u64) -> Result<Self, Error> {
let raw_bytes = match secrets.get_secret("jwt_secret")? {
Some(hex_str) => hex_decode(&hex_str).map_err(|_| AuthError::InvalidToken)?,
None => {
use rand::Rng;
let secret: [u8; 32] = rand::rng().random();
db.set_setting("jwt_secret", &hex_encode(&secret))?;
secrets.set_secret("jwt_secret", &hex_encode(&secret))?;
secret.to_vec()
}
};
@ -30,10 +32,16 @@ impl JwtService {
})
}
pub fn create_token(&self, user_id: i64, username: &str, role: &str, permissions: Vec<String>) -> Result<String, Error> {
pub fn create_token(
&self,
user_id: i64,
username: &str,
role: &str,
permissions: Vec<String>,
) -> Result<String, Error> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.unwrap_or(std::time::Duration::ZERO)
.as_secs();
let claims = Claims {
@ -44,13 +52,12 @@ impl JwtService {
exp: (now + self.expiry_hours * 3600) as usize,
};
encode(&Header::default(), &claims, &self.encoding_key)
.map_err(|_| AuthError::InvalidToken.into())
encode(&Header::default(), &claims, &self.encoding_key).map_err(|_| AuthError::InvalidToken.into())
}
pub fn validate_token(&self, token: &str) -> Result<Claims, Error> {
let token_data = decode::<Claims>(token, &self.decoding_key, &Validation::new(Algorithm::HS256))
.map_err(|e| {
let token_data =
decode::<Claims>(token, &self.decoding_key, &Validation::new(Algorithm::HS256)).map_err(|e| {
match e.kind() {
ErrorKind::ExpiredSignature => Error::from(AuthError::TokenExpired),
_ => Error::from(AuthError::InvalidToken),
@ -83,10 +90,12 @@ fn hex_decode(hex: &str) -> Result<Vec<u8>, &'static str> {
mod tests {
use super::*;
use crate::adapter::persistence::Database;
use crate::infrastructure::secret_store::SecretStore;
fn test_jwt_service() -> JwtService {
let db = Database::new(":memory:").unwrap();
JwtService::new(&db, 24).unwrap()
let db = Arc::new(Database::new(":memory:").unwrap());
let secrets: Arc<dyn SecretStorePort> = Arc::new(SecretStore::new(db));
JwtService::new(&secrets, 24).unwrap()
}
#[test]
@ -110,8 +119,9 @@ mod tests {
#[test]
fn test_expired_token() {
let db = Database::new(":memory:").unwrap();
let jwt = JwtService::new(&db, 0).unwrap(); // 0 hours = immediate expiry
let db = Arc::new(Database::new(":memory:").unwrap());
let secrets: Arc<dyn SecretStorePort> = Arc::new(SecretStore::new(db));
let jwt = JwtService::new(&secrets, 0).unwrap(); // 0 hours = immediate expiry
// Create token with 0 hour expiry — it expires in the past
let claims = Claims {
@ -128,14 +138,15 @@ mod tests {
#[test]
fn test_jwt_secret_persistence() {
let db = Database::new(":memory:").unwrap();
let db = Arc::new(Database::new(":memory:").unwrap());
let secrets: Arc<dyn SecretStorePort> = Arc::new(SecretStore::new(db));
// First creation generates and stores secret
let jwt1 = JwtService::new(&db, 24).unwrap();
let jwt1 = JwtService::new(&secrets, 24).unwrap();
let token = jwt1.create_token(1, "admin", "admin", vec![]).unwrap();
// Second creation reuses stored secret
let jwt2 = JwtService::new(&db, 24).unwrap();
let jwt2 = JwtService::new(&secrets, 24).unwrap();
let claims = jwt2.validate_token(&token).unwrap();
assert_eq!(claims.username, "admin");
}

View File

@ -1,15 +1,16 @@
use std::future::{ready, Future, Ready};
use std::future::{Future, Ready, ready};
use std::pin::Pin;
use std::rc::Rc;
use actix_web::body::EitherBody;
use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform};
use actix_web::{web, Error as ActixError, HttpMessage, HttpResponse};
use actix_web::{Error as ActixError, HttpMessage, HttpResponse, web};
use macros::log;
use crate::adapter::persistence::Database;
use crate::core::auth::jwt::JwtService;
use crate::interface::port::api_key::ApiKeyPort;
use crate::interface::port::repository::RepositoryPort;
use crate::model::error::auth::AuthError;
pub struct AuthMiddleware;
@ -64,7 +65,9 @@ fn required_permission(path: &str, method: &actix_web::http::Method) -> Option<S
} else if path.starts_with("/api/soar/")
|| path.starts_with("/api/notifications/")
|| path.starts_with("/api/report/")
|| path.starts_with("/api/mcp/")
|| path.starts_with("/api/api-keys/")
|| path.starts_with("/api/logs/")
|| path.starts_with("/api/audit/")
{
"system"
} else {
@ -88,10 +91,7 @@ where
type Error = ActixError;
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>>>>;
fn poll_ready(
&self,
ctx: &mut core::task::Context<'_>,
) -> std::task::Poll<Result<(), Self::Error>> {
fn poll_ready(&self, ctx: &mut core::task::Context<'_>) -> std::task::Poll<Result<(), Self::Error>> {
self.service.poll_ready(ctx)
}
@ -102,10 +102,7 @@ where
let path = req.path().to_string();
// Skip auth for public endpoints
if path == "/api/auth/login"
|| path.starts_with("/api/setup/")
|| !path.starts_with("/api/")
{
if path == "/api/auth/login" || path.starts_with("/api/setup/") || !path.starts_with("/api/") {
let res = service.call(req).await?.map_into_left_body();
return Ok(res);
}
@ -114,8 +111,8 @@ where
let jwt_service = match req.app_data::<web::Data<JwtService>>() {
Some(s) => s.clone(),
None => {
let resp = HttpResponse::InternalServerError()
.json(serde_json::json!({"error": "Auth not configured"}));
let resp =
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Auth not configured"}));
return Ok(req.into_response(resp).map_into_right_body());
}
};
@ -135,43 +132,53 @@ where
match jwt_service.validate_token(token) {
Ok(c) => c,
Err(_) => {
let resp = HttpResponse::Unauthorized()
.json(serde_json::json!({"error": "Invalid or expired token"}));
let resp =
HttpResponse::Unauthorized().json(serde_json::json!({"error": "Invalid or expired token"}));
return Ok(req.into_response(resp).map_into_right_body());
}
}
} else if let Some(api_key_header) = req.headers().get("X-API-Key") {
// MCP API key auth with rate limiting
// API key auth with rate limiting
let api_key = api_key_header.to_str().unwrap_or("");
let db = match req.app_data::<web::Data<Database>>() {
let api_key_port = match req.app_data::<web::Data<dyn ApiKeyPort>>() {
Some(d) => d.clone(),
None => {
let resp = HttpResponse::InternalServerError()
.json(serde_json::json!({"error": "Database not configured"}));
.json(serde_json::json!({"error": "ApiKeyPort not configured"}));
return Ok(req.into_response(resp).map_into_right_body());
}
};
let repo = match req.app_data::<web::Data<dyn RepositoryPort>>() {
Some(d) => d.clone(),
None => {
let resp = HttpResponse::InternalServerError()
.json(serde_json::json!({"error": "RepositoryPort not configured"}));
return Ok(req.into_response(resp).map_into_right_body());
}
};
// Rate limit check for API key attempts (reuse login failure tracking)
let rate_key = format!("apikey:{}", req.peer_addr().map(|a| a.ip().to_string()).unwrap_or_default());
if let Ok(Some(remaining)) = db.check_login_locked(&rate_key) {
let resp = HttpResponse::TooManyRequests()
.json(serde_json::json!({
"error": "Too many failed API key attempts",
"retry_after_secs": remaining,
}));
let rate_key = format!(
"apikey:{}",
req.peer_addr().map(|a| a.ip().to_string()).unwrap_or_default()
);
if let Ok(Some(remaining)) = repo.check_login_locked(&rate_key) {
let resp = HttpResponse::TooManyRequests().json(serde_json::json!({
"error": "Too many failed API key attempts",
"retry_after_secs": remaining,
}));
return Ok(req.into_response(resp).map_into_right_body());
}
match db.validate_api_key(api_key) {
match api_key_port.validate_api_key(api_key) {
Ok(Some(key_claims)) => {
if let Err(e) = db.clear_login_failures(&rate_key) {
if let Err(e) = repo.clear_login_failures(&rate_key) {
log!(AuthError::LoginClearError(e));
}
key_claims
}
Ok(None) => {
if let Err(e) = db.record_login_failure(&rate_key) {
if let Err(e) = repo.record_login_failure(&rate_key) {
log!(AuthError::LoginFailureTrackingError(e));
}
let resp = HttpResponse::Unauthorized()
@ -185,8 +192,8 @@ where
}
}
} else {
let resp = HttpResponse::Unauthorized()
.json(serde_json::json!({"error": "Missing authorization header"}));
let resp =
HttpResponse::Unauthorized().json(serde_json::json!({"error": "Missing authorization header"}));
return Ok(req.into_response(resp).map_into_right_body());
};
@ -194,8 +201,7 @@ where
if let Some(required) = required_permission(&path, req.method())
&& !claims.permissions.contains(&required)
{
let resp = HttpResponse::Forbidden()
.json(serde_json::json!({"error": "Insufficient permissions"}));
let resp = HttpResponse::Forbidden().json(serde_json::json!({"error": "Insufficient permissions"}));
return Ok(req.into_response(resp).map_into_right_body());
}

View File

@ -1,4 +1,5 @@
pub mod extractor;
pub mod https_redirect;
pub mod jwt;
pub mod middleware;
pub mod password;

View File

@ -1,9 +1,9 @@
use argon2::password_hash::rand_core::OsRng;
use argon2::password_hash::SaltString;
use argon2::password_hash::rand_core::OsRng;
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
use crate::model::error::auth::AuthError;
use crate::model::error::Error;
use crate::model::error::auth::AuthError;
pub fn hash_password(password: &str) -> Result<String, Error> {
let salt = SaltString::generate(&mut OsRng);
@ -16,9 +16,7 @@ pub fn hash_password(password: &str) -> Result<String, Error> {
pub fn verify_password(password: &str, hash: &str) -> Result<bool, Error> {
let parsed = PasswordHash::new(hash).map_err(|_| AuthError::InvalidCredentials)?;
Ok(Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.is_ok())
Ok(Argon2::default().verify_password(password.as_bytes(), &parsed).is_ok())
}
#[cfg(test)]

View File

@ -1,11 +1,11 @@
use std::future::{ready, Future, Ready};
use std::future::{Future, Ready, ready};
use std::pin::Pin;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use actix_web::body::EitherBody;
use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform};
use actix_web::{web, Error as ActixError, HttpResponse};
use actix_web::{Error as ActixError, HttpResponse, web};
/// Shared flag indicating whether setup has completed.
/// When false, only setup wizard routes are allowed; all others get 503.
@ -44,10 +44,7 @@ where
type Error = ActixError;
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>>>>;
fn poll_ready(
&self,
ctx: &mut core::task::Context<'_>,
) -> std::task::Poll<Result<(), Self::Error>> {
fn poll_ready(&self, ctx: &mut core::task::Context<'_>) -> std::task::Poll<Result<(), Self::Error>> {
self.service.poll_ready(ctx)
}
@ -67,8 +64,7 @@ where
// Normal mode: pass through, but block setup mutation endpoints.
// Allow /api/setup/status (read-only) so frontend can check setup state.
if path.starts_with("/api/setup/") && path != "/api/setup/status" {
let resp = HttpResponse::Gone()
.json(serde_json::json!({"error": "Setup already completed"}));
let resp = HttpResponse::Gone().json(serde_json::json!({"error": "Setup already completed"}));
return Ok(req.into_response(resp).map_into_right_body());
}
let res = service.call(req).await?.map_into_left_body();
@ -86,12 +82,11 @@ where
}
// Block all other API routes with 503
let resp = HttpResponse::ServiceUnavailable()
.json(serde_json::json!({
"error": "System setup in progress",
"setup_required": true,
"message": "Please complete the setup wizard at /setup"
}));
let resp = HttpResponse::ServiceUnavailable().json(serde_json::json!({
"error": "System setup in progress",
"setup_required": true,
"message": "Please complete the setup wizard at /setup"
}));
Ok(req.into_response(resp).map_into_right_body())
})
}

View File

@ -1,43 +1,83 @@
use std::sync::Arc;
use crate::interface::port::repository::RepositoryPort;
use crate::interface::port::secret_store::SecretStorePort;
use crate::model::error::Error;
use crate::model::error::misc::MiscError;
/// Keys that must be routed through SecretStore instead of plaintext settings.
const SECRET_KEYS: &[&str] = &["smtp_password"];
/// Valid eBPF pipeline stage names.
const VALID_PIPELINE_STAGES: &[&str] = &["access_control", "rate_limit", "service"];
/// All configurable settings grouped by section.
const SETTINGS_MAP: &[(&str, &[&str])] = &[
("network", &["ingress_interface", "egress_interface", "refresh_interval"]),
("http", &["http_port", "jwt_expiry_hours"]),
("inference", &["max_concurrent_flows", "min_packets_for_inference",
"inference_interval_secs", "aggregator_window_secs",
"inference_batch_size", "traffic_logging_mode",
"traffic_log_csv_path"]),
("xdp", &["combined_queue_count", "channel_size", "fill_queue_size", "comp_queue_size",
"tx_queue_size", "rx_queue_size", "frame_size", "frame_count",
"packet_buffer_size", "buffer_pool_capacity"]),
("models", &["deep_autoencoder_name", "classifier_name", "models_config_name"]),
(
"network",
&["ingress_interface", "egress_interface", "refresh_interval"],
),
("http", &["http_port", "jwt_expiry_hours", "force_https"]),
(
"inference",
&[
"max_concurrent_flows",
"min_packets_for_inference",
"inference_interval_secs",
"aggregator_window_secs",
"inference_batch_size",
"traffic_logging_mode",
"traffic_log_csv_path",
],
),
(
"xdp",
&[
"combined_queue_count",
"channel_size",
"fill_queue_size",
"comp_queue_size",
"tx_queue_size",
"rx_queue_size",
"frame_size",
"frame_count",
"packet_buffer_size",
"buffer_pool_capacity",
],
),
(
"models",
&["deep_autoencoder_name", "classifier_name", "models_config_name"],
),
// report_dir and log_dir intentionally NOT configurable via API to prevent
// arbitrary directory write/read. They use hardcoded safe defaults.
("misc", &["geoip_db_name"]),
("smtp", &["smtp_host", "smtp_port", "smtp_username", "smtp_password", "smtp_recipient"]),
("soar", &["soar_max_auto_block_cap", "soar_max_ttl_secs"]),
("ml", &["ml_drift_window_secs"]),
("telegram", &["telegram_max_messages_per_minute"]),
("dns", &["dns_max_domains_per_request"]),
("smtp", &["smtp_host", "smtp_port", "smtp_username", "smtp_recipient"]),
];
/// Domain service for system configuration read/write.
pub struct ConfigService {
db: Arc<dyn RepositoryPort>,
secrets: Option<Arc<dyn SecretStorePort>>,
}
impl ConfigService {
pub fn new(db: Arc<dyn RepositoryPort>) -> Self {
Self { db }
Self { db, secrets: None }
}
pub fn with_secret_store(mut self, secrets: Arc<dyn SecretStorePort>) -> Self {
self.secrets = Some(secrets);
self
}
/// Read all user-configurable settings from DB as structured JSON.
pub fn get_config(&self) -> serde_json::Value {
let get = |key: &str| -> String {
self.db.get_setting(key).ok().flatten().unwrap_or_default()
};
let get = |key: &str| -> String { self.db.get_setting(key).ok().flatten().unwrap_or_default() };
serde_json::json!({
"network": {
@ -48,6 +88,7 @@ impl ConfigService {
"http": {
"http_port": get("http_port"),
"jwt_expiry_hours": get("jwt_expiry_hours"),
"force_https": get("force_https"),
},
"inference": {
"max_concurrent_flows": get("max_concurrent_flows"),
@ -78,6 +119,19 @@ impl ConfigService {
"misc": {
"geoip_db_name": get("geoip_db_name"),
},
"soar": {
"soar_max_auto_block_cap": get("soar_max_auto_block_cap"),
"soar_max_ttl_secs": get("soar_max_ttl_secs"),
},
"ml": {
"ml_drift_window_secs": get("ml_drift_window_secs"),
},
"telegram": {
"telegram_max_messages_per_minute": get("telegram_max_messages_per_minute"),
},
"dns": {
"dns_max_domains_per_request": get("dns_max_domains_per_request"),
},
"pipeline": {
"ingress": get("pipeline_ingress"),
"egress": get("pipeline_egress"),
@ -108,6 +162,26 @@ impl ConfigService {
}
}
// Route secret keys through SecretStore (encrypted storage)
if let Some(ref secrets) = self.secrets {
for key in SECRET_KEYS {
// Secret keys live under their parent section (e.g., smtp_password under smtp)
let section = key.split('_').next().unwrap_or("");
if let Some(val) = body
.get(section)
.and_then(|v| v.as_object())
.and_then(|obj| obj.get(*key))
.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());
}
}
}
// Pipeline settings — validate stage names
if let Some(pipeline_obj) = body.get("pipeline").and_then(|v| v.as_object()) {
for (field, db_key) in [("ingress", "pipeline_ingress"), ("egress", "pipeline_egress")] {
@ -122,7 +196,8 @@ impl ConfigService {
stage,
VALID_PIPELINE_STAGES.join(", ")
),
}.into());
}
.into());
}
}
}

View File

@ -0,0 +1,190 @@
use std::collections::HashSet;
use std::time::{Duration, Instant};
use dashmap::DashMap;
use macros::log;
use tokio::sync::mpsc;
use crate::model::detection::ml_detection::AlertMessage;
use crate::model::event::{DetectionEvent, DetectionSource};
use crate::model::log::detection::DetectionLog;
/// Window within which unique sources are counted toward a single destination.
const BOTNET_WINDOW_SECS: u64 = 300; // 5 minutes
/// Minimum unique source IPs targeting the same destination to trigger a botnet alert.
const BOTNET_THRESHOLD: usize = 10;
/// Maximum tracked destination IPs to bound memory.
const MAX_TRACKED_DSTS: usize = 10_000;
struct TimedSourceSet {
sources: HashSet<String>,
window_start: Instant,
/// Most recent alert to this destination (used for protocol/confidence in DetectionEvent).
last_alert: AlertMessage,
}
/// Detects coordinated attacks: multiple source IPs targeting the same destination IP:port.
/// Uses a DashMap for lock-free concurrent access.
pub struct BotnetDetector {
/// dst_ip → set of unique src_ips within the time window
state: DashMap<String, TimedSourceSet>,
window: Duration,
threshold: usize,
}
impl BotnetDetector {
pub fn new() -> Self {
Self {
state: DashMap::new(),
window: Duration::from_secs(BOTNET_WINDOW_SECS),
threshold: BOTNET_THRESHOLD,
}
}
/// Process an alert and return a DetectionEvent if the botnet threshold is crossed.
pub fn process(&self, alert: &AlertMessage, detection_tx: &mpsc::Sender<DetectionEvent>) {
let key = alert.dst_ip.clone();
let now = Instant::now();
let should_alert = {
let mut entry = self.state.entry(key.clone()).or_insert_with(|| TimedSourceSet {
sources: HashSet::new(),
window_start: now,
last_alert: alert.clone(),
});
let set = entry.value_mut();
// Reset window if expired
if now.duration_since(set.window_start) >= self.window {
set.sources.clear();
set.window_start = now;
}
set.sources.insert(alert.src_ip.clone());
set.last_alert = alert.clone();
if set.sources.len() >= self.threshold {
Some(set.sources.len())
} else {
None
}
};
if let Some(unique_sources) = should_alert {
log!(DetectionLog::BotnetDetected {
dst_ip: key.clone(),
unique_sources,
window_secs: BOTNET_WINDOW_SECS,
});
// source_ip = the latest attacker; dest_ip = the victim being targeted.
// SOAR blocks source_ip, so we must NOT put the victim here.
let event = DetectionEvent {
source: DetectionSource::Correlation,
attack_type: "threat_detected".to_string(),
confidence: 0.85,
source_ip: alert.src_ip.clone(),
dest_ip: key.clone(),
protocol: alert.protocol,
packet_count: 0,
flow_duration_us: 0,
};
let _ = detection_tx.try_send(event);
// Reset after alerting to avoid repeated alerts within same window
if let Some(mut entry) = self.state.get_mut(&key) {
entry.sources.clear();
entry.window_start = now;
}
}
}
/// Remove expired entries. Returns number of entries removed.
pub fn cleanup(&self) -> usize {
let now = Instant::now();
let window = self.window;
let before = self.state.len();
self.state
.retain(|_, set| now.duration_since(set.window_start) < window);
// Enforce max capacity by removing oldest entries if over limit
if self.state.len() > MAX_TRACKED_DSTS {
let excess = self.state.len() - MAX_TRACKED_DSTS;
let keys_to_remove: Vec<String> = self.state.iter().take(excess).map(|e| e.key().clone()).collect();
for key in keys_to_remove {
self.state.remove(&key);
}
}
before.saturating_sub(self.state.len())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_alert(src_ip: &str, dst_ip: &str) -> AlertMessage {
AlertMessage {
timestamp: 0,
flow_key: String::new(),
src_ip: src_ip.to_string(),
dst_ip: dst_ip.to_string(),
src_port: 12345,
dst_port: 80,
protocol: 6,
is_attack: true,
attack_type: Some("DDoS".to_string()),
confidence: 0.9,
ae_score: 0.5,
packet_count: 100,
flow_duration_us: 1_000_000,
}
}
#[tokio::test]
async fn botnet_threshold_triggers_alert() {
let detector = BotnetDetector::new();
let (tx, mut rx) = mpsc::channel(64);
// Send alerts from 9 different sources (below threshold)
for i in 0..9 {
let alert = make_alert(&format!("10.0.0.{i}"), "192.168.1.1");
detector.process(&alert, &tx);
}
assert!(rx.try_recv().is_err(), "Should not alert below threshold");
// 10th source should trigger
let alert = make_alert("10.0.0.9", "192.168.1.1");
detector.process(&alert, &tx);
let event = rx.try_recv().expect("Should alert at threshold");
assert_eq!(event.source, DetectionSource::Correlation);
// source_ip must be the attacker, NOT the victim
assert_eq!(event.source_ip, "10.0.0.9");
assert_eq!(event.dest_ip, "192.168.1.1");
}
#[tokio::test]
async fn cleanup_removes_expired() {
let detector = BotnetDetector {
state: DashMap::new(),
window: Duration::from_millis(10),
threshold: BOTNET_THRESHOLD,
};
let (tx, _rx) = mpsc::channel(64);
let alert = make_alert("10.0.0.1", "192.168.1.1");
detector.process(&alert, &tx);
assert_eq!(detector.state.len(), 1);
std::thread::sleep(Duration::from_millis(20));
let removed = detector.cleanup();
assert_eq!(removed, 1);
assert_eq!(detector.state.len(), 0);
}
}

View File

@ -0,0 +1,76 @@
use std::time::Duration;
use macros::log;
use tokio::sync::{broadcast, mpsc};
use crate::core::correlation::botnet::BotnetDetector;
use crate::core::correlation::lateral::LateralMovementDetector;
use crate::core::correlation::scan::ScanDetector;
use crate::model::detection::ml_detection::AlertMessage;
use crate::model::event::DetectionEvent;
use crate::model::log::detection::DetectionLog;
/// How often to sweep expired correlation state.
const CLEANUP_INTERVAL_SECS: u64 = 60;
/// Coordinates cross-flow correlation detectors (botnet, scan, lateral movement).
/// Subscribes to ML AlertMessage broadcast and feeds enriched DetectionEvents
/// to the DetectionOrchestrator for dedup and SOAR routing.
pub struct CorrelationEngine {
botnet: BotnetDetector,
scan: ScanDetector,
lateral: LateralMovementDetector,
alert_rx: broadcast::Receiver<AlertMessage>,
detection_tx: mpsc::Sender<DetectionEvent>,
}
impl CorrelationEngine {
pub fn new(alert_rx: broadcast::Receiver<AlertMessage>, detection_tx: mpsc::Sender<DetectionEvent>) -> Self {
Self {
botnet: BotnetDetector::new(),
scan: ScanDetector::new(),
lateral: LateralMovementDetector::new(),
alert_rx,
detection_tx,
}
}
/// Spawn the correlation engine as a background task.
pub fn start(self) {
tokio::spawn(async move { self.run().await });
}
async fn run(mut self) {
log!(DetectionLog::CorrelationEngineStarted);
let mut cleanup_interval = tokio::time::interval(Duration::from_secs(CLEANUP_INTERVAL_SECS));
loop {
tokio::select! {
result = self.alert_rx.recv() => {
match result {
Ok(alert) => self.process_alert(&alert),
Err(broadcast::error::RecvError::Lagged(_)) => continue,
Err(broadcast::error::RecvError::Closed) => break,
}
}
_ = cleanup_interval.tick() => {
self.cleanup();
}
}
}
}
fn process_alert(&self, alert: &AlertMessage) {
self.botnet.process(alert, &self.detection_tx);
self.scan.process(alert, &self.detection_tx);
self.lateral.process(alert, &self.detection_tx);
}
fn cleanup(&self) {
let removed = self.botnet.cleanup() + self.scan.cleanup() + self.lateral.cleanup();
if removed > 0 {
log!(DetectionLog::CorrelationCleanup { removed });
}
}
}

View File

@ -0,0 +1,229 @@
use std::collections::HashSet;
use std::net::IpAddr;
use std::time::{Duration, Instant};
use dashmap::DashMap;
use macros::log;
use tokio::sync::mpsc;
use crate::model::detection::ml_detection::AlertMessage;
use crate::model::event::{DetectionEvent, DetectionSource};
use crate::model::log::detection::DetectionLog;
/// Window within which unique internal destinations are counted per source.
const LATERAL_WINDOW_SECS: u64 = 300; // 5 minutes
/// Minimum unique internal destination IPs to trigger a lateral movement alert.
const LATERAL_THRESHOLD: usize = 5;
/// Maximum tracked source IPs to bound memory.
const MAX_TRACKED_SRCS: usize = 10_000;
struct TimedDestSet {
dests: HashSet<String>,
window_start: Instant,
}
/// Detects lateral movement: an internal IP reaching many other internal IPs.
pub struct LateralMovementDetector {
/// src_ip → set of unique internal dst_ips within the time window
state: DashMap<String, TimedDestSet>,
window: Duration,
threshold: usize,
}
impl LateralMovementDetector {
pub fn new() -> Self {
Self {
state: DashMap::new(),
window: Duration::from_secs(LATERAL_WINDOW_SECS),
threshold: LATERAL_THRESHOLD,
}
}
/// Process an alert. Only tracks internal-to-internal flows.
pub fn process(&self, alert: &AlertMessage, detection_tx: &mpsc::Sender<DetectionEvent>) {
// Only track internal-to-internal flows
if !is_internal_ip(&alert.src_ip) || !is_internal_ip(&alert.dst_ip) {
return;
}
let key = alert.src_ip.clone();
let now = Instant::now();
let should_alert = {
let mut entry = self.state.entry(key.clone()).or_insert_with(|| TimedDestSet {
dests: HashSet::new(),
window_start: now,
});
let set = entry.value_mut();
if now.duration_since(set.window_start) >= self.window {
set.dests.clear();
set.window_start = now;
}
set.dests.insert(alert.dst_ip.clone());
if set.dests.len() >= self.threshold {
Some(set.dests.len())
} else {
None
}
};
if let Some(unique_dests) = should_alert {
log!(DetectionLog::LateralMovementDetected {
src_ip: key.clone(),
unique_dests,
window_secs: LATERAL_WINDOW_SECS,
});
let event = DetectionEvent {
source: DetectionSource::Correlation,
attack_type: "threat_detected".to_string(),
confidence: 0.75,
source_ip: key.clone(),
dest_ip: alert.dst_ip.clone(),
protocol: alert.protocol,
packet_count: 0,
flow_duration_us: 0,
};
let _ = detection_tx.try_send(event);
// Reset after alerting
if let Some(mut entry) = self.state.get_mut(&key) {
entry.dests.clear();
entry.window_start = now;
}
}
}
/// Remove expired entries. Returns number of entries removed.
pub fn cleanup(&self) -> usize {
let now = Instant::now();
let window = self.window;
let before = self.state.len();
self.state
.retain(|_, set| now.duration_since(set.window_start) < window);
if self.state.len() > MAX_TRACKED_SRCS {
let excess = self.state.len() - MAX_TRACKED_SRCS;
let keys_to_remove: Vec<String> = self.state.iter().take(excess).map(|e| e.key().clone()).collect();
for key in keys_to_remove {
self.state.remove(&key);
}
}
before.saturating_sub(self.state.len())
}
}
/// Check if an IP address string represents a private/internal address.
/// RFC 1918: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
/// RFC 4193: fc00::/7 (IPv6 unique local)
pub fn is_internal_ip(ip_str: &str) -> bool {
let Ok(ip) = ip_str.parse::<IpAddr>() else {
return false;
};
match ip {
IpAddr::V4(v4) => {
let octets = v4.octets();
// 10.0.0.0/8
octets[0] == 10
// 172.16.0.0/12
|| (octets[0] == 172 && (16..=31).contains(&octets[1]))
// 192.168.0.0/16
|| (octets[0] == 192 && octets[1] == 168)
// 127.0.0.0/8 (loopback)
|| octets[0] == 127
}
IpAddr::V6(v6) => {
let segments = v6.segments();
// fc00::/7
(segments[0] & 0xfe00) == 0xfc00
// ::1 (loopback)
|| v6.is_loopback()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_internal_ip_detection() {
assert!(is_internal_ip("10.0.0.1"));
assert!(is_internal_ip("10.255.255.255"));
assert!(is_internal_ip("172.16.0.1"));
assert!(is_internal_ip("172.31.255.255"));
assert!(is_internal_ip("192.168.0.1"));
assert!(is_internal_ip("192.168.255.255"));
assert!(is_internal_ip("127.0.0.1"));
assert!(!is_internal_ip("8.8.8.8"));
assert!(!is_internal_ip("172.32.0.1"));
assert!(!is_internal_ip("192.169.0.1"));
assert!(!is_internal_ip("1.1.1.1"));
}
#[test]
fn test_internal_ipv6() {
assert!(is_internal_ip("fc00::1"));
assert!(is_internal_ip("fd12:3456:789a::1"));
assert!(is_internal_ip("::1"));
assert!(!is_internal_ip("2001:db8::1"));
assert!(!is_internal_ip("2607:f8b0::1"));
}
#[test]
fn test_invalid_ip() {
assert!(!is_internal_ip("not-an-ip"));
assert!(!is_internal_ip(""));
}
fn make_alert(src_ip: &str, dst_ip: &str) -> AlertMessage {
AlertMessage {
timestamp: 0,
flow_key: String::new(),
src_ip: src_ip.to_string(),
dst_ip: dst_ip.to_string(),
src_port: 12345,
dst_port: 445,
protocol: 6,
is_attack: true,
attack_type: Some("Exploitation".to_string()),
confidence: 0.8,
ae_score: 0.4,
packet_count: 50,
flow_duration_us: 500_000,
}
}
#[tokio::test]
async fn lateral_threshold_triggers_for_internal_only() {
let detector = LateralMovementDetector::new();
let (tx, mut rx) = mpsc::channel(64);
// Internal → external should be ignored
detector.process(&make_alert("10.0.0.1", "8.8.8.8"), &tx);
assert!(rx.try_recv().is_err());
// Internal → internal, below threshold
for i in 1..5 {
detector.process(&make_alert("10.0.0.1", &format!("10.0.1.{i}")), &tx);
}
assert!(rx.try_recv().is_err(), "Should not alert below threshold");
// 5th unique internal dest should trigger
detector.process(&make_alert("10.0.0.1", "10.0.1.5"), &tx);
let event = rx.try_recv().expect("Should alert at threshold");
assert_eq!(event.source, DetectionSource::Correlation);
}
}

View File

@ -0,0 +1,4 @@
pub mod botnet;
pub mod engine;
pub mod lateral;
pub mod scan;

View File

@ -0,0 +1,173 @@
use std::collections::HashSet;
use std::time::{Duration, Instant};
use dashmap::DashMap;
use macros::log;
use tokio::sync::mpsc;
use crate::model::detection::ml_detection::AlertMessage;
use crate::model::event::{DetectionEvent, DetectionSource};
use crate::model::log::detection::DetectionLog;
/// Window within which unique destination ports are counted per source.
const SCAN_WINDOW_SECS: u64 = 120; // 2 minutes
/// Minimum unique destination ports to trigger a scan alert.
const SCAN_THRESHOLD: usize = 20;
/// Maximum tracked source IPs to bound memory.
const MAX_TRACKED_SRCS: usize = 10_000;
struct TimedPortSet {
ports: HashSet<u16>,
window_start: Instant,
last_dst_ip: String,
}
/// Detects port scanning: a single source IP probing many destination ports.
pub struct ScanDetector {
/// src_ip → set of unique dst_ports within the time window
state: DashMap<String, TimedPortSet>,
window: Duration,
threshold: usize,
}
impl ScanDetector {
pub fn new() -> Self {
Self {
state: DashMap::new(),
window: Duration::from_secs(SCAN_WINDOW_SECS),
threshold: SCAN_THRESHOLD,
}
}
/// Process an alert and emit a DetectionEvent if the scan threshold is crossed.
pub fn process(&self, alert: &AlertMessage, detection_tx: &mpsc::Sender<DetectionEvent>) {
let key = alert.src_ip.clone();
let now = Instant::now();
let should_alert = {
let mut entry = self.state.entry(key.clone()).or_insert_with(|| TimedPortSet {
ports: HashSet::new(),
window_start: now,
last_dst_ip: alert.dst_ip.clone(),
});
let set = entry.value_mut();
// Reset window if expired
if now.duration_since(set.window_start) >= self.window {
set.ports.clear();
set.window_start = now;
}
set.ports.insert(alert.dst_port);
set.last_dst_ip = alert.dst_ip.clone();
if set.ports.len() >= self.threshold {
Some((set.ports.len(), set.last_dst_ip.clone()))
} else {
None
}
};
if let Some((unique_ports, last_dst_ip)) = should_alert {
log!(DetectionLog::ScanDetected {
src_ip: key.clone(),
unique_ports,
window_secs: SCAN_WINDOW_SECS,
});
let event = DetectionEvent {
source: DetectionSource::Correlation,
attack_type: "port_scan".to_string(),
confidence: 0.80,
source_ip: key.clone(),
dest_ip: last_dst_ip,
protocol: alert.protocol,
packet_count: 0,
flow_duration_us: 0,
};
let _ = detection_tx.try_send(event);
// Reset after alerting
if let Some(mut entry) = self.state.get_mut(&key) {
entry.ports.clear();
entry.window_start = now;
}
}
}
/// Remove expired entries. Returns number of entries removed.
pub fn cleanup(&self) -> usize {
let now = Instant::now();
let window = self.window;
let before = self.state.len();
self.state
.retain(|_, set| now.duration_since(set.window_start) < window);
if self.state.len() > MAX_TRACKED_SRCS {
let excess = self.state.len() - MAX_TRACKED_SRCS;
let keys_to_remove: Vec<String> = self.state.iter().take(excess).map(|e| e.key().clone()).collect();
for key in keys_to_remove {
self.state.remove(&key);
}
}
before.saturating_sub(self.state.len())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_alert(src_ip: &str, dst_port: u16) -> AlertMessage {
AlertMessage {
timestamp: 0,
flow_key: String::new(),
src_ip: src_ip.to_string(),
dst_ip: "192.168.1.1".to_string(),
src_port: 12345,
dst_port,
protocol: 6,
is_attack: true,
attack_type: Some("Reconnaissance".to_string()),
confidence: 0.7,
ae_score: 0.3,
packet_count: 5,
flow_duration_us: 100_000,
}
}
#[tokio::test]
async fn scan_threshold_triggers_alert() {
let detector = ScanDetector::new();
let (tx, mut rx) = mpsc::channel(64);
for port in 0..19 {
let alert = make_alert("10.0.0.1", port);
detector.process(&alert, &tx);
}
assert!(rx.try_recv().is_err(), "Should not alert below threshold");
let alert = make_alert("10.0.0.1", 19);
detector.process(&alert, &tx);
let event = rx.try_recv().expect("Should alert at threshold");
assert_eq!(event.attack_type, "port_scan");
}
#[tokio::test]
async fn different_sources_tracked_independently() {
let detector = ScanDetector::new();
let (tx, mut rx) = mpsc::channel(64);
for port in 0..15 {
detector.process(&make_alert("10.0.0.1", port), &tx);
detector.process(&make_alert("10.0.0.2", port), &tx);
}
assert!(rx.try_recv().is_err(), "Neither should alert at 15 ports");
}
}

View File

@ -0,0 +1,277 @@
use std::time::{Duration, Instant};
use dashmap::DashMap;
use macros::log;
use tokio::sync::{broadcast, mpsc};
use crate::model::detection::ml_detection::AlertMessage;
use crate::model::event::{DetectionEvent, DetectionSource};
use crate::model::log::detection::DetectionLog;
/// How often to analyze cached flows for beaconing patterns.
const ANALYSIS_INTERVAL_SECS: u64 = 30;
/// Minimum number of flow observations before computing CV.
const MIN_OBSERVATIONS: usize = 5;
/// CV threshold: values below this indicate periodic (beaconing) behavior.
/// 0 = perfectly periodic, 1 = random. C2 beacons typically have CV < 0.3.
const CV_THRESHOLD: f64 = 0.3;
/// Maximum entries in the flow cache to bound memory.
const MAX_CACHE_ENTRIES: usize = 50_000;
/// Expire entries not seen within this window.
const EXPIRY_SECS: u64 = 600; // 10 minutes
/// Cooldown between re-alerting on the same (src, dst, port) tuple.
const ALERT_COOLDOWN_SECS: u64 = 300; // 5 minutes
/// Key for tracking flow timing: (src_ip, dst_ip, dst_port).
type FlowTuple = (String, String, u16);
struct CachedFlow {
timestamps: Vec<Instant>,
last_alerted: Option<Instant>,
}
/// Detects C2 beaconing by analyzing the periodicity of flows between
/// (src_ip, dst_ip, dst_port) tuples. Uses coefficient of variation (CV)
/// of inter-arrival times: CV < 0.3 with sufficient observations = beaconing.
pub struct BeaconingDetector {
flow_cache: DashMap<FlowTuple, CachedFlow>,
detection_tx: mpsc::Sender<DetectionEvent>,
alert_rx: broadcast::Receiver<AlertMessage>,
}
impl BeaconingDetector {
pub fn new(alert_rx: broadcast::Receiver<AlertMessage>, detection_tx: mpsc::Sender<DetectionEvent>) -> Self {
Self {
flow_cache: DashMap::new(),
detection_tx,
alert_rx,
}
}
/// Spawn the beaconing detector as a background task.
pub fn start(self) {
tokio::spawn(async move { self.run().await });
}
async fn run(mut self) {
log!(DetectionLog::BeaconingDetectorStarted);
let mut analysis_interval = tokio::time::interval(Duration::from_secs(ANALYSIS_INTERVAL_SECS));
loop {
tokio::select! {
result = self.alert_rx.recv() => {
match result {
Ok(alert) => self.record_flow(&alert),
Err(broadcast::error::RecvError::Lagged(_)) => continue,
Err(broadcast::error::RecvError::Closed) => break,
}
}
_ = analysis_interval.tick() => {
self.analyze_and_alert();
self.cleanup();
}
}
}
}
fn record_flow(&self, alert: &AlertMessage) {
let key = (alert.src_ip.clone(), alert.dst_ip.clone(), alert.dst_port);
let now = Instant::now();
let mut entry = self.flow_cache.entry(key).or_insert_with(|| CachedFlow {
timestamps: Vec::new(),
last_alerted: None,
});
entry.timestamps.push(now);
// Cap stored timestamps to avoid unbounded growth per entry
if entry.timestamps.len() > 100 {
let excess = entry.timestamps.len() - 100;
entry.timestamps.drain(..excess);
}
}
fn analyze_and_alert(&self) {
let now = Instant::now();
let cooldown = Duration::from_secs(ALERT_COOLDOWN_SECS);
// Phase 1: read-lock scan to find beaconing candidates (avoids holding write locks
// across the entire 50K-entry iteration, reducing contention with record_flow).
let mut alerts: Vec<(FlowTuple, f64, usize)> = Vec::new();
for entry in self.flow_cache.iter() {
let flow = entry.value();
if flow.timestamps.len() < MIN_OBSERVATIONS {
continue;
}
if let Some(last) = flow.last_alerted
&& now.duration_since(last) < cooldown
{
continue;
}
let cv = compute_cv(&flow.timestamps);
if cv < CV_THRESHOLD {
alerts.push((entry.key().clone(), cv, flow.timestamps.len()));
}
}
// Phase 2: selective write-lock only for entries that need last_alerted update.
for (key, cv, count) in alerts {
let (src_ip, dst_ip, dst_port) = &key;
log!(DetectionLog::BeaconingDetected {
src_ip: src_ip.clone(),
dst_ip: dst_ip.clone(),
dst_port: *dst_port,
cv,
count,
});
let event = DetectionEvent {
source: DetectionSource::Beaconing,
attack_type: "c2_communication".to_string(),
confidence: (1.0 - cv / CV_THRESHOLD) as f32 * 0.5 + 0.5,
source_ip: src_ip.clone(),
dest_ip: dst_ip.clone(),
protocol: 6,
packet_count: count as u64,
flow_duration_us: 0,
};
let _ = self.detection_tx.try_send(event);
if let Some(mut entry) = self.flow_cache.get_mut(&key) {
entry.last_alerted = Some(now);
}
}
}
fn cleanup(&self) {
let now = Instant::now();
let expiry = Duration::from_secs(EXPIRY_SECS);
self.flow_cache.retain(|_, flow| {
flow.timestamps
.last()
.is_some_and(|last| now.duration_since(*last) < expiry)
});
// Enforce max capacity
if self.flow_cache.len() > MAX_CACHE_ENTRIES {
let excess = self.flow_cache.len() - MAX_CACHE_ENTRIES;
let keys_to_remove: Vec<FlowTuple> = self.flow_cache.iter().take(excess).map(|e| e.key().clone()).collect();
for key in keys_to_remove {
self.flow_cache.remove(&key);
}
}
}
}
/// Compute the coefficient of variation (std / mean) of inter-arrival times.
/// Returns f64::MAX if fewer than 2 timestamps (no intervals to compute).
fn compute_cv(timestamps: &[Instant]) -> f64 {
if timestamps.len() < 2 {
return f64::MAX;
}
let intervals: Vec<f64> = timestamps
.windows(2)
.map(|w| w[1].duration_since(w[0]).as_secs_f64())
.collect();
let n = intervals.len() as f64;
let mean = intervals.iter().sum::<f64>() / n;
if mean <= 0.0 {
return f64::MAX;
}
let variance = intervals.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / n;
let std = variance.sqrt();
std / mean
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cv_perfectly_periodic() {
// Perfectly periodic: CV should be ~0
let base = Instant::now();
let timestamps: Vec<Instant> = (0..10).map(|i| base + Duration::from_secs(i * 60)).collect();
let cv = compute_cv(&timestamps);
assert!(cv < 0.01, "Perfectly periodic CV should be ~0, got {cv}");
}
#[test]
fn cv_random_high() {
// Irregular intervals: CV should be high
let base = Instant::now();
let timestamps = vec![
base,
base + Duration::from_secs(1),
base + Duration::from_secs(100),
base + Duration::from_secs(101),
base + Duration::from_secs(500),
base + Duration::from_secs(501),
];
let cv = compute_cv(&timestamps);
assert!(cv > 0.5, "Random intervals CV should be high, got {cv}");
}
#[test]
fn cv_with_slight_jitter() {
// Periodic with small jitter: CV should be low but > 0
let base = Instant::now();
let timestamps = vec![
base,
base + Duration::from_millis(60_000),
base + Duration::from_millis(121_000), // 61s interval
base + Duration::from_millis(179_000), // 58s interval
base + Duration::from_millis(240_000), // 61s interval
base + Duration::from_millis(299_000), // 59s interval
];
let cv = compute_cv(&timestamps);
assert!(cv < 0.3, "Slight jitter CV should be < 0.3, got {cv}");
}
#[test]
fn cv_insufficient_data() {
let base = Instant::now();
assert_eq!(compute_cv(&[base]), f64::MAX);
assert_eq!(compute_cv(&[]), f64::MAX);
}
#[tokio::test]
async fn beaconing_detector_records_and_detects() {
let (alert_tx, alert_rx) = broadcast::channel(64);
let (detection_tx, mut detection_rx) = mpsc::channel(64);
let detector = BeaconingDetector::new(alert_rx, detection_tx);
// Manually record periodic flows
let base = Instant::now();
let key = ("10.0.0.1".to_string(), "1.2.3.4".to_string(), 443_u16);
detector.flow_cache.insert(
key,
CachedFlow {
timestamps: (0..10).map(|i| base + Duration::from_secs(i * 60)).collect(),
last_alerted: None,
},
);
detector.analyze_and_alert();
let event = detection_rx.try_recv().expect("Should detect beaconing");
assert_eq!(event.source, DetectionSource::Beaconing);
assert_eq!(event.attack_type, "c2_communication");
drop(alert_tx);
}
}

View File

@ -0,0 +1,2 @@
pub mod beaconing;
pub mod orchestrator;

View File

@ -0,0 +1,205 @@
use std::num::NonZero;
use std::sync::Arc;
use std::time::{Duration, Instant};
use macros::log;
use tokio::sync::mpsc;
use crate::infrastructure::communication_manager::CommunicationManager;
use crate::infrastructure::geoip::GeoIpService;
use crate::model::error::system::SystemError;
use crate::model::event::{DetectionEvent, DetectionSource, ThreatDetectedEvent};
use crate::model::log::detection::DetectionLog;
/// Dedup window: detections for the same (source_ip, attack_type) within this window
/// are suppressed after the first emission.
const DEDUP_WINDOW_SECS: u64 = 30;
/// How often to sweep expired dedup entries.
const CLEANUP_INTERVAL_SECS: u64 = 60;
/// Repeat offender detection: same IP within this duration counts as repeat.
const REPEAT_OFFENDER_WINDOW_SECS: u64 = 2 * 60 * 60; // 2 hours
/// Maximum dedup entries to prevent unbounded memory growth under sustained attack.
const MAX_DEDUP_ENTRIES: usize = 50_000;
struct DedupEntry {
sources: Vec<DetectionSource>,
emitted_at: Instant,
}
/// Coordinates detections from multiple sources (ML, future: rules, correlation, threat feeds).
/// Deduplicates, enriches with GeoIP/hit count/repeat offender, and emits ThreatDetectedEvent.
pub struct DetectionOrchestrator {
rx: mpsc::Receiver<DetectionEvent>,
comm: Arc<CommunicationManager>,
geoip: Option<Arc<GeoIpService>>,
// Enrichment state
// SAFETY: NonZero::new on a literal is infallible.
src_ip_counts: lru::LruCache<String, u32>,
repeat_tracker: lru::LruCache<String, Instant>,
// Dedup state — LRU-bounded to prevent unbounded growth under sustained attack
dedup: lru::LruCache<(String, String), DedupEntry>,
dedup_window: Duration,
}
impl DetectionOrchestrator {
pub fn new(
rx: mpsc::Receiver<DetectionEvent>,
comm: Arc<CommunicationManager>,
geoip: Option<Arc<GeoIpService>>,
) -> Self {
Self {
rx,
comm,
geoip,
// SAFETY: NonZero::new on a non-zero literal is infallible.
src_ip_counts: lru::LruCache::new(NonZero::new(10_000).unwrap()),
repeat_tracker: lru::LruCache::new(NonZero::new(5_000).unwrap()),
dedup: lru::LruCache::new(NonZero::new(MAX_DEDUP_ENTRIES).unwrap()),
dedup_window: Duration::from_secs(DEDUP_WINDOW_SECS),
}
}
/// Spawn the orchestrator as a background task.
pub fn start(self) {
tokio::spawn(async move { self.run().await });
}
async fn run(mut self) {
log!(DetectionLog::OrchestratorStarted);
let mut cleanup_interval = tokio::time::interval(Duration::from_secs(CLEANUP_INTERVAL_SECS));
loop {
tokio::select! {
event = self.rx.recv() => {
match event {
Some(detection) => self.handle_detection(detection).await,
None => break, // All senders dropped
}
}
_ = cleanup_interval.tick() => {
self.cleanup_expired();
}
}
}
}
async fn handle_detection(&mut self, event: DetectionEvent) {
let key = (event.source_ip.clone(), event.attack_type.clone());
let now = Instant::now();
// Dedup check
if let Some(entry) = self.dedup.get(&key)
&& now.checked_duration_since(entry.emitted_at).unwrap_or(Duration::ZERO) < self.dedup_window
{
// Within window: add source attribution but don't re-emit
if !entry.sources.contains(&event.source) {
// Re-get as mutable to update sources
if let Some(entry) = self.dedup.get_mut(&key) {
entry.sources.push(event.source.clone());
}
}
log!(DetectionLog::DetectionDeduplicated {
source_ip: event.source_ip,
attack_type: event.attack_type,
});
return;
}
// Enrich and emit
let threat_event = self.enrich(&event).await;
let sources = vec![event.source.clone()];
log!(DetectionLog::DetectionEmitted {
source_ip: event.source_ip.clone(),
attack_type: event.attack_type.clone(),
confidence: event.confidence,
sources_count: sources.len(),
});
// Record dedup entry (LRU-bounded)
self.dedup.put(
key,
DedupEntry {
sources,
emitted_at: now,
},
);
if let Err(e) = self.comm.publish_event(threat_event).await {
log!(SystemError::MlSoarBridgeFailed(e));
}
}
async fn enrich(&mut self, event: &DetectionEvent) -> ThreatDetectedEvent {
let src_ip = &event.source_ip;
// Compute packet rate
let packet_rate = if event.flow_duration_us > 0 {
event.packet_count as f64 / (event.flow_duration_us as f64 / 1_000_000.0)
} else {
0.0
};
// Update hit count (LRU bounded)
let hit_count = match self.src_ip_counts.get_mut(src_ip) {
Some(c) => {
*c = c.saturating_add(1);
*c
}
None => {
self.src_ip_counts.put(src_ip.clone(), 1);
1
}
};
// Check repeat offender (same IP within window)
let repeat_window = Duration::from_secs(REPEAT_OFFENDER_WINDOW_SECS);
let now = Instant::now();
let is_repeat = self
.repeat_tracker
.get(src_ip)
.is_some_and(|last| now.checked_duration_since(*last).unwrap_or(Duration::ZERO) < repeat_window);
self.repeat_tracker.put(src_ip.clone(), now);
// GeoIP lookup
let geoip_country = if let Some(ref svc) = self.geoip {
if let Ok(ip) = src_ip.parse() {
svc.lookup(ip).await.ok().flatten().and_then(|loc| loc.country_code)
} else {
None
}
} else {
None
};
ThreatDetectedEvent {
attack_type: event.attack_type.clone(),
confidence: event.confidence,
source_ip: event.source_ip.clone(),
dest_ip: event.dest_ip.clone(),
flow_count: hit_count,
packet_rate,
protocol: event.protocol,
geoip_country,
is_repeat_offender: is_repeat,
sources: vec![event.source.clone()],
}
}
fn cleanup_expired(&mut self) {
let now = Instant::now();
let window = self.dedup_window;
// Pop expired entries from the LRU (oldest entries are least recently used)
while let Some((_, entry)) = self.dedup.peek_lru() {
if now.checked_duration_since(entry.emitted_at).unwrap_or(Duration::ZERO) >= window {
self.dedup.pop_lru();
} else {
break;
}
}
}
}

View File

@ -12,8 +12,6 @@ pub struct DnsFilterService {
dns_filter: Arc<DnsFilter>,
}
const MAX_DNS_DOMAINS_PER_REQUEST: usize = 1000;
impl DnsFilterService {
pub fn new(db: Arc<dyn RepositoryPort>, dns_filter: Arc<DnsFilter>) -> Self {
Self { db, dns_filter }
@ -24,10 +22,18 @@ impl DnsFilterService {
}
pub fn add_domains(&self, domains: &[String]) -> Result<usize, Error> {
if domains.len() > MAX_DNS_DOMAINS_PER_REQUEST {
let max_domains: usize = self
.db
.get_setting("dns_max_domains_per_request")
.ok()
.flatten()
.and_then(|v| v.parse().ok())
.unwrap_or(1000);
if domains.len() > max_domains {
return Err(MiscError::ValidationError {
message: format!("too many domains (max {})", MAX_DNS_DOMAINS_PER_REQUEST),
}.into());
message: format!("too many domains (max {})", max_domains),
}
.into());
}
// eBPF first
for domain in domains {

View File

@ -8,8 +8,8 @@ use common::model::port_rule::PortRule;
use tokio::sync::RwLock;
use crate::model::direction::FlowDirection;
use crate::model::error::ebpf::EbpfError;
use crate::model::error::Error;
use crate::model::error::ebpf::EbpfError;
use crate::model::ip_address::NativeConvert;
use crate::model::list_type::ListType;
@ -165,9 +165,7 @@ impl<T: NativeConvert + Pod> MapWrapper<T> {
Err(EbpfError::RuleReachLimit)?;
}
self.map
.insert(ip, rule, 0)
.map_err(EbpfError::MapOperationError)?;
self.map.insert(ip, rule, 0).map_err(EbpfError::MapOperationError)?;
Ok(())
}
@ -189,9 +187,7 @@ impl<T: NativeConvert + Pod> MapWrapper<T> {
if rule.is_empty() {
self.map.remove(&ip).map_err(EbpfError::MapOperationError)?;
} else {
self.map
.insert(ip, rule, 0)
.map_err(EbpfError::MapOperationError)?;
self.map.insert(ip, rule, 0).map_err(EbpfError::MapOperationError)?;
}
Ok(())
}

View File

@ -3,8 +3,8 @@ use std::collections::HashSet;
use common::model::dns_name::DnsName;
use parking_lot::RwLock;
use crate::model::error::misc::MiscError;
use crate::model::error::Error;
use crate::model::error::misc::MiscError;
pub struct DnsFilter {
blacklist: RwLock<HashSet<DnsName>>,
@ -30,11 +30,7 @@ impl DnsFilter {
}
pub fn list_domains(&self) -> Vec<String> {
self.blacklist
.read()
.iter()
.filter_map(wire_format_to_domain)
.collect()
self.blacklist.read().iter().filter_map(wire_format_to_domain).collect()
}
/// Check if a DNS query name (in wire format) or any of its parent domains is blacklisted.
@ -67,8 +63,7 @@ impl DnsFilter {
let mut parent = DnsName::zeroed();
let remaining = name_len - offset;
parent.data[..remaining.min(128)]
.copy_from_slice(&name.data[offset..offset + remaining.min(128)]);
parent.data[..remaining.min(128)].copy_from_slice(&name.data[offset..offset + remaining.min(128)]);
if bl.contains(&parent) {
return true;
}

View File

@ -1,5 +1,5 @@
use std::sync::Arc;
use std::mem;
use std::sync::Arc;
use std::time::Duration;
use aya::maps::{MapData, RingBuf};
@ -9,10 +9,9 @@ use common::define::drop_reason::*;
use common::model::drop_event::DropEvent as RawDropEvent;
use parking_lot::Mutex;
use crate::model::config::constants::DROP_CHANNEL_CAPACITY;
use crate::model::drop_event::{DropCounters, DropEventMessage};
const DROP_CHANNEL_CAPACITY: usize = 100;
pub struct DropMonitor {
broadcast_tx: broadcast::Sender<DropEventMessage>,
counters: Mutex<DropCounters>,
@ -82,8 +81,14 @@ impl Default for DropMonitor {
fn format_ips(raw: &RawDropEvent) -> (String, String) {
match raw.ip_version {
4 => {
let src = format!("{}.{}.{}.{}", raw.src_ip[0], raw.src_ip[1], raw.src_ip[2], raw.src_ip[3]);
let dst = format!("{}.{}.{}.{}", raw.dst_ip[0], raw.dst_ip[1], raw.dst_ip[2], raw.dst_ip[3]);
let src = format!(
"{}.{}.{}.{}",
raw.src_ip[0], raw.src_ip[1], raw.src_ip[2], raw.src_ip[3]
);
let dst = format!(
"{}.{}.{}.{}",
raw.dst_ip[0], raw.dst_ip[1], raw.dst_ip[2], raw.dst_ip[3]
);
(src, dst)
}
_ => {
@ -114,10 +119,7 @@ fn reason_to_str(reason: u8) -> &'static str {
}
/// Start the ring buffer consumer as a tokio task. Returns a shutdown sender.
pub async fn start_consumer(
ring_buf: RingBuf<MapData>,
monitor: Arc<DropMonitor>,
) -> oneshot::Sender<()> {
pub async fn start_consumer(ring_buf: RingBuf<MapData>, monitor: Arc<DropMonitor>) -> oneshot::Sender<()> {
let (shutdown_tx, mut shutdown_rx) = oneshot::channel();
tokio::spawn(async move {

View File

@ -1,21 +1,21 @@
use std::collections::{HashMap as StdHashMap, HashSet};
use std::sync::Arc;
use aya::maps::lpm_trie::{Key, LpmTrie};
use aya::maps::MapData;
use aya::Ebpf;
use aya::maps::MapData;
use aya::maps::lpm_trie::{Key, LpmTrie};
use ipnetwork::IpNetwork;
use maxminddb::{geoip2, Reader};
use maxminddb::{Reader, geoip2};
use parking_lot::RwLock;
use crate::infrastructure::app_config::AppConfig;
use crate::model::error::Error;
use crate::model::error::ebpf::EbpfError;
use crate::model::error::misc::MiscError;
use crate::model::error::Error;
/// Pre-indexed GeoIP prefix table, built once at startup.
struct GeoIndex {
v4: StdHashMap<String, Vec<(u32, u32)>>, // country -> [(ip_be, prefix_len)]
v4: StdHashMap<String, Vec<(u32, u32)>>, // country -> [(ip_be, prefix_len)]
v6: StdHashMap<String, Vec<(u128, u32)>>,
}
@ -35,11 +35,10 @@ impl GeoBlock {
let v6_trie = LpmTrie::try_from(v6_map).map_err(EbpfError::MapOperationError)?;
let db_path = &app_config.misc.geoip_db_name;
let reader = Reader::open_readfile(db_path)
.map_err(|e| MiscError::GeoIPDatabaseError {
path: db_path.clone(),
reason: e.to_string(),
})?;
let reader = Reader::open_readfile(db_path).map_err(|e| MiscError::GeoIPDatabaseError {
path: db_path.clone(),
reason: e.to_string(),
})?;
let index = Self::build_index(&reader)?;
@ -61,7 +60,9 @@ impl GeoBlock {
for result in iter {
let Ok(lookup) = result else { continue };
let Ok(network) = lookup.network() else { continue };
let Ok(Some(city)) = lookup.decode::<geoip2::City>() else { continue };
let Ok(Some(city)) = lookup.decode::<geoip2::City>() else {
continue;
};
let Some(code) = city.country.iso_code else { continue };
let code = code.to_uppercase();
@ -77,7 +78,9 @@ impl GeoBlock {
for result in iter {
let Ok(lookup) = result else { continue };
let Ok(network) = lookup.network() else { continue };
let Ok(Some(city)) = lookup.decode::<geoip2::City>() else { continue };
let Ok(Some(city)) = lookup.decode::<geoip2::City>() else {
continue;
};
let Some(code) = city.country.iso_code else { continue };
let code = code.to_uppercase();
@ -163,20 +166,14 @@ impl GeoBlock {
}
fn clear_trie_v4(trie: &mut LpmTrie<MapData, u32, u8>) {
let keys: Vec<Key<u32>> = trie.iter()
.filter_map(|r| r.ok())
.map(|(k, _)| k)
.collect();
let keys: Vec<Key<u32>> = trie.iter().filter_map(|r| r.ok()).map(|(k, _)| k).collect();
for key in keys {
let _ = trie.remove(&key);
}
}
fn clear_trie_v6(trie: &mut LpmTrie<MapData, u128, u8>) {
let keys: Vec<Key<u128>> = trie.iter()
.filter_map(|r| r.ok())
.map(|(k, _)| k)
.collect();
let keys: Vec<Key<u128>> = trie.iter().filter_map(|r| r.ok()).map(|(k, _)| k).collect();
for key in keys {
let _ = trie.remove(&key);
}

View File

@ -2,8 +2,8 @@ pub mod access_control;
pub mod dns_filter;
pub mod drop_monitor;
pub mod geo_block;
pub mod rate_limit;
pub mod protocol_filter;
pub mod rate_limit;
pub mod xsk_manager;
use std::sync::Arc;
@ -19,14 +19,14 @@ use crate::core::ebpf::access_control::AccessControl;
use crate::core::ebpf::dns_filter::DnsFilter;
use crate::core::ebpf::drop_monitor::DropMonitor;
use crate::core::ebpf::geo_block::GeoBlock;
use crate::core::ebpf::rate_limit::RateLimitConfig;
use crate::core::ebpf::protocol_filter::ProtocolFilter;
use crate::core::ebpf::rate_limit::RateLimitConfig;
use crate::core::ebpf::xsk_manager::XskManager;
use crate::infrastructure::app_config::AppConfig;
use crate::core::ml::engine::Engine;
use crate::infrastructure::app_config::AppConfig;
use crate::model::error::Error;
use crate::model::error::ebpf::EbpfError;
use crate::model::error::system::SystemError;
use crate::model::error::Error;
pub struct EbpfServices {
pub xsk_manager: Arc<XskManager>,

View File

@ -8,8 +8,8 @@ use common::model::ip_address::{AddrPortV4, AddrPortV6, IPv4, IPv6};
use common::model::placeholder::PlaceHolder;
use tokio::sync::RwLock;
use crate::model::error::ebpf::EbpfError;
use crate::model::error::Error;
use crate::model::error::ebpf::EbpfError;
use crate::model::ip_address::NativeConvert;
pub struct ProtocolFilter {
@ -190,9 +190,7 @@ impl WhiteListControl {
fn is_white_list_enable(&self) -> bool {
match self.map.get(&0, 0) {
Ok(status) => {
status != 0
}
Ok(status) => status != 0,
Err(_) => false,
}
}
@ -280,9 +278,7 @@ impl<T: NativeConvert + Pod> EntryMap<T> {
fn add(&mut self, key: T::Native) -> Result<(), Error> {
let key = T::from_native(key);
self.map
.insert(key, 0_u8, 0)
.map_err(EbpfError::MapOperationError)?;
self.map.insert(key, 0_u8, 0).map_err(EbpfError::MapOperationError)?;
Ok(())
}

View File

@ -1,9 +1,9 @@
use aya::maps::{Array, MapData};
use aya::Ebpf;
use aya::maps::{Array, MapData};
use parking_lot::Mutex;
use crate::model::error::ebpf::EbpfError;
use crate::model::error::Error;
use crate::model::error::ebpf::EbpfError;
pub struct RateLimitConfig {
config_map: Mutex<Array<MapData, u64>>,
@ -13,51 +13,83 @@ impl RateLimitConfig {
pub fn new(ebpf: &mut Ebpf) -> Result<Self, Error> {
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) })
Ok(Self {
config_map: Mutex::new(config_map),
})
}
pub fn set_packet_rate(&self, rate: u64) -> Result<(), Error> {
self.config_map.lock().set(0, rate, 0).map_err(EbpfError::MapOperationError)?;
self.config_map
.lock()
.set(0, rate, 0)
.map_err(EbpfError::MapOperationError)?;
Ok(())
}
pub fn set_syn_rate(&self, rate: u64) -> Result<(), Error> {
self.config_map.lock().set(1, rate, 0).map_err(EbpfError::MapOperationError)?;
self.config_map
.lock()
.set(1, rate, 0)
.map_err(EbpfError::MapOperationError)?;
Ok(())
}
pub fn set_udp_rate(&self, rate: u64) -> Result<(), Error> {
self.config_map.lock().set(2, rate, 0).map_err(EbpfError::MapOperationError)?;
self.config_map
.lock()
.set(2, rate, 0)
.map_err(EbpfError::MapOperationError)?;
Ok(())
}
pub fn set_dns_rate(&self, rate: u64) -> Result<(), Error> {
self.config_map.lock().set(3, rate, 0).map_err(EbpfError::MapOperationError)?;
self.config_map
.lock()
.set(3, rate, 0)
.map_err(EbpfError::MapOperationError)?;
Ok(())
}
pub fn set_window_ns(&self, ns: u64) -> Result<(), Error> {
self.config_map.lock().set(4, ns, 0).map_err(EbpfError::MapOperationError)?;
self.config_map
.lock()
.set(4, ns, 0)
.map_err(EbpfError::MapOperationError)?;
Ok(())
}
pub fn get_packet_rate(&self) -> Result<u64, Error> {
self.config_map.lock().get(&0, 0).map_err(|e| EbpfError::MapOperationError(e).into())
self.config_map
.lock()
.get(&0, 0)
.map_err(|e| EbpfError::MapOperationError(e).into())
}
pub fn get_syn_rate(&self) -> Result<u64, Error> {
self.config_map.lock().get(&1, 0).map_err(|e| EbpfError::MapOperationError(e).into())
self.config_map
.lock()
.get(&1, 0)
.map_err(|e| EbpfError::MapOperationError(e).into())
}
pub fn get_udp_rate(&self) -> Result<u64, Error> {
self.config_map.lock().get(&2, 0).map_err(|e| EbpfError::MapOperationError(e).into())
self.config_map
.lock()
.get(&2, 0)
.map_err(|e| EbpfError::MapOperationError(e).into())
}
pub fn get_dns_rate(&self) -> Result<u64, Error> {
self.config_map.lock().get(&3, 0).map_err(|e| EbpfError::MapOperationError(e).into())
self.config_map
.lock()
.get(&3, 0)
.map_err(|e| EbpfError::MapOperationError(e).into())
}
pub fn get_window_ns(&self) -> Result<u64, Error> {
self.config_map.lock().get(&4, 0).map_err(|e| EbpfError::MapOperationError(e).into())
self.config_map
.lock()
.get(&4, 0)
.map_err(|e| EbpfError::MapOperationError(e).into())
}
}

View File

@ -6,9 +6,9 @@ use std::sync::Arc;
use std::thread;
use std::time::Duration;
use aya::maps::{MapData, XskMap};
use aya::Ebpf;
use crossbeam::channel::{bounded, Receiver, Sender};
use aya::maps::{MapData, XskMap};
use crossbeam::channel::{Receiver, Sender, bounded};
use crossbeam::queue::SegQueue;
use macros::log;
use parking_lot::Mutex;
@ -17,14 +17,14 @@ use xsk_rs::config::{BindFlags, FrameSize, Interface, LibxdpFlags, QueueSize, So
use xsk_rs::{CompQueue, FillQueue, FrameDesc, RxQueue, Socket, TxQueue, Umem};
use crate::core::ebpf::dns_filter::DnsFilter;
use crate::infrastructure::app_config::AppConfig;
use crate::core::ml::engine::Engine;
use crate::core::ml::flow_tracker::FlowTracker;
use crate::infrastructure::app_config::AppConfig;
use crate::model::config::NetworkConfig;
use crate::model::direction::Direction;
use crate::model::error::Error;
use crate::model::error::ebpf::EbpfError;
use crate::model::error::system::SystemError;
use crate::model::error::Error;
use crate::model::log::ebpf::EbpfLog;
use crate::utils::packet_parser::parse_packet;
@ -37,10 +37,12 @@ struct BufferPool {
impl BufferPool {
fn new(capacity: usize, buffer_size: usize) -> Self {
let buffers = (0..capacity)
.map(|_| Vec::with_capacity(buffer_size))
.collect();
Self { buffers, buffer_size, max_capacity: capacity * 2 }
let buffers = (0..capacity).map(|_| Vec::with_capacity(buffer_size)).collect();
Self {
buffers,
buffer_size,
max_capacity: capacity * 2,
}
}
fn get(&mut self) -> Vec<u8> {
@ -80,7 +82,12 @@ impl XskManager {
})
}
pub fn run(&self, ml_engine: Option<Arc<Engine>>, dns_filter: Option<Arc<DnsFilter>>, shutdowns: &SegQueue<oneshot::Sender<()>>) -> Result<(), Error> {
pub fn run(
&self,
ml_engine: Option<Arc<Engine>>,
dns_filter: Option<Arc<DnsFilter>>,
shutdowns: &SegQueue<oneshot::Sender<()>>,
) -> Result<(), Error> {
let network = self.app_config.network.clone();
let combined_queue_count = network.combined_queue_count;
@ -306,7 +313,12 @@ impl XskPair {
Ok(nb_completed)
}
fn process_rx_queue(&mut self, forward_tx: &Sender<Vec<u8>>, buffer_pool: &mut BufferPool, rx_descs: &mut [FrameDesc]) -> Result<usize, EbpfError> {
fn process_rx_queue(
&mut self,
forward_tx: &Sender<Vec<u8>>,
buffer_pool: &mut BufferPool,
rx_descs: &mut [FrameDesc],
) -> Result<usize, EbpfError> {
let rx_count = unsafe { self.rx.consume(rx_descs) };
if rx_count > 0 {
@ -328,15 +340,17 @@ impl XskPair {
// DNS blacklist check — drop blacklisted DNS queries before forwarding
if let Some(ref dns) = self.dns_filter
&& let Some((dns_name, name_len)) = DnsFilter::parse_query_name(raw)
&& dns.is_blacklisted(&dns_name, name_len) {
continue;
&& dns.is_blacklisted(&dns_name, name_len)
{
continue;
}
// Parse directly from UMEM (zero-copy for ML path).
// Only clone for the forwarding path afterwards.
if let Some(ref tracker) = self.tracker
&& let Some((packet_info, _)) = parse_packet(raw) {
tracker.lock().process_packet(packet_info, is_ingress);
&& let Some((packet_info, _)) = parse_packet(raw)
{
tracker.lock().process_packet(packet_info, is_ingress);
}
// Clone into pooled buffer for forwarding
@ -367,7 +381,12 @@ impl XskPair {
Ok(rx_count)
}
fn process_tx_queue(&mut self, forward_rx: &Receiver<Vec<u8>>, buffer_pool: &mut BufferPool, comp_descs: &mut [FrameDesc]) -> Result<usize, EbpfError> {
fn process_tx_queue(
&mut self,
forward_rx: &Receiver<Vec<u8>>,
buffer_pool: &mut BufferPool,
comp_descs: &mut [FrameDesc],
) -> Result<usize, EbpfError> {
let mut packets_to_send = Vec::with_capacity(64);
while let Ok(packet) = forward_rx.try_recv() {
packets_to_send.push(packet);
@ -426,8 +445,9 @@ impl XskPair {
}
if let Err(e) = self.tx.wakeup()
&& e.kind() != std::io::ErrorKind::WouldBlock {
log!(EbpfLog::TXWakeupFailed(e.to_string()));
&& e.kind() != std::io::ErrorKind::WouldBlock
{
log!(EbpfLog::TXWakeupFailed(e.to_string()));
}
// Log dropped packets when frames < packets

View File

@ -14,54 +14,40 @@ use crate::model::error::Error;
/// If a key is missing the report uses empty/zero defaults.
pub fn generate_weekly_report(db: &dyn RepositoryPort) -> Result<String, Error> {
let threats_count = db
.get_setting("weekly_threats_count")
?
.get_setting("weekly_threats_count")?
.unwrap_or_else(|| "0".to_string());
let top_ips_json = db
.get_setting("weekly_top_ips")
?
.unwrap_or_else(|| "[]".to_string());
let top_ips_json = db.get_setting("weekly_top_ips")?.unwrap_or_else(|| "[]".to_string());
let threat_breakdown_json = db
.get_setting("weekly_threat_breakdown")
?
.get_setting("weekly_threat_breakdown")?
.unwrap_or_else(|| "{}".to_string());
let bandwidth = db
.get_setting("weekly_bandwidth_bytes")
?
.get_setting("weekly_bandwidth_bytes")?
.unwrap_or_else(|| "0".to_string());
let health_json = db
.get_setting("weekly_system_health")
?
.unwrap_or_else(|| {
serde_json::json!({
"cpu_percent": 0.0,
"memory_percent": 0.0,
"disk_percent": 0.0
})
.to_string()
});
let health_json = db.get_setting("weekly_system_health")?.unwrap_or_else(|| {
serde_json::json!({
"cpu_percent": 0.0,
"memory_percent": 0.0,
"disk_percent": 0.0
})
.to_string()
});
// ── Parse JSON blobs ───────────────────────────────────────────────
let top_ips: Vec<serde_json::Value> =
serde_json::from_str(&top_ips_json).unwrap_or_default();
let top_ips: Vec<serde_json::Value> = serde_json::from_str(&top_ips_json).unwrap_or_default();
let threat_breakdown: serde_json::Map<String, serde_json::Value> =
serde_json::from_str(&threat_breakdown_json).unwrap_or_default();
let health: serde_json::Value =
serde_json::from_str(&health_json).unwrap_or_default();
let health: serde_json::Value = serde_json::from_str(&health_json).unwrap_or_default();
// ── Build HTML ─────────────────────────────────────────────────────
let bandwidth_mb = bandwidth
.parse::<f64>()
.unwrap_or(0.0)
/ 1_048_576.0;
let bandwidth_mb = bandwidth.parse::<f64>().unwrap_or(0.0) / 1_048_576.0;
let mut top_ips_rows = String::new();
for (i, entry) in top_ips.iter().enumerate().take(5) {

View File

@ -1,6 +1,7 @@
use crate::interface::port::repository::RepositoryPort;
use crate::model::error::notification::NotificationError;
use crate::interface::port::secret_store::SecretStorePort;
use crate::model::error::Error;
use crate::model::error::notification::NotificationError;
use lettre::message::header::ContentType;
use lettre::transport::smtp::authentication::Credentials;
use lettre::{Message, SmtpTransport, Transport};
@ -15,6 +16,8 @@ pub struct SmtpClient {
port: u16,
username: String,
password: String,
/// The sender email address. Falls back to `username` if not set.
sender: String,
}
impl SmtpClient {
@ -22,7 +25,12 @@ impl SmtpClient {
///
/// Returns `None` if any required setting (`smtp_host`, `smtp_port`,
/// `smtp_username`, `smtp_password`) is missing.
pub fn from_database(db: &dyn RepositoryPort) -> Result<Option<Self>, Error> {
/// If a `SecretStorePort` is provided, reads the password from the secret store
/// (falling back to the settings table for backward compat before migration).
pub fn from_database(
db: &dyn RepositoryPort,
secrets: Option<&dyn SecretStorePort>,
) -> Result<Option<Self>, Error> {
let host = match db.get_setting("smtp_host")? {
Some(v) if !v.is_empty() => v,
_ => return Ok(None),
@ -35,32 +43,111 @@ impl SmtpClient {
Some(v) if !v.is_empty() => v,
_ => return Ok(None),
};
let password = match db.get_setting("smtp_password")? {
// Try secret store first, fall back to settings
let password = Self::resolve_smtp_password(db, secrets)?;
let password = match password {
Some(v) if !v.is_empty() => v,
_ => return Ok(None),
};
let port: u16 = port_str.parse().unwrap_or(587);
// smtp_sender overrides username as the From address.
// Fall back to username if smtp_sender is not configured.
let sender = match db.get_setting("smtp_sender")? {
Some(v) if !v.is_empty() => v,
_ => username.clone(),
};
// Validate that the sender looks like an email address
if !sender.contains('@') {
return Ok(None);
}
Ok(Some(Self {
host,
port,
username,
password,
sender,
}))
}
/// Try to construct an `SmtpClient` from a SOAR port (which also provides `get_setting`).
/// Same logic as `from_database`, but accepts `&dyn SoarPort` instead of `&dyn RepositoryPort`.
pub fn from_soar_port(
db: &dyn crate::interface::port::soar::SoarPort,
secrets: Option<&dyn SecretStorePort>,
) -> Result<Option<Self>, Error> {
let host = match db.get_setting("smtp_host")? {
Some(v) if !v.is_empty() => v,
_ => return Ok(None),
};
let port_str = match db.get_setting("smtp_port")? {
Some(v) if !v.is_empty() => v,
_ => return Ok(None),
};
let username = match db.get_setting("smtp_username")? {
Some(v) if !v.is_empty() => v,
_ => 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),
},
};
let port: u16 = port_str.parse().unwrap_or(587);
let sender = match db.get_setting("smtp_sender")? {
Some(v) if !v.is_empty() => v,
_ => username.clone(),
};
if !sender.contains('@') {
return Ok(None);
}
Ok(Some(Self {
host,
port,
username,
password,
sender,
}))
}
/// 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),
}
}
/// Send an HTML email using the configured SMTP transport.
pub fn send(&self, to: &str, subject: &str, html_body: &str) -> Result<(), Error> {
let from_addr = self.username.parse().map_err(|e| {
NotificationError::InvalidAddress {
reason: format!("invalid from address: {e}"),
}
let from_addr = self.sender.parse().map_err(|e| NotificationError::InvalidAddress {
reason: format!("invalid from address: {e}"),
})?;
let to_addr = to.parse().map_err(|e| {
NotificationError::InvalidAddress {
reason: format!("invalid to address: {e}"),
}
let to_addr = to.parse().map_err(|e| NotificationError::InvalidAddress {
reason: format!("invalid to address: {e}"),
})?;
let email = Message::builder()
@ -69,29 +156,39 @@ impl SmtpClient {
.subject(subject)
.header(ContentType::TEXT_HTML)
.body(html_body.to_string())
.map_err(|e| {
NotificationError::MessageBuildFailed {
reason: e.to_string(),
}
})?;
.map_err(|e| NotificationError::MessageBuildFailed { reason: e.to_string() })?;
let creds = Credentials::new(self.username.clone(), self.password.clone());
let mailer = SmtpTransport::starttls_relay(&self.host)
.map_err(|e| {
NotificationError::SmtpConnectionFailed {
reason: e.to_string(),
}
})?
.port(self.port)
.credentials(creds)
.build();
mailer.send(&email).map_err(|e| {
NotificationError::SmtpSendFailed {
reason: e.to_string(),
let mailer = match self.port {
465 => {
// Implicit TLS (SMTPS)
SmtpTransport::relay(&self.host)
.map_err(|e| NotificationError::SmtpConnectionFailed { reason: e.to_string() })?
.port(self.port)
.credentials(creds)
.build()
}
})?;
25 | 587 => {
// STARTTLS (standard submission ports)
SmtpTransport::starttls_relay(&self.host)
.map_err(|e| NotificationError::SmtpConnectionFailed { reason: e.to_string() })?
.port(self.port)
.credentials(creds)
.build()
}
_ => {
// Non-standard port — use unencrypted transport with credentials
SmtpTransport::builder_dangerous(&self.host)
.port(self.port)
.credentials(creds)
.build()
}
};
mailer
.send(&email)
.map_err(|e| NotificationError::SmtpSendFailed { reason: e.to_string() })?;
Ok(())
}
@ -101,16 +198,18 @@ impl SmtpClient {
/// report (Monday 08:00 local time) and dispatches it via SMTP.
pub struct ReportScheduler {
db: Arc<dyn RepositoryPort>,
secrets: Option<Arc<dyn SecretStorePort>>,
}
impl ReportScheduler {
pub fn new(db: Arc<dyn RepositoryPort>) -> Self {
Self { db }
pub fn new(db: Arc<dyn RepositoryPort>, secrets: Option<Arc<dyn SecretStorePort>>) -> Self {
Self { db, secrets }
}
/// Spawn a background tokio task that runs the weekly check loop.
pub fn run(&self) -> tokio::task::JoinHandle<()> {
let db = Arc::clone(&self.db);
let secrets = self.secrets.clone();
tokio::spawn(async move {
info!("Weekly report scheduler started");
let mut interval = time::interval(Duration::from_secs(3600));
@ -123,7 +222,7 @@ impl ReportScheduler {
info!("Weekly report window reached — preparing report");
let smtp = match SmtpClient::from_database(&*db) {
let smtp = match SmtpClient::from_database(&*db, secrets.as_deref()) {
Ok(Some(client)) => client,
Ok(None) => {
warn!(
@ -154,13 +253,8 @@ impl ReportScheduler {
}
};
let subject = format!(
"NetGuardia Weekly Report — {}",
chrono::Local::now().format("%Y-%m-%d")
);
let send_result =
tokio::task::spawn_blocking(move || smtp.send(&recipient, &subject, &html))
.await;
let subject = format!("NetGuardia Weekly Report — {}", chrono::Local::now().format("%Y-%m-%d"));
let send_result = tokio::task::spawn_blocking(move || smtp.send(&recipient, &subject, &html)).await;
match send_result {
Ok(Ok(())) => info!("Weekly report sent successfully"),

View File

@ -20,16 +20,24 @@ impl AttackAggregator {
}
}
pub fn should_alert(&mut self, flow_key: &FlowKey, score: f32, threshold: f32) -> bool {
pub fn should_alert(&mut self, flow_key: &FlowKey, score: f32, threshold: f32, attack_type: Option<&str>) -> bool {
let now = Instant::now();
let detections = self.detections.entry(flow_key.clone()).or_default();
detections.retain(|(time, _)| now.duration_since(*time) < self.window_duration);
detections.push((now, score));
if detections.len() >= self.min_detections {
let avg_score: f32 =
detections.iter().map(|(_, s)| s).sum::<f32>() / detections.len() as f32;
// Per-attack-type adaptive min_detections:
// DDoS/DoS: high frequency, need more confirmations to avoid alert storms
// C2/Cryptomining: low frequency, alert on first detection
let effective_min = match attack_type {
Some("DDoS") | Some("DoS") => self.min_detections.saturating_mul(2).max(1),
Some("C2 Communication") | Some("Cryptomining") => 1,
_ => self.min_detections,
};
if detections.len() >= effective_min {
let avg_score: f32 = detections.iter().map(|(_, s)| s).sum::<f32>() / detections.len() as f32;
return avg_score > threshold * self.alert_threshold_multiplier;
}
@ -44,5 +52,65 @@ impl AttackAggregator {
!detections.is_empty()
});
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_key() -> FlowKey {
FlowKey {
src_ip: [192, 168, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
dst_ip: [10, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
src_port: 12345,
dst_port: 80,
protocol: 6,
ip_version: 4,
}
}
#[test]
fn default_attack_type_uses_base_min_detections() {
let mut agg = AttackAggregator::new(60, 3);
let key = test_key();
// Need 3 detections for default type
assert!(!agg.should_alert(&key, 5.0, 1.0, Some("Brute Force")));
assert!(!agg.should_alert(&key, 5.0, 1.0, Some("Brute Force")));
assert!(agg.should_alert(&key, 5.0, 1.0, Some("Brute Force")));
}
#[test]
fn ddos_requires_double_min_detections() {
let mut agg = AttackAggregator::new(60, 3);
let key = test_key();
// DDoS needs 6 detections (3 * 2)
for _ in 0..5 {
assert!(!agg.should_alert(&key, 5.0, 1.0, Some("DDoS")));
}
assert!(agg.should_alert(&key, 5.0, 1.0, Some("DDoS")));
}
#[test]
fn c2_alerts_on_first_detection() {
let mut agg = AttackAggregator::new(60, 3);
let key = test_key();
// C2 Communication alerts immediately (min=1)
assert!(agg.should_alert(&key, 5.0, 1.0, Some("C2 Communication")));
}
#[test]
fn cryptomining_alerts_on_first_detection() {
let mut agg = AttackAggregator::new(60, 3);
let key = test_key();
assert!(agg.should_alert(&key, 5.0, 1.0, Some("Cryptomining")));
}
#[test]
fn none_attack_type_uses_default() {
let mut agg = AttackAggregator::new(60, 3);
let key = test_key();
assert!(!agg.should_alert(&key, 5.0, 1.0, None));
assert!(!agg.should_alert(&key, 5.0, 1.0, None));
assert!(agg.should_alert(&key, 5.0, 1.0, None));
}
}

View File

@ -1,22 +1,19 @@
use macros::log;
use tokio::sync::broadcast;
use crate::model::config::constants::ML_ALERT_CHANNEL_CAPACITY;
use crate::model::log::ml::MLLog;
use crate::model::ml_detection::{AlertMessage, DetectionResult};
const ALERT_CHANNEL_CAPACITY: usize = 100;
pub struct MLAlert {
pub struct MLAlert {
broadcast_tx: broadcast::Sender<AlertMessage>,
}
impl MLAlert {
pub fn new() -> Self {
let (broadcast_tx, _) = broadcast::channel(ALERT_CHANNEL_CAPACITY);
let (broadcast_tx, _) = broadcast::channel(ML_ALERT_CHANNEL_CAPACITY);
MLAlert {
broadcast_tx,
}
MLAlert { broadcast_tx }
}
pub fn subscribe_to_alerts(&self) -> broadcast::Receiver<AlertMessage> {

View File

@ -3,7 +3,7 @@ use std::path::PathBuf;
use crate::model::error::ml::MLError;
pub use crate::model::config::MLInferenceConfig;
use crate::model::config::MLInferenceConfig;
/// Backward-compatible alias so existing `use config_loader::InferenceConfig` paths still compile.
pub type InferenceConfig = MLInferenceConfig;
@ -11,10 +11,9 @@ pub type InferenceConfig = MLInferenceConfig;
impl MLInferenceConfig {
pub fn load_file(file: &str) -> Result<Self, MLError> {
let path = PathBuf::from("models").join(file);
let content = fs::read_to_string(&path)
.map_err(|_| MLError::ConfigLoadFailed(path.to_path_buf()))?;
let config: MLInferenceConfig = serde_json::from_str(&content)
.map_err(|e| MLError::ConfigParseFailed(e.to_string()))?;
let content = fs::read_to_string(&path).map_err(|_| MLError::ConfigLoadFailed(path.to_path_buf()))?;
let config: MLInferenceConfig =
serde_json::from_str(&content).map_err(|e| MLError::ConfigParseFailed(e.to_string()))?;
if config.ae_feature_names.is_empty() {
return Err(MLError::ConfigParseFailed("ae_feature_names is empty"));
}

View File

@ -0,0 +1,156 @@
use std::collections::VecDeque;
use std::time::{Duration, Instant};
use crate::model::detection::drift::{DriftReport, FeatureBaselines};
/// Maximum number of snapshots to retain, preventing unbounded memory growth.
const MAX_SNAPSHOTS: usize = 10_000;
/// Tracks rolling mean/stddev of normalized input features over a configurable window.
/// Compares against training-time baselines to detect data drift.
pub struct DriftDetector {
/// Recent feature snapshots within the rolling window, capped at MAX_SNAPSHOTS.
snapshots: VecDeque<(Instant, Vec<f64>)>,
/// Number of features expected per snapshot.
num_features: usize,
/// Feature baselines (if available).
baselines: Option<FeatureBaselines>,
/// Rolling window duration (runtime-configurable via DB `ml_drift_window_secs`).
drift_window: Duration,
}
impl DriftDetector {
/// Create a new detector with a configurable drift window duration.
/// Default window is 3600s (1 hour) when not specified via DB setting `ml_drift_window_secs`.
pub fn new(baselines: Option<FeatureBaselines>, drift_window: Duration) -> Self {
let num_features = baselines.as_ref().map_or(0, |b| b.names.len());
Self {
snapshots: VecDeque::new(),
num_features,
baselines,
drift_window,
}
}
/// Add a new feature snapshot and evict stale entries.
pub fn update(&mut self, features: &[f64]) {
let now = Instant::now();
self.snapshots.push_back((now, features.to_vec()));
self.evict_stale(now);
// Cap total snapshots to prevent unbounded memory growth
while self.snapshots.len() > MAX_SNAPSHOTS {
self.snapshots.pop_front();
}
}
/// Check whether the current rolling mean has drifted > 3σ from baseline.
pub fn check_drift(&self) -> Option<DriftReport> {
let baselines = self.baselines.as_ref()?;
if self.snapshots.is_empty() || self.num_features == 0 {
return None;
}
let n = self.snapshots.len() as f64;
let mut sums = vec![0.0_f64; self.num_features];
for (_, features) in &self.snapshots {
for (i, &val) in features.iter().enumerate().take(self.num_features) {
sums[i] += val;
}
}
let mut drifted_features = Vec::new();
let mut max_deviation = 0.0_f64;
for (i, (sum, (bl_mean, bl_std))) in sums
.iter()
.zip(baselines.means.iter().zip(baselines.stds.iter()))
.enumerate()
.take(self.num_features)
{
let current_mean = sum / n;
// Skip features with zero or near-zero stddev (constant features)
if *bl_std < 1e-12 {
continue;
}
let deviation = ((current_mean - bl_mean) / bl_std).abs();
if deviation > 3.0 {
drifted_features.push(baselines.names[i].clone());
if deviation > max_deviation {
max_deviation = deviation;
}
}
}
if drifted_features.is_empty() {
None
} else {
Some(DriftReport {
drifted_features,
max_deviation,
})
}
}
/// Remove snapshots older than the configured drift window.
fn evict_stale(&mut self, now: Instant) {
while let Some((ts, _)) = self.snapshots.front() {
if now.duration_since(*ts) > self.drift_window {
self.snapshots.pop_front();
} else {
break;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_baselines(n: usize) -> FeatureBaselines {
FeatureBaselines {
names: (0..n).map(|i| format!("feature_{i}")).collect(),
means: vec![0.0; n],
stds: vec![1.0; n],
}
}
#[test]
fn no_drift_when_within_threshold() {
let baselines = make_baselines(3);
let mut detector = DriftDetector::new(Some(baselines), Duration::from_secs(3600));
// Values within 3σ of baseline mean 0.0 with std 1.0
detector.update(&[1.0, -1.0, 2.0]);
detector.update(&[0.5, -0.5, 1.5]);
assert!(detector.check_drift().is_none());
}
#[test]
fn drift_detected_when_exceeds_threshold() {
let baselines = make_baselines(3);
let mut detector = DriftDetector::new(Some(baselines), Duration::from_secs(3600));
// Mean of 5.0 exceeds 3σ from baseline mean 0.0
detector.update(&[5.0, 0.0, 0.0]);
detector.update(&[5.0, 0.0, 0.0]);
let report = detector.check_drift().unwrap();
assert!(report.drifted_features.contains(&"feature_0".to_string()));
assert!(report.max_deviation > 3.0);
}
#[test]
fn no_baselines_means_no_drift() {
let mut detector = DriftDetector::new(None, Duration::from_secs(3600));
detector.update(&[100.0, 200.0]);
assert!(detector.check_drift().is_none());
}
#[test]
fn empty_snapshots_no_drift() {
let baselines = make_baselines(3);
let detector = DriftDetector::new(Some(baselines), Duration::from_secs(3600));
assert!(detector.check_drift().is_none());
}
}

View File

@ -1,18 +1,19 @@
use std::sync::Arc;
use std::time::{Duration, Instant};
use parking_lot::Mutex;
use macros::log;
use parking_lot::Mutex;
use tokio::sync::oneshot;
use tokio::time::interval;
use super::aggregator::AttackAggregator;
use super::config_loader::InferenceConfig;
use super::feature_extractor::FlowFeatures;
use super::drift_detector::DriftDetector;
use super::flow_tracker::{FlowData, FlowTracker};
use super::inference::Inference;
use super::model_loader::MLModels;
use super::traffic_logger::TrafficLogger;
use crate::model::detection::flow_features::FlowFeatures;
use super::alert::MLAlert;
use crate::model::log::ml::MLLog;
@ -26,6 +27,7 @@ pub struct Engine {
trackers: Vec<ThreadTracker>,
inference_pipeline: Arc<Inference>,
aggregator: Mutex<AttackAggregator>,
drift_detector: Arc<Mutex<DriftDetector>>,
ml_alert: Arc<MLAlert>,
min_packets: usize,
batch_size: usize,
@ -38,14 +40,19 @@ impl Engine {
models: Arc<MLModels>,
config: Arc<InferenceConfig>,
ml_alert: Arc<MLAlert>,
drift_detector: Arc<Mutex<DriftDetector>>,
engine_config: EngineConfig,
traffic_logger: Option<Arc<TrafficLogger>>,
num_threads: u32,
) -> Self {
let inference_pipeline = Arc::new(Inference::new(models, config));
let min_detections = ((engine_config.aggregator_window_secs / engine_config.inference_interval_secs) / 2).max(1) as usize;
let aggregator = Mutex::new(AttackAggregator::new(engine_config.aggregator_window_secs, min_detections));
let min_detections =
((engine_config.aggregator_window_secs / engine_config.inference_interval_secs) / 2).max(1) as usize;
let aggregator = Mutex::new(AttackAggregator::new(
engine_config.aggregator_window_secs,
min_detections,
));
let max_flows_per_thread = engine_config.max_flows / (num_threads as usize).max(1);
let trackers: Vec<ThreadTracker> = (0..num_threads)
@ -56,6 +63,7 @@ impl Engine {
trackers,
inference_pipeline,
aggregator,
drift_detector,
ml_alert,
min_packets: engine_config.min_packets,
batch_size: engine_config.batch_size,
@ -89,7 +97,7 @@ impl Engine {
shutdown_tx
}
async fn run_inference_loop(&self, mut shutdown_rx: oneshot::Receiver<()>) {
async fn run_inference_loop(self: Arc<Self>, mut shutdown_rx: oneshot::Receiver<()>) {
let mut ticker = interval(Duration::from_secs(self.inference_interval_secs));
loop {
@ -98,21 +106,37 @@ impl Engine {
_ = ticker.tick() => {}
}
self.run_inference_tick();
// Move CPU-bound ML inference off the tokio executor
let engine = Arc::clone(&self);
let _ = tokio::task::spawn_blocking(move || {
engine.run_inference_tick();
})
.await;
}
}
fn run_inference_tick(&self) {
let mut all_flows = Vec::new();
let mut total_count = 0;
let now_us = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_micros() as u64)
.unwrap_or(0);
// Phase 0: clean up stale / terminated flows
for tracker in &self.trackers {
let mut t = tracker.lock();
t.cleanup_stale_flows(now_us);
}
// Phase 1: short lock per tracker — clone uninferred flows, mark as inferred
for tracker in &self.trackers {
let mut t = tracker.lock();
total_count += t.flow_count();
all_flows.extend(
t.get_uninferred_flows().into_iter()
.filter(|flow| flow.packet_count() >= self.min_packets)
t.get_uninferred_flows()
.into_iter()
.filter(|flow| flow.packet_count() >= self.min_packets),
);
// lock released here
}
@ -148,6 +172,22 @@ impl Engine {
log!(MLLog::RunningInference(batch.len()));
// Feed normalized features into drift detector for each flow in the batch
{
let config = &self.inference_pipeline.config;
let mut dd = self.drift_detector.lock();
for flow in batch.iter() {
let features = FlowFeatures::extract(flow, &config.ae_feature_names);
let normalized: Vec<f64> = features
.features
.iter()
.zip(config.ae_scaler_mean.iter().zip(config.ae_scaler_std.iter()))
.map(|(&val, (&mean, &std))| if std.abs() > 1e-12 { (val - mean) / std } else { 0.0 })
.collect();
dd.update(&normalized);
}
}
let start = Instant::now();
let results = self.inference_pipeline.infer_batch(batch);
let elapsed_us = start.elapsed().as_micros() as u64;
@ -170,8 +210,12 @@ impl Engine {
let mut aggregator = self.aggregator.lock();
for result in &results {
if result.is_attack {
let should_alert =
aggregator.should_alert(&result.flow_key_raw, result.ae_score, result.threshold);
let should_alert = aggregator.should_alert(
&result.flow_key_raw,
result.ae_score,
result.threshold,
result.attack_type.as_deref(),
);
if should_alert {
log!(MLLog::ThreatDetected(
@ -190,5 +234,4 @@ impl Engine {
aggregator.cleanup();
}
}
}

View File

@ -1,15 +1,9 @@
use std::collections::HashMap;
use common::define::tcp_flags::*;
use super::flow_tracker::FlowData;
use crate::model::ml_detection::{ClipParams, PacketData};
use crate::model::ml_detection::PacketData;
#[derive(Debug, Clone)]
pub struct FlowFeatures {
pub features: Vec<f64>,
pub feature_num: usize,
}
use crate::model::detection::flow_features::FlowFeatures;
impl FlowFeatures {
pub fn extract(flow: &FlowData, feature_names: &[String]) -> Self {
@ -24,125 +18,6 @@ impl FlowFeatures {
Self { features, feature_num }
}
pub fn normalize(&mut self, means: &[f64], stds: &[f64]) {
for i in 0..self.feature_num {
if stds[i] > 0.0 {
self.features[i] = (self.features[i] - means[i]) / stds[i];
} else {
self.features[i] = 0.0;
}
}
}
pub fn clip(&mut self, clip_min: f64, clip_max: f64) {
for i in 0..self.feature_num {
self.features[i] = self.features[i].max(clip_min).min(clip_max);
}
}
pub fn winsorize(&mut self, clip_params: &HashMap<String, ClipParams>, feature_names: &[String]) {
for (i, feature_name) in feature_names.iter().enumerate() {
if i < self.feature_num
&& let Some(params) = clip_params.get(feature_name) {
self.features[i] = self.features[i].clamp(params.lower, params.upper);
}
}
}
pub fn all_feature_names() -> Vec<&'static str> {
vec![
"Destination Port",
"Protocol",
"Flow Duration",
"Total Fwd Packets",
"Total Backward Packets",
"Total Length of Fwd Packets",
"Total Length of Bwd Packets",
"Fwd Packet Length Max",
"Fwd Packet Length Min",
"Fwd Packet Length Mean",
"Fwd Packet Length Std",
"Bwd Packet Length Max",
"Bwd Packet Length Min",
"Bwd Packet Length Mean",
"Bwd Packet Length Std",
"Flow Bytes/s",
"Flow Packets/s",
"Flow IAT Mean",
"Flow IAT Std",
"Flow IAT Max",
"Flow IAT Min",
"Fwd IAT Total",
"Fwd IAT Mean",
"Fwd IAT Std",
"Fwd IAT Max",
"Fwd IAT Min",
"Bwd IAT Total",
"Bwd IAT Mean",
"Bwd IAT Std",
"Bwd IAT Max",
"Bwd IAT Min",
"Fwd PSH Flags",
"Bwd PSH Flags",
"Fwd URG Flags",
"Bwd URG Flags",
"Fwd Header Length",
"Bwd Header Length",
"Fwd Packets/s",
"Bwd Packets/s",
"Min Packet Length",
"Max Packet Length",
"Packet Length Mean",
"Packet Length Std",
"Packet Length Variance",
"FIN Flag Count",
"SYN Flag Count",
"RST Flag Count",
"PSH Flag Count",
"ACK Flag Count",
"URG Flag Count",
"CWE Flag Count",
"ECE Flag Count",
"Down/Up Ratio",
"Average Packet Size",
"Avg Fwd Segment Size",
"Avg Bwd Segment Size",
"Fwd Header Length.1",
"Fwd Avg Bytes/Bulk",
"Fwd Avg Packets/Bulk",
"Fwd Avg Bulk Rate",
"Bwd Avg Bytes/Bulk",
"Bwd Avg Packets/Bulk",
"Bwd Avg Bulk Rate",
"Subflow Fwd Packets",
"Subflow Fwd Bytes",
"Subflow Bwd Packets",
"Subflow Bwd Bytes",
"Init_Win_bytes_forward",
"Init_Win_bytes_backward",
"act_data_pkt_fwd",
"min_seg_size_forward",
"Active Mean",
"Active Std",
"Active Max",
"Active Min",
"Idle Mean",
"Idle Std",
"Idle Max",
"Idle Min",
]
}
pub fn all_feature_names_owned() -> Vec<String> {
Self::all_feature_names().iter().map(|s| s.to_string()).collect()
}
pub fn to_csv_record(&self) -> Vec<String> {
let mut record: Vec<String> = self.features.iter().map(|f| f.to_string()).collect();
record.push("BENIGN".to_string());
record
}
}
/// All statistics pre-computed once from a FlowData, then looked up by feature name.
@ -244,6 +119,10 @@ struct PrecomputedStats {
idle_min: f64,
idle_mean: f64,
idle_std: f64,
// Phase 2: new features for C2/Cryptomining detection
fwd_bwd_bytes_ratio: f64,
fwd_iat_skewness: f64,
}
impl PrecomputedStats {
@ -328,6 +207,10 @@ impl PrecomputedStats {
let (idle_max, idle_min, idle_mean, idle_std) =
compute_stats(&flow.idle_periods.iter().map(|&x| x as f64).collect::<Vec<_>>());
// Phase 2: new features for C2/Cryptomining detection
let fwd_bwd_bytes_ratio = safe_div(fwd_total_bytes, fwd_total_bytes + bwd_total_bytes);
let fwd_iat_skewness = compute_bowley_skewness(&fwd_iats);
Self {
dst_port: flow.flow_key.dst_port as f64,
protocol: flow.flow_key.protocol as f64,
@ -397,6 +280,8 @@ impl PrecomputedStats {
idle_min,
idle_mean,
idle_std,
fwd_bwd_bytes_ratio,
fwd_iat_skewness,
}
}
@ -484,6 +369,16 @@ impl PrecomputedStats {
"Idle Max" => self.idle_max,
"Idle Min" => self.idle_min,
// Phase 2: unified names for IAT std (already computed, add aliases)
"fwd_iat_std" => self.fwd_iat_std,
"bwd_iat_std" => self.bwd_iat_std,
"flow_iat_std" => self.flow_iat_std,
// Phase 2: new features for C2/Cryptomining detection
"fwd_bwd_bytes_ratio" => self.fwd_bwd_bytes_ratio,
"pkt_len_variance" => self.all_len_std * self.all_len_std,
"fwd_iat_skewness" => self.fwd_iat_skewness,
_ => 0.0,
}
}
@ -522,6 +417,23 @@ fn compute_iats(packets: &[PacketData]) -> Vec<f64> {
.collect()
}
/// Bowley (quartile) skewness: (Q3 + Q1 - 2*Q2) / (Q3 - Q1)
/// Returns 0.0 for insufficient data or zero IQR.
/// Used for C2 beacon detection — regular beacons have skewness near 0.
fn compute_bowley_skewness(values: &[f64]) -> f64 {
if values.len() < 4 {
return 0.0;
}
let mut sorted = values.to_vec();
sorted.sort_by(|a, b| a.total_cmp(b));
let n = sorted.len();
let q1 = sorted[n / 4];
let q2 = sorted[n / 2];
let q3 = sorted[3 * n / 4];
let iqr = q3 - q1;
if iqr <= 0.0 { 0.0 } else { (q3 + q1 - 2.0 * q2) / iqr }
}
fn compute_flow_iats(fwd_packets: &[PacketData], bwd_packets: &[PacketData]) -> Vec<f64> {
let mut all_packets: Vec<&PacketData> = fwd_packets.iter().chain(bwd_packets.iter()).collect();
all_packets.sort_by_key(|p| p.timestamp_us);
@ -535,3 +447,38 @@ fn compute_flow_iats(fwd_packets: &[PacketData], bwd_packets: &[PacketData]) ->
.map(|w| (w[1].timestamp_us - w[0].timestamp_us) as f64)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bowley_skewness_insufficient_data() {
assert_eq!(compute_bowley_skewness(&[]), 0.0);
assert_eq!(compute_bowley_skewness(&[1.0]), 0.0);
assert_eq!(compute_bowley_skewness(&[1.0, 2.0, 3.0]), 0.0);
}
#[test]
fn bowley_skewness_zero_iqr() {
// All identical values → Q1 == Q3 → IQR = 0
assert_eq!(compute_bowley_skewness(&[5.0, 5.0, 5.0, 5.0]), 0.0);
assert_eq!(compute_bowley_skewness(&[1.0, 1.0, 1.0, 1.0, 1.0, 1.0]), 0.0);
}
#[test]
fn bowley_skewness_known_output() {
// Symmetric distribution: [1, 2, 3, 4, 5, 6, 7, 8] (n=8)
// Q1 = sorted[2] = 3, Q2 = sorted[4] = 5, Q3 = sorted[6] = 7
// Bowley = (7 + 3 - 2*5) / (7 - 3) = 0 / 4 = 0.0
let symmetric = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
assert!((compute_bowley_skewness(&symmetric)).abs() < 1e-10);
// Right-skewed: [1, 1, 1, 1, 2, 5, 10, 20] (n=8)
// Q1 = sorted[2] = 1, Q2 = sorted[4] = 2, Q3 = sorted[6] = 10
// Bowley = (10 + 1 - 2*2) / (10 - 1) = 7 / 9 ≈ 0.778
let right_skewed = vec![1.0, 1.0, 1.0, 1.0, 2.0, 5.0, 10.0, 20.0];
let skew = compute_bowley_skewness(&right_skewed);
assert!((skew - 7.0 / 9.0).abs() < 1e-10);
}
}

View File

@ -1,6 +1,12 @@
use std::collections::HashMap;
use common::define::tcp_flags::*;
use std::num::NonZero;
use common::define::tcp_flags::*;
use lru::LruCache;
use crate::model::config::constants::{
FLOW_BULK_MIN_BYTES, FLOW_BULK_MIN_PACKETS, FLOW_IDLE_THRESHOLD_US, FLOW_IDLE_TIMEOUT_US,
FLOW_MAX_PACKETS_PER_DIRECTION, FLOW_MAX_PERIODS, FLOW_TERMINATED_TIMEOUT_US,
};
use crate::model::direction::Direction;
use crate::model::ml_detection::{BulkState, FlowKey, PacketData};
use crate::model::user_packet::UserPacket;
@ -60,8 +66,16 @@ impl FlowData {
urg_count: 0,
cwe_count: 0,
ece_count: 0,
init_win_bytes_fwd: if first_packet.is_forward { first_packet.tcp_window_size } else { 0 },
init_win_bytes_bwd: if !first_packet.is_forward { first_packet.tcp_window_size } else { 0 },
init_win_bytes_fwd: if first_packet.is_forward {
first_packet.tcp_window_size
} else {
0
},
init_win_bytes_bwd: if !first_packet.is_forward {
first_packet.tcp_window_size
} else {
0
},
active_periods: Vec::new(),
idle_periods: Vec::new(),
last_packet_time: first_packet.timestamp_us,
@ -74,9 +88,6 @@ impl FlowData {
}
pub fn add_packet(&mut self, packet: &UserPacket) {
const MAX_PACKETS_PER_DIRECTION: usize = 1000;
const MAX_PERIODS: usize = 10000;
let packet_data = PacketData {
timestamp_us: packet.timestamp_us,
length: packet.packet_length,
@ -85,22 +96,40 @@ impl FlowData {
flags: packet.tcp_flags,
};
if packet.tcp_flags & TCP_FIN != 0 { self.fin_count += 1; }
if packet.tcp_flags & TCP_SYN != 0 { self.syn_count += 1; }
if packet.tcp_flags & TCP_RST != 0 { self.rst_count += 1; }
if packet.tcp_flags & TCP_PSH != 0 { self.psh_count += 1; }
if packet.tcp_flags & TCP_ACK != 0 { self.ack_count += 1; }
if packet.tcp_flags & TCP_URG != 0 { self.urg_count += 1; }
if packet.tcp_flags & TCP_CWR != 0 { self.cwe_count += 1; }
if packet.tcp_flags & TCP_ECE != 0 { self.ece_count += 1; }
if packet.tcp_flags & TCP_FIN != 0 {
self.fin_count += 1;
}
if packet.tcp_flags & TCP_SYN != 0 {
self.syn_count += 1;
}
if packet.tcp_flags & TCP_RST != 0 {
self.rst_count += 1;
}
if packet.tcp_flags & TCP_PSH != 0 {
self.psh_count += 1;
}
if packet.tcp_flags & TCP_ACK != 0 {
self.ack_count += 1;
}
if packet.tcp_flags & TCP_URG != 0 {
self.urg_count += 1;
}
if packet.tcp_flags & TCP_CWR != 0 {
self.cwe_count += 1;
}
if packet.tcp_flags & TCP_ECE != 0 {
self.ece_count += 1;
}
let iat = packet.timestamp_us.saturating_sub(self.last_packet_time);
const IDLE_THRESHOLD_US: u64 = 1_000_000;
if iat > IDLE_THRESHOLD_US {
if self.idle_periods.len() < MAX_PERIODS { self.idle_periods.push(iat); }
} else if iat > 0
&& self.active_periods.len() < MAX_PERIODS { self.active_periods.push(iat); }
if iat > FLOW_IDLE_THRESHOLD_US {
if self.idle_periods.len() < FLOW_MAX_PERIODS {
self.idle_periods.push(iat);
}
} else if iat > 0 && self.active_periods.len() < FLOW_MAX_PERIODS {
self.active_periods.push(iat);
}
self.last_packet_time = packet.timestamp_us;
self.last_time_us = packet.timestamp_us;
@ -112,28 +141,29 @@ impl FlowData {
}
if packet.is_forward {
if self.fwd_packets.len() < MAX_PACKETS_PER_DIRECTION {
if self.fwd_packets.len() < FLOW_MAX_PACKETS_PER_DIRECTION {
self.fwd_packets.push(packet_data.clone());
}
self.fwd_total_bytes += packet.payload_length as u64;
self.fwd_header_bytes += packet.header_length as u64;
if self.init_win_bytes_fwd == 0 { self.init_win_bytes_fwd = packet.tcp_window_size; }
if self.init_win_bytes_fwd == 0 {
self.init_win_bytes_fwd = packet.tcp_window_size;
}
Self::update_bulk_state(&mut self.fwd_bulk_state, &packet_data);
} else {
if self.bwd_packets.len() < MAX_PACKETS_PER_DIRECTION {
if self.bwd_packets.len() < FLOW_MAX_PACKETS_PER_DIRECTION {
self.bwd_packets.push(packet_data.clone());
}
self.bwd_total_bytes += packet.payload_length as u64;
self.bwd_header_bytes += packet.header_length as u64;
if self.init_win_bytes_bwd == 0 { self.init_win_bytes_bwd = packet.tcp_window_size; }
if self.init_win_bytes_bwd == 0 {
self.init_win_bytes_bwd = packet.tcp_window_size;
}
Self::update_bulk_state(&mut self.bwd_bulk_state, &packet_data);
}
}
fn update_bulk_state(bulk_state: &mut BulkState, packet: &PacketData) {
const BULK_MIN_PACKETS: u64 = 4;
const BULK_MIN_BYTES: u64 = 1000;
if packet.payload_length > 0 {
if !bulk_state.in_bulk {
bulk_state.in_bulk = true;
@ -148,8 +178,8 @@ impl FlowData {
}
} else {
if bulk_state.in_bulk
&& bulk_state.last_bulk_packets >= BULK_MIN_PACKETS
&& bulk_state.last_bulk_bytes >= BULK_MIN_BYTES
&& bulk_state.last_bulk_packets >= FLOW_BULK_MIN_PACKETS
&& bulk_state.last_bulk_bytes >= FLOW_BULK_MIN_BYTES
{
bulk_state.bulk_count += 1;
bulk_state.total_bytes += bulk_state.last_bulk_bytes;
@ -177,27 +207,29 @@ impl FlowData {
/// Per-thread flow tracker. No locks — each XSK thread owns one.
/// RSS guarantees the same flow always goes to the same thread.
/// Uses LruCache for O(1) eviction instead of O(n) min_by_key scan.
pub struct FlowTracker {
active: HashMap<FlowKey, FlowData>,
max_flows: usize,
active: LruCache<FlowKey, FlowData>,
}
impl FlowTracker {
pub fn new(max_flows: usize) -> Self {
// SAFETY: max(1, max_flows) ensures NonZero is never zero.
let cap = NonZero::new(max_flows.max(1)).unwrap_or_else(|| unreachable!());
Self {
active: HashMap::new(),
max_flows,
active: LruCache::new(cap),
}
}
pub fn process_packet(&mut self, mut packet: UserPacket, is_ingress: bool) {
let packet_key = FlowKey::from_packet(&packet);
let reversed_key = packet_key.clone().reverse();
let reversed_key = packet_key.reverse();
// Try to match an existing flow first (canonical key already established).
let (actual_key, is_forward) = if self.active.contains_key(&packet_key) {
// Use peek() to avoid promoting — we'll promote via get_mut() below.
let (actual_key, is_forward) = if self.active.peek(&packet_key).is_some() {
(packet_key, true)
} else if self.active.contains_key(&reversed_key) {
} else if self.active.peek(&reversed_key).is_some() {
(reversed_key, false)
} else {
// New flow: determine initiator using TCP flags, fall back to is_ingress.
@ -208,14 +240,22 @@ impl FlowTracker {
// Ingress: external server responding to internal client → reverse so
// canonical key has internal client as src.
// Egress: internal server responding to external client → keep as-is.
if is_ingress { (reversed_key, false) } else { (packet_key, true) }
if is_ingress {
(reversed_key, false)
} else {
(packet_key, true)
}
} else if syn {
// SYN: sender is always the initiator.
(packet_key, true)
} else {
// Mid-stream / UDP / ICMP: use is_ingress as best-effort heuristic.
// Egress = we are the initiator (forward); ingress = remote initiated (backward).
if is_ingress { (reversed_key, false) } else { (packet_key, true) }
if is_ingress {
(reversed_key, false)
} else {
(packet_key, true)
}
}
};
@ -223,36 +263,39 @@ impl FlowTracker {
// Record which interface the initiator is on for this flow.
let initiator_direction = if is_forward {
if is_ingress { Direction::Ingress } else { Direction::Egress }
if is_ingress {
Direction::Ingress
} else {
Direction::Egress
}
} else if is_ingress {
Direction::Egress
} else {
if is_ingress { Direction::Egress } else { Direction::Ingress }
Direction::Ingress
};
let flow = self.active
.entry(actual_key.clone())
.or_insert_with(|| FlowData::new(actual_key, &packet, initiator_direction));
flow.add_packet(&packet);
if self.active.len() > self.max_flows
&& let Some(oldest_key) = self.active.iter()
.min_by_key(|(_, flow)| flow.last_time_us)
.map(|(k, _)| k.clone())
{
self.active.remove(&oldest_key);
// LruCache::push handles eviction automatically when capacity is exceeded (O(1)).
// If the flow already exists, get_mut promotes it to MRU; otherwise push creates it.
if let Some(flow) = self.active.get_mut(&actual_key) {
flow.add_packet(&packet);
} else {
let mut flow = FlowData::new(actual_key.clone(), &packet, initiator_direction);
flow.add_packet(&packet);
self.active.push(actual_key, flow);
}
}
/// Get all active flows (clone, no drain). Used by WebSocket.
pub fn get_flows(&self) -> Vec<FlowData> {
self.active.values().cloned().collect()
self.active.iter().map(|(_, flow)| flow.clone()).collect()
}
/// Get flows that received new packets since their last inference,
/// and mark them as inferred. Used by ML engine.
pub fn get_uninferred_flows(&mut self) -> Vec<FlowData> {
let mut result = Vec::new();
for flow in self.active.values_mut() {
// iter_mut does NOT promote entries (preserves LRU order)
for (_, flow) in self.active.iter_mut() {
if flow.last_time_us > flow.last_inferred_us {
result.push(flow.clone());
flow.last_inferred_us = flow.last_time_us;
@ -264,5 +307,124 @@ impl FlowTracker {
pub fn flow_count(&self) -> usize {
self.active.len()
}
/// Remove flows that have been idle too long or are terminated (FIN/RST seen).
/// `now_us`: current timestamp in microseconds (same scale as packet timestamps).
/// Returns the number of flows removed.
pub fn cleanup_stale_flows(&mut self, now_us: u64) -> usize {
// LruCache doesn't have retain(), so collect keys to remove then pop them.
let keys_to_remove: Vec<FlowKey> = self
.active
.iter()
.filter(|(_, flow)| {
let idle = now_us.saturating_sub(flow.last_time_us);
let is_terminated = flow.fin_count > 0 || flow.rst_count > 0;
if is_terminated {
idle >= FLOW_TERMINATED_TIMEOUT_US
} else {
idle >= FLOW_IDLE_TIMEOUT_US
}
})
.map(|(k, _)| k.clone())
.collect();
let removed = keys_to_remove.len();
for key in keys_to_remove {
self.active.pop(&key);
}
removed
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_packet(timestamp_us: u64, tcp_flags: u8) -> UserPacket {
UserPacket {
ip_version: 4,
protocol: 6, // TCP
tcp_flags,
src_ip: [10, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
dst_ip: [10, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
src_port: 12345,
dst_port: 80,
packet_length: 100,
payload_length: 60,
header_length: 40,
tcp_window_size: 65535,
timestamp_us,
is_forward: true,
}
}
#[test]
fn cleanup_removes_idle_flows() {
let mut tracker = FlowTracker::new(10000);
let base_ts = 1_000_000_000u64; // 1000 seconds
// Insert a flow with old timestamp
let pkt = make_packet(base_ts, 0x02); // SYN
tracker.process_packet(pkt, false);
assert_eq!(tracker.flow_count(), 1);
// 130 seconds later — should be cleaned up (idle > 120s)
let now = base_ts + 130_000_000;
let removed = tracker.cleanup_stale_flows(now);
assert_eq!(removed, 1);
assert_eq!(tracker.flow_count(), 0);
}
#[test]
fn cleanup_keeps_active_flows() {
let mut tracker = FlowTracker::new(10000);
let base_ts = 1_000_000_000u64;
let pkt = make_packet(base_ts, 0x02);
tracker.process_packet(pkt, false);
// Only 10 seconds later — should NOT be cleaned up
let now = base_ts + 10_000_000;
let removed = tracker.cleanup_stale_flows(now);
assert_eq!(removed, 0);
assert_eq!(tracker.flow_count(), 1);
}
#[test]
fn cleanup_removes_terminated_flows_after_short_idle() {
let mut tracker = FlowTracker::new(10000);
let base_ts = 1_000_000_000u64;
// SYN packet
let pkt1 = make_packet(base_ts, 0x02);
tracker.process_packet(pkt1, false);
// FIN packet 1 second later
let pkt2 = make_packet(base_ts + 1_000_000, 0x01); // FIN
tracker.process_packet(pkt2, false);
// 6 seconds after FIN — terminated flow should be removed (idle > 5s)
let now = base_ts + 7_000_000;
let removed = tracker.cleanup_stale_flows(now);
assert_eq!(removed, 1);
assert_eq!(tracker.flow_count(), 0);
}
#[test]
fn cleanup_keeps_recently_terminated_flows() {
let mut tracker = FlowTracker::new(10000);
let base_ts = 1_000_000_000u64;
let pkt1 = make_packet(base_ts, 0x02);
tracker.process_packet(pkt1, false);
// FIN packet
let pkt2 = make_packet(base_ts + 1_000_000, 0x01);
tracker.process_packet(pkt2, false);
// Only 2 seconds after FIN — should still be around
let now = base_ts + 3_000_000;
let removed = tracker.cleanup_stale_flows(now);
assert_eq!(removed, 0);
assert_eq!(tracker.flow_count(), 1);
}
}

View File

@ -4,9 +4,9 @@ use macros::log;
use tract_onnx::prelude::*;
use super::config_loader::InferenceConfig;
use super::feature_extractor::FlowFeatures;
use super::flow_tracker::FlowData;
use super::model_loader::MLModels;
use crate::model::detection::flow_features::FlowFeatures;
use crate::model::log::ml::MLLog;
use crate::model::ml_detection::DetectionResult;
@ -25,6 +25,21 @@ impl Inference {
}
pub fn infer_single(&self, flow: &FlowData) -> Option<DetectionResult> {
// catch_unwind protects against tract-onnx internal panics on edge-case inputs.
// Without this, panic=abort config would kill the entire process.
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.infer_single_inner(flow))) {
Ok(result) => result,
Err(_) => {
log!(MLLog::InferenceFailed(
"ONNX".to_string(),
"inference panicked (caught)".to_string(),
));
None
}
}
}
fn infer_single_inner(&self, flow: &FlowData) -> Option<DetectionResult> {
let ae_features = self.preprocess_ae_features(flow);
let ae_input = Self::vec_to_array2(&ae_features);
@ -67,6 +82,8 @@ impl Inference {
confidence,
ae_score,
threshold: self.config.ae_threshold,
packet_count: flow.packet_count() as u64,
flow_duration_us: flow.duration_us(),
})
}
@ -96,10 +113,7 @@ impl Inference {
fn run_autoencoder(&self, input: &tract_ndarray::Array2<f32>) -> TractResult<f32> {
let input_tensor = input.clone().into_tensor();
let result = self
.models
.deep_autoencoder
.run(tvec![input_tensor.into()])?;
let result = self.models.deep_autoencoder.run(tvec![input_tensor.into()])?;
let output = result[0]
.to_array_view::<f32>()?

View File

@ -1,9 +1,10 @@
pub mod alert;
pub mod model_loader;
pub mod config_loader;
pub mod flow_tracker;
pub mod feature_extractor;
pub mod inference;
pub mod engine;
pub mod aggregator;
pub mod traffic_logger;
pub mod alert;
pub mod config_loader;
pub mod drift_detector;
pub mod engine;
pub mod feature_extractor;
pub mod flow_tracker;
pub mod inference;
pub mod model_loader;
pub mod traffic_logger;

View File

@ -1,5 +1,5 @@
use tract_onnx::prelude::*;
use std::path::PathBuf;
use tract_onnx::prelude::*;
use crate::infrastructure::app_config::AppConfig;
use crate::model::error::ml::MLError;
@ -14,8 +14,14 @@ pub struct MLModels {
impl MLModels {
pub fn load_models(app_config: &Arc<AppConfig>, inference_config: &Arc<InferenceConfig>) -> Result<Self, MLError> {
Ok(Self {
deep_autoencoder: Self::loader(&app_config.inference.deep_autoencoder_name, inference_config.num_ae_features())?,
classifier: Self::loader(&app_config.inference.classifier_name, inference_config.num_classifier_features())?
deep_autoencoder: Self::loader(
&app_config.inference.deep_autoencoder_name,
inference_config.num_ae_features(),
)?,
classifier: Self::loader(
&app_config.inference.classifier_name,
inference_config.num_classifier_features(),
)?,
})
}
@ -42,4 +48,4 @@ impl MLModels {
let outputs = model.model().outputs.len();
format!("{}: inputs: {}, outputs: {}", name, inputs, outputs)
}
}
}

View File

@ -2,7 +2,7 @@ use std::fs::OpenOptions;
use std::io::{BufWriter, Write};
use std::thread;
use crossbeam::channel::{bounded, Sender, TrySendError};
use crossbeam::channel::{Sender, TrySendError, bounded};
use macros::log;
use crate::model::error::ml::MLError;

View File

@ -1,14 +1,16 @@
pub mod acl_service;
pub mod auth;
pub mod config_service;
pub mod dns_filter_service;
pub mod email;
pub mod ebpf;
pub mod ml;
pub mod notification_service;
pub mod playbook_service;
pub mod rate_limit_service;
pub mod report;
pub mod soar;
pub mod stats_aggregator;
pub mod system;
pub mod acl_service;
pub mod auth;
pub mod config_service;
pub mod correlation;
pub mod detection;
pub mod dns_filter_service;
pub mod ebpf;
pub mod email;
pub mod ml;
pub mod notification_service;
pub mod playbook_service;
pub mod rate_limit_service;
pub mod report;
pub mod soar;
pub mod stats_aggregator;
pub mod system;

View File

@ -1,76 +1,95 @@
use std::sync::Arc;
use crate::adapter::persistence::Database;
use crate::interface::port::notification::AlertNotifier;
use crate::interface::port::notification::{AlertNotifier, NotificationConfigPort};
use crate::interface::port::repository::RepositoryPort;
use crate::interface::port::secret_store::SecretStorePort;
use crate::model::error::Error;
use crate::model::error::misc::MiscError;
/// Domain service for notification config (Telegram, SMTP).
/// Coordinates DB persistence and external service testing.
pub struct NotificationService {
db: Arc<Database>,
notif: Arc<dyn NotificationConfigPort>,
repo: Arc<dyn RepositoryPort>,
secrets: Arc<dyn SecretStorePort>,
}
impl NotificationService {
pub fn new(db: Arc<Database>) -> Self {
Self { db }
pub fn new(
notif: Arc<dyn NotificationConfigPort>,
repo: Arc<dyn RepositoryPort>,
secrets: Arc<dyn SecretStorePort>,
) -> Self {
Self { notif, repo, secrets }
}
/// Get Telegram config with redacted bot_token.
pub fn get_telegram_config(&self) -> Result<serde_json::Value, Error> {
match self.db.get_notification_config("telegram")? {
Some(json_str) => {
match serde_json::from_str::<serde_json::Value>(&json_str) {
Ok(mut config) => {
if let Some(token) = config.get("bot_token").and_then(|t| t.as_str())
&& token.len() > 8 {
let redacted = format!("{}...{}", &token[..4], &token[token.len()-4..]);
config["bot_token_redacted"] = serde_json::Value::String(redacted);
config.as_object_mut().map(|obj| obj.remove("bot_token"));
}
config["configured"] = serde_json::Value::Bool(true);
Ok(config)
match self.notif.get_notification_config("telegram")? {
Some(json_str) => match serde_json::from_str::<serde_json::Value>(&json_str) {
Ok(mut config) => {
// Resolve the actual token for redaction display
let token = match config.get("bot_token").and_then(|t| t.as_str()) {
Some("__encrypted__") => self.secrets.get_secret("telegram_bot_token")?,
Some(t) => Some(t.to_string()),
None => None,
};
if let Some(ref t) = token
&& t.len() > 8
{
let redacted = format!("{}...{}", &t[..4], &t[t.len() - 4..]);
config["bot_token_redacted"] = serde_json::Value::String(redacted);
}
Err(_) => Ok(serde_json::json!({"configured": false})),
config.as_object_mut().map(|obj| obj.remove("bot_token"));
config["configured"] = serde_json::Value::Bool(true);
Ok(config)
}
}
Err(_) => Ok(serde_json::json!({"configured": false})),
},
None => Ok(serde_json::json!({"configured": false})),
}
}
/// Save Telegram bot_token + chat_id to DB.
/// The bot_token is stored encrypted in the secret store; the config JSON
/// holds the `"__encrypted__"` sentinel.
pub fn set_telegram_config(&self, bot_token: &str, chat_id: &str) -> Result<(), Error> {
self.secrets.set_secret("telegram_bot_token", bot_token)?;
let config_json = serde_json::json!({
"bot_token": bot_token,
"bot_token": "__encrypted__",
"chat_id": chat_id,
}).to_string();
self.db.set_notification_config("telegram", &config_json)
})
.to_string();
self.notif.set_notification_config("telegram", &config_json)
}
/// Send a test Telegram message using current config.
pub async fn test_telegram(&self) -> Result<(), Error> {
let adapter = crate::adapter::telegram::TelegramAdapter::new(self.db.clone())?;
let adapter = crate::adapter::telegram::TelegramAdapter::new(
self.notif.clone(),
self.repo.clone(),
Some(self.secrets.clone()),
)?;
adapter.send_test_message().await
}
/// Send a test email using current SMTP config.
pub fn test_smtp(&self) -> Result<String, Error> {
let smtp_client = crate::core::email::scheduler::SmtpClient::from_database(
self.db.as_ref() as &dyn RepositoryPort,
)?;
let smtp = smtp_client.ok_or_else(|| {
MiscError::ValidationError { message:
"SMTP not configured. Set smtp_host, smtp_port, smtp_username, smtp_password first.".into()
}
let smtp_client =
crate::core::email::scheduler::SmtpClient::from_database(self.repo.as_ref(), Some(self.secrets.as_ref()))?;
let smtp = smtp_client.ok_or_else(|| MiscError::ValidationError {
message: "SMTP not configured. Set smtp_host, smtp_port, smtp_username, smtp_password first. \
If smtp_username is not an email address, also set smtp_sender."
.into(),
})?;
let recipient = self.db.get_setting("smtp_recipient")?
let recipient = self
.repo
.get_setting("smtp_recipient")?
.filter(|r| !r.is_empty())
.ok_or_else(|| {
MiscError::ValidationError { message:
"No smtp_recipient configured.".into()
}
.ok_or_else(|| MiscError::ValidationError {
message: "No smtp_recipient configured.".into(),
})?;
smtp.send(

View File

@ -1,85 +1,57 @@
use std::sync::Arc;
use crate::adapter::persistence::Database;
use crate::core::soar::engine::SoarEngine;
use crate::interface::port::access_control::AccessControlPort;
use crate::interface::port::soar::SoarPort;
use macros::log;
use crate::model::error::Error;
use crate::model::error::soar::SoarError;
use crate::model::soar::playbook_data::{
ActionData, ActiveBlockData, ConditionData, CreatePlaybookInput, ExecutionData, PlaybookData, UpdatePlaybookRow,
};
use std::collections::HashMap;
/// Domain service for SOAR playbook CRUD operations.
/// Coordinates DB reads/writes, SOAR engine cache refresh, and eBPF unblock.
pub struct PlaybookService {
db: Arc<Database>,
db: Arc<dyn SoarPort>,
soar_engine: Arc<SoarEngine>,
access_control: Arc<dyn AccessControlPort>,
}
/// Input for creating a new playbook.
pub struct CreatePlaybookInput {
pub name: String,
pub trigger_event: String,
pub condition_threshold: Option<f64>,
pub condition_count: Option<i64>,
pub condition_window_secs: Option<i64>,
pub cooldown_secs: i64,
pub actions: Vec<(String, String)>, // (action_type, params_json)
}
/// Flattened playbook representation for API responses.
pub struct PlaybookData {
pub id: i64,
pub name: String,
pub enabled: bool,
pub trigger_event: String,
pub condition_threshold: Option<f64>,
pub condition_count: Option<i64>,
pub condition_window_secs: Option<i64>,
pub cooldown_secs: i64,
pub actions: Vec<ActionData>,
}
pub struct ActionData {
pub id: i64,
pub action_order: i64,
pub action_type: String,
pub params: serde_json::Value,
}
/// Execution record from soar_executions table.
pub struct ExecutionData {
pub id: i64,
pub playbook_id: i64,
pub source_ip: Option<String>,
pub trigger_event: String,
pub actions_executed: serde_json::Value,
pub created_at: String,
}
/// Active block record from soar_block_rules table.
pub struct ActiveBlockData {
pub id: i64,
pub source_ip: String,
pub playbook_id: i64,
pub expires_at: String,
}
impl PlaybookService {
pub fn new(
db: Arc<Database>,
db: Arc<dyn SoarPort>,
soar_engine: Arc<SoarEngine>,
access_control: Arc<dyn AccessControlPort>,
) -> Self {
Self { db, soar_engine, access_control }
Self {
db,
soar_engine,
access_control,
}
}
pub fn list_playbooks(&self) -> Result<Vec<PlaybookData>, Error> {
let rows = self.db.load_playbooks_with_actions()?;
let mut result: Vec<PlaybookData> = Vec::new();
for (pb_id, name, enabled, trigger_event, threshold, count, window, cooldown,
action_id, action_order, action_type, action_params) in rows
for (
pb_id,
name,
enabled,
trigger_event,
threshold,
count,
window,
cooldown,
action_id,
action_order,
action_type,
action_params,
) in rows
{
// Find or create the playbook entry
let pb = if let Some(last) = result.last_mut() {
@ -87,25 +59,35 @@ impl PlaybookService {
last
} else {
result.push(PlaybookData {
id: pb_id, name, enabled, trigger_event,
id: pb_id,
name,
enabled,
trigger_event,
condition_threshold: threshold,
condition_count: count,
condition_window_secs: window,
cooldown_secs: cooldown,
actions: Vec::new(),
conditions: Vec::new(),
});
result.last_mut().unwrap()
// SAFETY: just pushed above, Vec cannot be empty
result.last_mut().unwrap_or_else(|| unreachable!())
}
} else {
result.push(PlaybookData {
id: pb_id, name, enabled, trigger_event,
id: pb_id,
name,
enabled,
trigger_event,
condition_threshold: threshold,
condition_count: count,
condition_window_secs: window,
cooldown_secs: cooldown,
actions: Vec::new(),
conditions: Vec::new(),
});
result.last_mut().unwrap()
// SAFETY: just pushed above, Vec cannot be empty
result.last_mut().unwrap_or_else(|| unreachable!())
};
// Append action if present (LEFT JOIN may yield NULLs)
@ -116,26 +98,101 @@ impl PlaybookService {
id: aid,
action_order: order,
action_type: atype,
params: serde_json::from_str(&params_str)
.unwrap_or(serde_json::Value::Null),
params: serde_json::from_str(&params_str).unwrap_or(serde_json::Value::Null),
});
}
}
// Load conditions and attach to playbooks
let cond_rows = self.db.load_all_playbook_conditions()?;
let mut cond_map: HashMap<i64, Vec<ConditionData>> = HashMap::new();
for (cid, pb_id, ctype, operator, value, value2) in cond_rows {
cond_map.entry(pb_id).or_default().push(ConditionData {
id: cid,
condition_type: ctype,
operator,
value,
value2,
});
}
for pb in &mut result {
if let Some(conds) = cond_map.remove(&pb.id) {
pb.conditions = conds;
}
}
Ok(result)
}
pub fn create_playbook(&self, input: &CreatePlaybookInput) -> Result<i64, Error> {
let playbook_id = self.db.insert_playbook(
&input.name, &input.trigger_event, input.condition_threshold,
input.condition_count, input.condition_window_secs, input.cooldown_secs,
&input.name,
&input.trigger_event,
input.condition_threshold,
input.condition_count,
input.condition_window_secs,
input.cooldown_secs,
)?;
for (i, (action_type, params_str)) in input.actions.iter().enumerate() {
self.db.insert_playbook_action(playbook_id, (i + 1) as i64, action_type, params_str)?;
self.db
.insert_playbook_action(playbook_id, (i + 1) as i64, action_type, params_str)?;
}
for cond in &input.conditions {
self.db.insert_playbook_condition(
playbook_id,
&cond.condition_type,
&cond.operator,
&cond.value,
cond.value2.as_deref(),
)?;
}
self.soar_engine.reload_cache()?;
Ok(playbook_id)
}
pub fn update_playbook(&self, id: i64, input: &CreatePlaybookInput) -> Result<bool, Error> {
let row = UpdatePlaybookRow {
name: input.name.clone(),
trigger_event: input.trigger_event.clone(),
condition_threshold: input.condition_threshold,
condition_count: input.condition_count,
condition_window_secs: input.condition_window_secs,
cooldown_secs: input.cooldown_secs,
};
let updated = self.db.update_playbook(id, &row)?;
if !updated {
return Ok(false);
}
// Delete old actions and conditions, then re-insert
self.db.delete_playbook_actions(id)?;
self.db.delete_playbook_conditions(id)?;
for (i, (action_type, params_str)) in input.actions.iter().enumerate() {
self.db
.insert_playbook_action(id, (i + 1) as i64, action_type, params_str)?;
}
for cond in &input.conditions {
self.db.insert_playbook_condition(
id,
&cond.condition_type,
&cond.operator,
&cond.value,
cond.value2.as_deref(),
)?;
}
self.soar_engine.reload_cache()?;
Ok(true)
}
pub fn toggle_playbook(&self, id: i64, enabled: bool) -> Result<bool, Error> {
let updated = self.db.update_playbook_enabled(id, enabled)?;
if updated {
self.soar_engine.reload_cache()?;
}
Ok(updated)
}
pub fn delete_playbook(&self, id: i64) -> Result<bool, Error> {
let deleted = self.db.delete_playbook(id)?;
if deleted {
@ -146,15 +203,23 @@ impl PlaybookService {
pub fn list_active_blocks(&self) -> Result<Vec<ActiveBlockData>, Error> {
let blocks = self.db.get_active_soar_blocks()?;
Ok(blocks.into_iter().map(|(id, ip, pb_id, expires)| {
ActiveBlockData { id, source_ip: ip, playbook_id: pb_id, expires_at: expires }
}).collect())
Ok(blocks
.into_iter()
.map(|(id, ip, pb_id, expires)| ActiveBlockData {
id,
source_ip: ip,
playbook_id: pb_id,
expires_at: expires,
})
.collect())
}
/// Manually unblock an IP: remove from eBPF, mark DB, decrement counter.
pub async fn manual_unblock(&self, id: i64) -> Result<(), Error> {
// Look up the block to get source_ip
let block = self.db.get_soar_block_by_id(id)?
let block = self
.db
.get_soar_block_by_id(id)?
.ok_or_else(|| SoarError::ActionFailed {
action_type: "manual_unblock".to_string(),
reason: format!("Block rule {} not found", id),
@ -181,16 +246,19 @@ impl PlaybookService {
pub fn list_executions(&self, limit: i64) -> Result<Vec<ExecutionData>, Error> {
let rows = self.db.list_soar_executions(limit)?;
Ok(rows.into_iter().map(|(id, pb_id, source_ip, trigger_event, actions, created_at)| {
ExecutionData {
id,
playbook_id: pb_id,
source_ip,
trigger_event,
actions_executed: serde_json::from_str(&actions).unwrap_or(serde_json::Value::Null),
created_at,
}
}).collect())
Ok(rows
.into_iter()
.map(
|(id, pb_id, source_ip, trigger_event, actions, created_at)| ExecutionData {
id,
playbook_id: pb_id,
source_ip,
trigger_event,
actions_executed: serde_json::from_str(&actions).unwrap_or(serde_json::Value::Null),
created_at,
},
)
.collect())
}
pub fn list_whitelist(&self) -> Result<Vec<String>, Error> {
@ -215,7 +283,13 @@ pub fn ip_version_from_str(ip: &str) -> u8 {
match ip.parse::<std::net::IpAddr>() {
Ok(std::net::IpAddr::V4(_)) => 4,
Ok(std::net::IpAddr::V6(_)) => 6,
Err(_) => if ip.contains(':') { 6 } else { 4 }, // fallback
Err(_) => {
if ip.contains(':') {
6
} else {
4
}
} // fallback
}
}

View File

@ -44,13 +44,4 @@ impl RateLimitService {
}
}
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
pub struct RateLimitSettings {
pub packet_rate: Option<u64>,
pub syn_rate: Option<u64>,
pub udp_rate: Option<u64>,
pub dns_rate: Option<u64>,
pub window_ns: Option<u64>,
}
use crate::model::system::rate_limit_settings::RateLimitSettings;

View File

@ -1,161 +1 @@
use serde::{Deserialize, Serialize};
use crate::interface::port::repository::RepositoryPort;
use crate::model::error::Error;
/// Shared report data structure used by both HTML email and PDF report.
#[derive(Debug, Clone, Serialize)]
pub struct ReportData {
pub period: String,
pub generated_at: String,
pub executive_summary: ExecutiveSummary,
pub threat_breakdown: Vec<ThreatBreakdownItem>,
pub top_blocked_ips: Vec<BlockedIpItem>,
pub geo_distribution: Vec<GeoItem>,
pub soar_activity: SoarActivity,
pub system_health: SystemHealthSummary,
pub recommendations: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ExecutiveSummary {
pub total_threats: u64,
pub total_blocked: u64,
pub uptime_percent: f64,
pub active_rules: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct ThreatBreakdownItem {
pub threat_type: String,
pub count: u64,
pub trend: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BlockedIpItem {
pub ip: String,
pub count: u64,
pub country: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GeoItem {
pub country: String,
pub threat_count: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct SoarActivity {
pub auto_blocks_executed: u64,
pub playbooks_triggered: u64,
pub auto_unblocks: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SystemHealthSummary {
pub avg_cpu_percent: f64,
pub avg_memory_percent: f64,
pub disk_usage_percent: f64,
pub ebpf_status: String,
}
impl ReportData {
/// Build report data from database settings (aggregated by the ML pipeline).
pub fn from_database(db: &dyn RepositoryPort) -> Result<Self, Error> {
let now = chrono::Local::now();
let period = format!("{}{}", (now - chrono::Duration::days(7)).format("%Y-%m-%d"), now.format("%Y-%m-%d"));
let threats_count: u64 = db.get_setting("weekly_threats_count")?
.and_then(|v| v.parse().ok())
.unwrap_or(0);
let top_ips: Vec<BlockedIpItem> = db.get_setting("weekly_top_ips")?
.and_then(|v| serde_json::from_str(&v).ok())
.unwrap_or_else(|| vec![
BlockedIpItem { ip: "".into(), count: 0, country: "N/A".into() },
]);
let breakdown: Vec<ThreatBreakdownItem> = db.get_setting("weekly_threat_breakdown")?
.and_then(|v| {
let obj: serde_json::Value = serde_json::from_str(&v).ok()?;
let items = obj.as_object()?.iter().map(|(k, v)| {
ThreatBreakdownItem {
threat_type: k.clone(),
count: v.as_u64().unwrap_or(0),
trend: "".into(),
}
}).collect();
Some(items)
})
.unwrap_or_default();
let health: SystemHealthSummary = db.get_setting("weekly_system_health")?
.and_then(|v| serde_json::from_str(&v).ok())
.unwrap_or(SystemHealthSummary {
avg_cpu_percent: 0.0,
avg_memory_percent: 0.0,
disk_usage_percent: 0.0,
ebpf_status: "running".into(),
});
// Generate recommendations based on data
let mut recommendations = Vec::new();
if threats_count > 10 {
recommendations.push("Consider enabling geo-blocking for high-risk regions".into());
}
if breakdown.iter().any(|b| b.threat_type == "port_scan" && b.count > 50) {
recommendations.push("Review exposed ports and consider tightening protocol filter rules".into());
}
if recommendations.is_empty() {
recommendations.push("No action needed — your network security posture is healthy".into());
}
let uptime_percent: f64 = db.get_setting("system_uptime_percent")?
.and_then(|v| v.parse().ok())
.unwrap_or(0.0);
let active_rules: u64 = db.get_setting("active_rules_count")?
.and_then(|v| v.parse().ok())
.unwrap_or(0);
let geo_distribution: Vec<GeoItem> = db.get_setting("weekly_geo_distribution")?
.and_then(|v| serde_json::from_str(&v).ok())
.unwrap_or_default();
let auto_blocks: u64 = db.get_setting("weekly_soar_blocks")?
.and_then(|v| v.parse().ok())
.unwrap_or(0);
let playbooks_triggered: u64 = db.get_setting("weekly_soar_triggers")?
.and_then(|v| v.parse().ok())
.unwrap_or(0);
let auto_unblocks: u64 = db.get_setting("weekly_soar_unblocks")?
.and_then(|v| v.parse().ok())
.unwrap_or(0);
let blocked_count: u64 = db.get_setting("weekly_blocked_count")?
.and_then(|v| v.parse().ok())
.unwrap_or(auto_blocks);
Ok(ReportData {
period,
generated_at: now.format("%Y-%m-%d %H:%M:%S").to_string(),
executive_summary: ExecutiveSummary {
total_threats: threats_count,
total_blocked: blocked_count,
uptime_percent,
active_rules,
},
threat_breakdown: breakdown,
top_blocked_ips: top_ips,
geo_distribution,
soar_activity: SoarActivity {
auto_blocks_executed: auto_blocks,
playbooks_triggered,
auto_unblocks,
},
system_health: health,
recommendations,
})
}
}
// Types are available via crate::model::report::data

View File

@ -1,10 +1,10 @@
use std::path::PathBuf;
use tracing::info;
use crate::core::report::data::ReportData;
use crate::interface::port::repository::RepositoryPort;
use crate::model::error::notification::NotificationError;
use crate::model::error::Error;
use crate::model::error::notification::NotificationError;
use crate::model::report::data::ReportData;
/// Generate a self-contained HTML security report and write to disk.
/// Returns the path to the generated HTML file.
@ -17,16 +17,12 @@ pub fn generate_html_report(db: &dyn RepositoryPort, output_dir: &str) -> Result
chrono::Local::now().format("%Y%m%d-%H%M%S")
));
std::fs::create_dir_all(output_dir).map_err(|e| {
NotificationError::TelegramApiError {
reason: format!("Failed to create report directory: {}", e),
}
std::fs::create_dir_all(output_dir).map_err(|e| NotificationError::TelegramApiError {
reason: format!("Failed to create report directory: {}", e),
})?;
std::fs::write(&html_path, &html).map_err(|e| {
NotificationError::TelegramApiError {
reason: format!("Failed to write HTML report: {}", e),
}
std::fs::write(&html_path, &html).map_err(|e| NotificationError::TelegramApiError {
reason: format!("Failed to write HTML report: {}", e),
})?;
info!("HTML report generated at {:?}", html_path);
@ -41,11 +37,14 @@ fn render_html_report(data: &ReportData) -> String {
for item in &data.threat_breakdown {
breakdown_rows.push_str(&format!(
"<tr><td>{}</td><td class=\"num\">{}</td><td>{}</td></tr>",
html_escape(&item.threat_type), item.count, html_escape(&item.trend)
html_escape(&item.threat_type),
item.count,
html_escape(&item.trend)
));
}
if data.threat_breakdown.is_empty() {
breakdown_rows.push_str("<tr><td colspan=\"3\" class=\"empty\">No threat data available for this period</td></tr>");
breakdown_rows
.push_str("<tr><td colspan=\"3\" class=\"empty\">No threat data available for this period</td></tr>");
}
// Top blocked IPs rows
@ -53,7 +52,9 @@ fn render_html_report(data: &ReportData) -> String {
for ip in &data.top_blocked_ips {
ip_rows.push_str(&format!(
"<tr><td><code>{}</code></td><td class=\"num\">{}</td><td>{}</td></tr>",
html_escape(&ip.ip), ip.count, html_escape(&ip.country)
html_escape(&ip.ip),
ip.count,
html_escape(&ip.country)
));
}
if data.top_blocked_ips.is_empty() {
@ -65,7 +66,8 @@ fn render_html_report(data: &ReportData) -> String {
for geo in &data.geo_distribution {
geo_rows.push_str(&format!(
"<tr><td>{}</td><td class=\"num\">{}</td></tr>",
html_escape(&geo.country), geo.threat_count
html_escape(&geo.country),
geo.threat_count
));
}
if data.geo_distribution.is_empty() {
@ -204,6 +206,7 @@ pub fn generate_report_json(db: &dyn RepositoryPort) -> Result<serde_json::Value
serde_json::to_value(&data).map_err(|e| {
NotificationError::TelegramApiError {
reason: format!("Failed to serialize report: {}", e),
}.into()
}
.into()
})
}

View File

@ -1,2 +1,2 @@
pub mod engine;
pub mod data;
pub mod engine;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,87 @@
use std::collections::VecDeque;
use std::time::{Duration, Instant};
use dashmap::DashMap;
/// Key for frequency tracking: (playbook_id, source_ip).
type FreqKey = (i64, String);
/// Maximum tracked keys to bound memory under DDoS.
const MAX_TRACKED_KEYS: usize = 50_000;
/// Lock-free frequency tracker using DashMap for concurrent per-IP event counting.
pub struct FrequencyTracker {
events: DashMap<FreqKey, VecDeque<Instant>>,
max_deque_size: usize,
}
impl FrequencyTracker {
pub fn new() -> Self {
Self {
events: DashMap::new(),
max_deque_size: 200,
}
}
/// Record an event and return the count of events within the given window.
pub fn record_and_count(&self, playbook_id: i64, source_ip: &str, window_secs: u64) -> u64 {
let key = (playbook_id, source_ip.to_string());
let now = Instant::now();
let window = Duration::from_secs(window_secs);
let mut entry = self.events.entry(key).or_default();
let deque = entry.value_mut();
// Prune expired entries from the front
while let Some(front) = deque.front() {
if now.duration_since(*front) > window {
deque.pop_front();
} else {
break;
}
}
deque.push_back(now);
// Cap deque size to prevent unbounded growth
while deque.len() > self.max_deque_size {
deque.pop_front();
}
deque.len() as u64
}
/// Remove empty deques and entries where all timestamps are expired.
/// Uses a conservative 2-hour max window for expiry detection.
pub fn cleanup(&self) -> u32 {
let now = Instant::now();
let max_window = Duration::from_secs(7200); // 2 hours — conservative upper bound
let mut removed = 0u32;
self.events.retain(|_, deque| {
if deque.is_empty() {
removed += 1;
return false;
}
// If all entries are older than max_window, remove the whole entry
if let Some(newest) = deque.back()
&& now.checked_duration_since(*newest).unwrap_or(Duration::ZERO) > max_window
{
removed += 1;
return false;
}
true
});
// Enforce max key cap to prevent unbounded growth under DDoS
if self.events.len() > MAX_TRACKED_KEYS {
let excess = self.events.len() - MAX_TRACKED_KEYS;
let keys_to_remove: Vec<FreqKey> = self.events.iter().take(excess).map(|e| e.key().clone()).collect();
for key in keys_to_remove {
self.events.remove(&key);
removed += 1;
}
}
removed
}
}

View File

@ -1,2 +1,3 @@
pub mod engine;
pub mod frequency;
pub mod scheduler;

View File

@ -3,9 +3,9 @@ use std::sync::Arc;
use macros::log;
use tokio::time::{self, Duration};
use crate::adapter::persistence::Database;
use crate::core::soar::engine::SoarEngine;
use crate::interface::port::access_control::AccessControlPort;
use crate::interface::port::soar::SoarPort;
use crate::model::error::Error;
use crate::model::error::soar::SoarError;
use crate::model::log::soar::SoarLog;
@ -13,18 +13,22 @@ use crate::model::log::soar::SoarLog;
/// TTL expiry scheduler: runs every 60 seconds, removes expired auto-block rules.
/// Before removing from eBPF, checks if a manual ACL rule exists for the same IP.
pub struct TtlScheduler {
db: Arc<Database>,
db: Arc<dyn SoarPort>,
access_control: Arc<dyn AccessControlPort>,
soar_engine: Arc<SoarEngine>,
}
impl TtlScheduler {
pub fn new(
db: Arc<Database>,
db: Arc<dyn SoarPort>,
access_control: Arc<dyn AccessControlPort>,
soar_engine: Arc<SoarEngine>,
) -> Self {
Self { db, access_control, soar_engine }
Self {
db,
access_control,
soar_engine,
}
}
/// Spawn a background tokio task that runs the TTL sweep every 60 seconds.
@ -42,13 +46,19 @@ impl TtlScheduler {
}
/// Sweep expired block rules and remove from eBPF if no manual ACL conflict.
/// Also checks for expired rate limit adjustments.
/// Also checks for expired rate limit adjustments and cleans up stale cooldowns.
async fn sweep(&self) -> Result<(), Error> {
// Check rate limit restoration
if let Err(e) = self.soar_engine.check_rate_limit_restoration() {
log!(SoarLog::EventHandlingFailed(format!("Rate limit restoration check failed: {}", e)));
if let Err(e) = self.soar_engine.check_rate_limit_restoration().await {
log!(SoarLog::EventHandlingFailed(format!(
"Rate limit restoration check failed: {}",
e
)));
}
// Clean up expired cooldown + frequency tracker entries to prevent unbounded memory growth
self.soar_engine.cleanup_expired_cooldowns();
let expired = self.db.get_expired_soar_blocks()?;
if expired.is_empty() {
@ -67,13 +77,19 @@ impl TtlScheduler {
self.db.mark_soar_block_unblocked(*id)?;
self.soar_engine.decrement_block_count();
skipped += 1;
log!(SoarLog::WhitelistSkipped(source_ip.clone(), "TTL expired but manual ACL exists".to_string()));
log!(SoarLog::WhitelistSkipped(
source_ip.clone(),
"TTL expired but manual ACL exists".to_string()
));
continue;
}
// Remove from eBPF ACL via AccessControlPort
if let Err(e) = self.access_control.unblock_ip(source_ip).await {
log!(SoarLog::RecoveryFailed(source_ip.clone(), format!("unblock failed: {}", e)));
log!(SoarLog::RecoveryFailed(
source_ip.clone(),
format!("unblock failed: {}", e)
));
}
// Also remove from acl_rules DB table (the auto-added entry)

View File

@ -3,18 +3,20 @@ use std::sync::Arc;
use tokio::time::{self, Duration};
use tracing::{error, info};
use crate::adapter::persistence::Database;
use crate::interface::port::repository::RepositoryPort;
use crate::interface::port::stats::StatsPort;
use crate::model::error::Error;
/// Background service that periodically aggregates statistics from SOAR/ML tables
/// and writes them to the settings table for the Report engine to consume.
pub struct StatsAggregator {
db: Arc<Database>,
stats: Arc<dyn StatsPort>,
repo: Arc<dyn RepositoryPort>,
}
impl StatsAggregator {
pub fn new(db: Arc<Database>) -> Self {
Self { db }
pub fn new(stats: Arc<dyn StatsPort>, repo: Arc<dyn RepositoryPort>) -> Self {
Self { stats, repo }
}
/// Spawn a background task that runs aggregation every hour.
@ -40,31 +42,35 @@ impl StatsAggregator {
let days = 7;
// SOAR execution counts
let threats_count = self.db.count_weekly_executions(days)?;
self.db.set_setting("weekly_threats_count", &threats_count.to_string())?;
let threats_count = self.stats.count_weekly_executions(days)?;
self.repo
.set_setting("weekly_threats_count", &threats_count.to_string())?;
let blocks_count = self.db.count_weekly_blocks(days)?;
self.db.set_setting("weekly_soar_blocks", &blocks_count.to_string())?;
self.db.set_setting("weekly_soar_triggers", &threats_count.to_string())?;
let blocks_count = self.stats.count_weekly_blocks(days)?;
self.repo.set_setting("weekly_soar_blocks", &blocks_count.to_string())?;
self.repo
.set_setting("weekly_soar_triggers", &threats_count.to_string())?;
let unblocks_count = self.db.count_weekly_unblocks(days)?;
self.db.set_setting("weekly_soar_unblocks", &unblocks_count.to_string())?;
let unblocks_count = self.stats.count_weekly_unblocks(days)?;
self.repo
.set_setting("weekly_soar_unblocks", &unblocks_count.to_string())?;
self.db.set_setting("weekly_blocked_count", &blocks_count.to_string())?;
self.repo
.set_setting("weekly_blocked_count", &blocks_count.to_string())?;
// Threat breakdown by type
let breakdown = self.db.weekly_threat_breakdown(days)?;
let breakdown = self.stats.weekly_threat_breakdown(days)?;
let breakdown_json: serde_json::Map<String, serde_json::Value> = breakdown
.into_iter()
.map(|(k, v)| (k, serde_json::Value::Number(v.into())))
.collect();
self.db.set_setting(
self.repo.set_setting(
"weekly_threat_breakdown",
&serde_json::to_string(&breakdown_json).unwrap_or_else(|_| "{}".to_string()),
)?;
// Top blocked IPs
let top_ips = self.db.weekly_top_ips(days, 10)?;
let top_ips = self.stats.weekly_top_ips(days, 10)?;
let top_ips_json: Vec<serde_json::Value> = top_ips
.into_iter()
.map(|(ip, count)| {
@ -75,14 +81,14 @@ impl StatsAggregator {
})
})
.collect();
self.db.set_setting(
self.repo.set_setting(
"weekly_top_ips",
&serde_json::to_string(&top_ips_json).unwrap_or_else(|_| "[]".to_string()),
)?;
// Active rules count
let active_rules = self.db.count_acl_rules()?;
self.db.set_setting("active_rules_count", &active_rules.to_string())?;
let active_rules = self.stats.count_acl_rules()?;
self.repo.set_setting("active_rules_count", &active_rules.to_string())?;
// System health snapshot using sysinfo
{
@ -105,7 +111,7 @@ impl StatsAggregator {
"disk_usage_percent": 0.0,
"ebpf_status": "running",
});
self.db.set_setting(
self.repo.set_setting(
"weekly_system_health",
&serde_json::to_string(&health_json).unwrap_or_else(|_| "{}".to_string()),
)?;
@ -118,12 +124,13 @@ impl StatsAggregator {
} else {
(uptime_secs as f64 / week_secs as f64) * 100.0
};
self.db.set_setting("system_uptime_percent", &format!("{:.1}", uptime_percent))?;
self.repo
.set_setting("system_uptime_percent", &format!("{:.1}", uptime_percent))?;
}
// Geo distribution (initialize if not present)
if self.db.get_setting("weekly_geo_distribution")?.is_none() {
self.db.set_setting("weekly_geo_distribution", "[]")?;
if self.repo.get_setting("weekly_geo_distribution")?.is_none() {
self.repo.set_setting("weekly_geo_distribution", "[]")?;
}
info!(
@ -138,6 +145,7 @@ impl StatsAggregator {
#[cfg(test)]
mod tests {
use super::*;
use crate::adapter::persistence::Database;
#[test]
fn aggregator_writes_weekly_stats() {
@ -145,11 +153,12 @@ mod tests {
// Seed some SOAR executions
db.seed_default_playbooks().ok();
db.insert_soar_execution(1, Some("1.2.3.4"), "threat_detected", "[]").ok();
db.insert_soar_execution(1, Some("1.2.3.4"), "threat_detected", "[]")
.ok();
db.insert_soar_execution(1, Some("5.6.7.8"), "brute_force", "[]").ok();
db.insert_soar_block_rule("1.2.3.4", 1, "2099-01-01 00:00:00").ok();
let aggregator = StatsAggregator::new(db.clone());
let aggregator = StatsAggregator::new(db.clone() as Arc<dyn StatsPort>, db.clone() as Arc<dyn RepositoryPort>);
aggregator.aggregate().expect("aggregation should succeed");
// Verify settings were written
@ -178,8 +187,10 @@ mod tests {
#[test]
fn aggregator_handles_empty_db() {
let db = Arc::new(Database::new(":memory:").expect("test db"));
let aggregator = StatsAggregator::new(db.clone());
aggregator.aggregate().expect("aggregation should succeed with empty data");
let aggregator = StatsAggregator::new(db.clone() as Arc<dyn StatsPort>, db.clone() as Arc<dyn RepositoryPort>);
aggregator
.aggregate()
.expect("aggregation should succeed with empty data");
let threats = db.get_setting("weekly_threats_count").unwrap().unwrap();
assert_eq!(threats, "0");

View File

@ -1,33 +1,69 @@
use std::sync::Arc;
use aya::maps::{MapData, ProgramArray};
use aya::Ebpf;
use aya::maps::{MapData, ProgramArray};
use macros::log;
use crate::adapter::persistence::Database;
use crate::core::acl_service::AclService;
use crate::core::auth::jwt::JwtService;
use crate::core::config_service::ConfigService;
use crate::core::dns_filter_service::DnsFilterService;
use crate::core::ebpf::EbpfServices;
use crate::core::email::scheduler::ReportScheduler;
use crate::core::ml::config_loader::InferenceConfig;
use crate::core::ml::drift_detector::DriftDetector;
use crate::core::notification_service::NotificationService;
use crate::core::playbook_service::PlaybookService;
use crate::core::rate_limit_service::RateLimitService;
use crate::adapter::persistence::Database;
use crate::core::ebpf::EbpfServices;
use crate::infrastructure::app_config::AppConfig;
use crate::infrastructure::app_services::AppServices;
use crate::infrastructure::communication_manager::CommunicationManager;
use crate::core::ml::config_loader::InferenceConfig;
use crate::infrastructure::http_server::HttpServerParams;
use crate::infrastructure::service_factory::ServiceFactory;
use crate::core::email::scheduler::ReportScheduler;
use crate::core::soar::engine::SoarEngine;
use crate::core::soar::scheduler::TtlScheduler;
use crate::interface::communication::event_types::ThreatDetectedEvent;
use crate::infrastructure::app_config::AppConfig;
use crate::infrastructure::app_services::AppServices;
use crate::infrastructure::audit_logger::AuditLogger;
use crate::infrastructure::communication_manager::CommunicationManager;
use crate::infrastructure::geoip::GeoIpService;
use crate::infrastructure::http_server::HttpServerParams;
use crate::infrastructure::secret_store::SecretStore;
use crate::infrastructure::service_factory::ServiceFactory;
use crate::model::error::Error;
use crate::model::error::system::SystemError;
use crate::model::event::{DetectionEvent, DetectionSource, DriftDetectedEvent};
use crate::model::log::detection::DetectionLog;
use crate::model::log::ml::MLLog;
use crate::model::log::system::SystemLog;
use crate::model::ml_detection::AlertMessage;
use crate::model::system::readiness::ReadinessState;
/// API-triggered shutdown mode.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ShutdownMode {
Shutdown,
Restart,
}
/// Handle for triggering shutdown from HTTP endpoints.
/// Uses a parking_lot::Mutex<Option<oneshot::Sender>> so it can be shared as app_data.
pub struct ShutdownHandle {
tx: parking_lot::Mutex<Option<tokio::sync::oneshot::Sender<ShutdownMode>>>,
}
impl ShutdownHandle {
fn new(tx: tokio::sync::oneshot::Sender<ShutdownMode>) -> Self {
Self {
tx: parking_lot::Mutex::new(Some(tx)),
}
}
/// Trigger shutdown. Returns false if already triggered.
pub fn trigger(&self, mode: ShutdownMode) -> bool {
if let Some(tx) = self.tx.lock().take() {
tx.send(mode).is_ok()
} else {
false
}
}
}
/// Orchestrates system lifecycle: startup ordering and shutdown.
/// Construction is delegated to `ServiceFactory::build()`.
@ -38,6 +74,7 @@ pub struct System {
pub ebpf_services: Arc<EbpfServices>,
pub app_services: Arc<AppServices>,
pub db: Arc<Database>,
pub secret_store: Arc<SecretStore>,
pub jwt_service: Arc<JwtService>,
pub comm: Arc<CommunicationManager>,
pub soar_engine: Arc<SoarEngine>,
@ -51,6 +88,9 @@ pub struct System {
pub notification_service: Arc<NotificationService>,
pub playbook_service: Arc<PlaybookService>,
pub rate_limit_service: Arc<RateLimitService>,
pub geoip: Option<Arc<GeoIpService>>,
pub drift_detector: Arc<parking_lot::Mutex<DriftDetector>>,
pub shutdown_handle: Option<Arc<ShutdownHandle>>,
_ingress_program_array: ProgramArray<MapData>,
}
@ -64,6 +104,7 @@ impl System {
ebpf_services: state.ebpf_services,
app_services: state.app_services,
db: state.db,
secret_store: state.secret_store,
jwt_service: state.jwt_service,
comm: state.comm,
soar_engine: state.soar_engine,
@ -77,12 +118,16 @@ impl System {
notification_service: state.notification_service,
playbook_service: state.playbook_service,
rate_limit_service: state.rate_limit_service,
geoip: state.geoip,
drift_detector: state.drift_detector,
shutdown_handle: None,
_ingress_program_array: state._ingress_program_array,
})
}
/// Start all services and HTTP server. Setup is already complete at this point.
pub async fn run(&mut self) -> Result<(), Error> {
/// Returns the shutdown mode requested (Shutdown or Restart).
pub async fn run(&mut self) -> Result<ShutdownMode, Error> {
log!(SystemLog::Initializing);
log!(MLLog::ModelsLoaded(
@ -122,16 +167,91 @@ impl System {
report.run();
}
// Start audit logger (subscribe to AuditEvent + DriftDetectedEvent, persist to DB)
let audit_logger = Arc::new(AuditLogger::new(
self.db.clone() as Arc<dyn crate::interface::port::audit::AuditPort>
));
audit_logger.start(&self.comm);
// Start stats aggregator (writes weekly_* settings for Report engine)
let stats_aggregator = crate::core::stats_aggregator::StatsAggregator::new(self.db.clone());
let stats_aggregator = crate::core::stats_aggregator::StatsAggregator::new(
self.db.clone() as Arc<dyn crate::interface::port::stats::StatsPort>,
self.db.clone() as Arc<dyn crate::interface::port::repository::RepositoryPort>,
);
stats_aggregator.start();
// Bridge ML alerts → SOAR
let comm_for_bridge = self.comm.clone();
// Start drift detection background task
{
let drift_detector = self.drift_detector.clone();
let comm_drift = self.comm.clone();
tokio::spawn(async move {
Self::run_drift_monitor(drift_detector, comm_drift).await;
});
}
// Start detection orchestrator (dedup + enrichment + source attribution)
let (detection_tx, detection_rx) = tokio::sync::mpsc::channel::<DetectionEvent>(1024);
let orchestrator = crate::core::detection::orchestrator::DetectionOrchestrator::new(
detection_rx,
self.comm.clone(),
self.geoip.clone(),
);
orchestrator.start();
// Clone detection_tx for correlation engine and beaconing detector
let correlation_detection_tx = detection_tx.clone();
let beaconing_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();
let correlation_engine =
crate::core::correlation::engine::CorrelationEngine::new(correlation_alert_rx, correlation_detection_tx);
correlation_engine.start();
// Start temporal beaconing detector (CV-based C2 periodicity detection)
let beaconing_alert_rx = self.app_services.ml_alert.subscribe_to_alerts();
let beaconing_detector =
crate::core::detection::beaconing::BeaconingDetector::new(beaconing_alert_rx, beaconing_detection_tx);
beaconing_detector.start();
// Bridge ML alerts → DetectionEvent (thin adapter, no enrichment)
tokio::spawn(async move {
Self::bridge_ml_to_soar(ml_alert_rx, comm_for_bridge).await;
Self::bridge_ml_to_detection(ml_alert_rx, detection_tx).await;
});
// Initialize force_https flag from DB setting
let force_https = Arc::new(std::sync::atomic::AtomicBool::new(
self.db
.get_setting("force_https")
.ok()
.flatten()
.map(|v| v == "true")
.unwrap_or(false),
));
// Build per-subsystem readiness flags for /health/ready
let readiness_state = Arc::new(ReadinessState::new());
// DB is connected (System::new succeeded), ML models loaded (AppServices::new succeeded)
readiness_state
.db_connected
.store(true, std::sync::atomic::Ordering::SeqCst);
readiness_state
.ml_model_loaded
.store(true, std::sync::atomic::Ordering::SeqCst);
// eBPF was attached above (self.attach_ebpf succeeded)
readiness_state
.ebpf_attached
.store(true, std::sync::atomic::Ordering::SeqCst);
// SOAR engine started above (self.soar_engine.start succeeded)
readiness_state
.soar_engine_running
.store(true, std::sync::atomic::Ordering::SeqCst);
// Create shutdown channel for API-triggered shutdown/restart
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<ShutdownMode>();
let shutdown_handle = Arc::new(ShutdownHandle::new(shutdown_tx));
self.shutdown_handle = Some(shutdown_handle.clone());
// Start HTTP server in background (!Send, use actix::spawn)
let setup_flag = Arc::new(std::sync::atomic::AtomicBool::new(true));
let ready_flag = Arc::new(std::sync::atomic::AtomicBool::new(false));
@ -142,16 +262,20 @@ impl System {
ebpf_services: self.ebpf_services.clone(),
app_services: self.app_services.clone(),
db: self.db.clone(),
secret_store: self.secret_store.clone(),
jwt_service: self.jwt_service.clone(),
comm: self.comm.clone(),
setup_complete: setup_flag,
ready: ready_flag,
readiness_state,
acl_service: self.acl_service.clone(),
config_service: self.config_service.clone(),
dns_filter_service: self.dns_filter_service.clone(),
notification_service: self.notification_service.clone(),
playbook_service: self.playbook_service.clone(),
rate_limit_service: self.rate_limit_service.clone(),
force_https,
shutdown_handle: shutdown_handle.clone(),
};
let ready_for_http = ready_flag_for_set.clone();
actix::spawn(async move {
@ -187,9 +311,15 @@ impl System {
}
}
// Wait for shutdown signal
tokio::signal::ctrl_c().await.ok();
Ok(())
// Wait for shutdown signal (ctrl-c OR API-triggered)
tokio::select! {
_ = tokio::signal::ctrl_c() => {
Ok(ShutdownMode::Shutdown)
}
mode = shutdown_rx => {
Ok(mode.unwrap_or(ShutdownMode::Shutdown))
}
}
}
pub async fn terminate(&self) -> Result<(), Error> {
@ -211,37 +341,69 @@ impl System {
"Exploitation" => "threat_detected".to_string(),
"Reconnaissance" => "port_scan".to_string(),
other => {
log!(SystemLog::UnknownMlAttackType(other.to_string()));
log!(DetectionLog::UnknownMlAttackType(other.to_string()));
"threat_detected".to_string()
}
}
}
async fn bridge_ml_to_soar(
mut rx: tokio::sync::broadcast::Receiver<AlertMessage>,
/// Periodically check the drift detector and publish DriftDetectedEvent when drift is found.
async fn run_drift_monitor(
drift_detector: Arc<parking_lot::Mutex<DriftDetector>>,
comm: Arc<CommunicationManager>,
) {
log!(SystemLog::MlSoarBridgeStarted);
let mut interval = tokio::time::interval(std::time::Duration::from_secs(60));
loop {
interval.tick().await;
let report = drift_detector.lock().check_drift();
if let Some(report) = report {
log!(SystemLog::DriftDetected(
report.drifted_features.len(),
report.max_deviation
));
let event = DriftDetectedEvent {
drifted_features: report.drifted_features,
max_deviation: report.max_deviation,
};
if let Err(e) = comm.publish_event(event).await {
log!(SystemError::DriftEventPublishFailed(e));
}
}
}
}
/// Thin ML bridge: converts AlertMessage → DetectionEvent and sends to orchestrator.
/// Enrichment (GeoIP, hit count, repeat offender) is handled by the DetectionOrchestrator.
async fn bridge_ml_to_detection(
mut rx: tokio::sync::broadcast::Receiver<AlertMessage>,
tx: tokio::sync::mpsc::Sender<DetectionEvent>,
) {
log!(DetectionLog::MlBridgeStarted);
loop {
match rx.recv().await {
Ok(alert) => {
let event = ThreatDetectedEvent {
let event = DetectionEvent {
source: DetectionSource::ML,
attack_type: Self::normalize_attack_type(
&alert.attack_type.unwrap_or_else(|| "unknown".into()),
),
confidence: alert.confidence,
source_ip: alert.src_ip,
dest_ip: alert.dst_ip,
protocol: alert.protocol,
packet_count: alert.packet_count,
flow_duration_us: alert.flow_duration_us,
};
if let Err(e) = comm.publish_event(event).await {
log!(SystemError::MlSoarBridgeFailed(e));
if tx.send(event).await.is_err() {
break; // Orchestrator dropped
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
log!(SystemLog::MlSoarBridgeLagged(n));
log!(DetectionLog::MlBridgeLagged(n));
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
log!(SystemLog::MlAlertChannelClosed);
log!(DetectionLog::MlAlertChannelClosed);
break;
}
}

View File

@ -1,9 +1,7 @@
use crate::adapter::persistence::Database;
use crate::model::config::{
HttpConfig, InferenceConfig as InfConfig, MiscConfig, NetworkConfig, PipelineConfig,
};
use crate::model::error::system::SystemError;
use crate::model::config::{HttpConfig, InferenceConfig as InfConfig, MiscConfig, NetworkConfig, PipelineConfig};
use crate::model::error::Error;
use crate::model::error::system::SystemError;
pub struct AppConfig {
pub http: HttpConfig,
@ -55,13 +53,27 @@ impl AppConfig {
("inference_interval_secs", "5".into()),
("aggregator_window_secs", "30".into()),
("inference_batch_size", "200".into()),
("traffic_logging_mode", "true".into()),
("traffic_logging_mode", "false".into()),
("traffic_log_csv_path", "traffic_log.csv".into()),
// Misc
("geoip_db_name", "net-guardia/static/geo/dbip-city-lite.mmdb".into()),
// Pipeline
("pipeline_ingress", "access_control,rate_limit,service".into()),
("pipeline_egress", "".into()),
// SOAR
("soar_max_auto_block_cap", "100".into()),
("soar_max_ttl_secs", "86400".into()),
// ML
("ml_drift_window_secs", "3600".into()),
// Telegram
("telegram_max_messages_per_minute", "20".into()),
// Directories
("report_dir", "/var/lib/netguardia/reports".into()),
("log_dir", "logs".into()),
// DNS
("dns_max_domains_per_request", "1000".into()),
// HTTPS redirect
("force_https", "false".into()),
];
for (key, value) in defaults {
@ -106,7 +118,7 @@ impl AppConfig {
inference_interval_secs: 5,
aggregator_window_secs: 30,
inference_batch_size: 200,
traffic_logging_mode: true,
traffic_logging_mode: false,
traffic_log_csv_path: "traffic_log.csv".into(),
},
misc: MiscConfig {
@ -133,12 +145,14 @@ impl AppConfig {
// HTTP
if let Ok(Some(v)) = db.get_setting("http_port")
&& let Ok(port) = v.parse::<u16>() {
config.http.http_server_bind_port = port;
&& let Ok(port) = v.parse::<u16>()
{
config.http.http_server_bind_port = port;
}
if let Ok(Some(v)) = db.get_setting("jwt_expiry_hours")
&& let Ok(hours) = v.parse::<u64>() {
config.http.jwt_expiry_hours = hours;
&& let Ok(hours) = v.parse::<u64>()
{
config.http.jwt_expiry_hours = hours;
}
if let Ok(Some(v)) = db.get_setting("cors_allowed_origins") {
config.http.cors_allowed_origins = if v.is_empty() {
@ -150,39 +164,87 @@ impl AppConfig {
// XDP tuning
if let Ok(Some(v)) = db.get_setting("combined_queue_count")
&& let Ok(n) = v.parse::<u32>() { config.network.combined_queue_count = n; }
&& let Ok(n) = v.parse::<u32>()
{
config.network.combined_queue_count = n;
}
if let Ok(Some(v)) = db.get_setting("fill_queue_size")
&& let Ok(n) = v.parse::<u32>() { config.network.fill_queue_size = n; }
&& let Ok(n) = v.parse::<u32>()
{
config.network.fill_queue_size = n;
}
if let Ok(Some(v)) = db.get_setting("comp_queue_size")
&& let Ok(n) = v.parse::<u32>() { config.network.comp_queue_size = n; }
&& let Ok(n) = v.parse::<u32>()
{
config.network.comp_queue_size = n;
}
if let Ok(Some(v)) = db.get_setting("tx_queue_size")
&& let Ok(n) = v.parse::<u32>() { config.network.tx_queue_size = n; }
&& let Ok(n) = v.parse::<u32>()
{
config.network.tx_queue_size = n;
}
if let Ok(Some(v)) = db.get_setting("rx_queue_size")
&& let Ok(n) = v.parse::<u32>() { config.network.rx_queue_size = n; }
&& let Ok(n) = v.parse::<u32>()
{
config.network.rx_queue_size = n;
}
if let Ok(Some(v)) = db.get_setting("frame_size")
&& let Ok(n) = v.parse::<u32>() { config.network.frame_size = n; }
&& let Ok(n) = v.parse::<u32>()
{
config.network.frame_size = n;
}
if let Ok(Some(v)) = db.get_setting("frame_count")
&& let Ok(n) = v.parse::<u32>() { config.network.frame_count = n; }
&& let Ok(n) = v.parse::<u32>()
{
config.network.frame_count = n;
}
// Inference tuning
if let Ok(Some(v)) = db.get_setting("max_concurrent_flows")
&& let Ok(n) = v.parse::<usize>() { config.inference.max_concurrent_flows = n; }
&& let Ok(n) = v.parse::<usize>()
{
config.inference.max_concurrent_flows = n;
}
if let Ok(Some(v)) = db.get_setting("min_packets_for_inference")
&& let Ok(n) = v.parse::<usize>() { config.inference.min_packets_for_inference = n; }
&& let Ok(n) = v.parse::<usize>()
{
config.inference.min_packets_for_inference = n;
}
if let Ok(Some(v)) = db.get_setting("inference_interval_secs")
&& let Ok(n) = v.parse::<u64>() { config.inference.inference_interval_secs = n; }
&& let Ok(n) = v.parse::<u64>()
{
config.inference.inference_interval_secs = n;
}
if let Ok(Some(v)) = db.get_setting("aggregator_window_secs")
&& let Ok(n) = v.parse::<u64>() { config.inference.aggregator_window_secs = n; }
&& let Ok(n) = v.parse::<u64>()
{
config.inference.aggregator_window_secs = n;
}
if let Ok(Some(v)) = db.get_setting("inference_batch_size")
&& let Ok(n) = v.parse::<usize>() { config.inference.inference_batch_size = n; }
&& let Ok(n) = v.parse::<usize>()
{
config.inference.inference_batch_size = n;
}
if let Ok(Some(v)) = db.get_setting("refresh_interval")
&& let Ok(n) = v.parse::<u64>() { config.network.refresh_interval = n; }
&& let Ok(n) = v.parse::<u64>()
{
config.network.refresh_interval = n;
}
if let Ok(Some(v)) = db.get_setting("channel_size")
&& let Ok(n) = v.parse::<usize>() { config.network.channel_size = n; }
&& let Ok(n) = v.parse::<usize>()
{
config.network.channel_size = n;
}
if let Ok(Some(v)) = db.get_setting("packet_buffer_size")
&& let Ok(n) = v.parse::<usize>() { config.network.packet_buffer_size = n; }
&& let Ok(n) = v.parse::<usize>()
{
config.network.packet_buffer_size = n;
}
if let Ok(Some(v)) = db.get_setting("buffer_pool_capacity")
&& let Ok(n) = v.parse::<usize>() { config.network.buffer_pool_capacity = n; }
&& let Ok(n) = v.parse::<usize>()
{
config.network.buffer_pool_capacity = n;
}
// Bool settings
if let Ok(Some(v)) = db.get_setting("traffic_logging_mode") {
@ -191,15 +253,30 @@ impl AppConfig {
// File path settings
if let Ok(Some(v)) = db.get_setting("deep_autoencoder_name")
&& !v.is_empty() { config.inference.deep_autoencoder_name = v; }
&& !v.is_empty()
{
config.inference.deep_autoencoder_name = v;
}
if let Ok(Some(v)) = db.get_setting("classifier_name")
&& !v.is_empty() { config.inference.classifier_name = v; }
&& !v.is_empty()
{
config.inference.classifier_name = v;
}
if let Ok(Some(v)) = db.get_setting("models_config_name")
&& !v.is_empty() { config.inference.models_config_name = v; }
&& !v.is_empty()
{
config.inference.models_config_name = v;
}
if let Ok(Some(v)) = db.get_setting("traffic_log_csv_path")
&& !v.is_empty() { config.inference.traffic_log_csv_path = v; }
&& !v.is_empty()
{
config.inference.traffic_log_csv_path = v;
}
if let Ok(Some(v)) = db.get_setting("geoip_db_name")
&& !v.is_empty() { config.misc.geoip_db_name = v; }
&& !v.is_empty()
{
config.misc.geoip_db_name = v;
}
// Pipeline (stored as comma-separated)
if let Ok(Some(v)) = db.get_setting("pipeline_ingress") {
@ -312,9 +389,18 @@ mod tests {
let db = test_db();
AppConfig::seed_defaults(&db).expect("seed should succeed");
assert_eq!(db.get_setting("http_port").unwrap(), Some("8080".to_string()));
assert_eq!(db.get_setting("traffic_logging_mode").unwrap(), Some("true".to_string()));
assert_eq!(db.get_setting("pipeline_ingress").unwrap(), Some("access_control,rate_limit,service".to_string()));
assert_eq!(db.get_setting("geoip_db_name").unwrap(), Some("net-guardia/static/geo/dbip-city-lite.mmdb".to_string()));
assert_eq!(
db.get_setting("traffic_logging_mode").unwrap(),
Some("false".to_string())
);
assert_eq!(
db.get_setting("pipeline_ingress").unwrap(),
Some("access_control,rate_limit,service".to_string())
);
assert_eq!(
db.get_setting("geoip_db_name").unwrap(),
Some("net-guardia/static/geo/dbip-city-lite.mmdb".to_string())
);
}
#[test]
@ -327,8 +413,17 @@ mod tests {
#[test]
fn db_overrides_traffic_logging_mode() {
// Default is false (ML inference enabled). Override to true enables CSV logging only.
let db = test_db();
db.set_setting("traffic_logging_mode", "true").unwrap();
let config = AppConfig::new(&db).unwrap();
assert!(config.inference.traffic_logging_mode);
}
#[test]
fn default_traffic_logging_mode_is_false() {
// ML inference should be enabled by default, not CSV logging
let db = test_db();
db.set_setting("traffic_logging_mode", "false").unwrap();
let config = AppConfig::new(&db).unwrap();
assert!(!config.inference.traffic_logging_mode);
}

View File

@ -5,20 +5,21 @@ use crossbeam::queue::SegQueue;
use macros::log;
use tokio::sync::oneshot;
use crate::core::ml::alert::MLAlert;
use crate::core::ml::config_loader::InferenceConfig;
use crate::core::ml::drift_detector::DriftDetector;
use crate::core::ml::engine::Engine;
use crate::core::ml::model_loader::MLModels;
use crate::core::ml::traffic_logger::TrafficLogger;
use crate::infrastructure::app_config::AppConfig;
use crate::infrastructure::health::SystemHealth;
use crate::core::ml::alert::MLAlert;
use crate::infrastructure::statistics::FlowStatistics;
use crate::core::ml::config_loader::InferenceConfig;
use crate::core::ml::engine::Engine;
use crate::model::ml_detection::EngineConfig;
use crate::core::ml::feature_extractor::FlowFeatures;
use crate::core::ml::model_loader::MLModels;
use crate::model::detection::flow_features::FlowFeatures;
use crate::model::error::Error;
use crate::model::error::misc::MiscError;
use crate::model::error::system::SystemError;
use crate::model::error::Error;
use crate::model::log::system::SystemLog;
use crate::core::ml::traffic_logger::TrafficLogger;
use crate::model::ml_detection::EngineConfig;
/// Application-level service orchestrator.
/// Holds all runtime services (health monitoring, ML inference, flow statistics)
@ -33,7 +34,11 @@ pub struct AppServices {
}
impl AppServices {
pub fn new(app_config: Arc<AppConfig>, inference_config: Arc<InferenceConfig>) -> Result<Self, Error> {
pub fn new(
app_config: Arc<AppConfig>,
inference_config: Arc<InferenceConfig>,
drift_detector: Arc<parking_lot::Mutex<DriftDetector>>,
) -> Result<Self, Error> {
let health = SystemHealth::new(app_config.clone())?;
let ml_models = Arc::new(MLModels::load_models(&app_config, &inference_config)?);
@ -63,6 +68,7 @@ impl AppServices {
ml_models.clone(),
inference_config.clone(),
ml_alert.clone(),
drift_detector,
engine_config,
traffic_logger,
app_config.network.combined_queue_count,

View File

@ -0,0 +1,103 @@
use std::sync::Arc;
use macros::log;
use crate::infrastructure::communication_manager::CommunicationManager;
use crate::interface::port::audit::AuditPort;
use crate::model::event::{AuditEvent, DriftDetectedEvent};
use crate::model::log::audit::AuditLog;
/// Subscribes to `AuditEvent` and persists each entry to the `audit_log` table.
/// Falls back to log-only when DB writes fail (never panics).
pub struct AuditLogger {
db: Arc<dyn AuditPort>,
}
impl AuditLogger {
pub fn new(db: Arc<dyn AuditPort>) -> Self {
Self { db }
}
/// Subscribe to AuditEvent and DriftDetectedEvent on the communication manager
/// and start background tasks that persist events to DB + structured logs.
pub fn start(self: Arc<Self>, comm: &CommunicationManager) {
// Subscribe to AuditEvent
if let Ok(mut rx) = comm.subscribe_event::<AuditEvent>() {
let this = self.clone();
tokio::spawn(async move {
loop {
match rx.recv().await {
Ok(event) => {
this.handle_audit_event(&event);
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
log!(AuditLog::AuditLagged { count: n });
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
log!(AuditLog::AuditChannelClosed);
break;
}
}
}
});
} else {
log!(AuditLog::AuditSubscribeFailed);
}
// Subscribe to DriftDetectedEvent — log as audit trail entry
if let Ok(mut rx) = comm.subscribe_event::<DriftDetectedEvent>() {
let this = self;
tokio::spawn(async move {
loop {
match rx.recv().await {
Ok(event) => {
this.handle_drift_event(&event);
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
log!(AuditLog::AuditLagged { count: n });
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
log!(AuditLog::AuditChannelClosed);
break;
}
}
}
});
} else {
log!(AuditLog::AuditSubscribeFailed);
}
}
fn handle_audit_event(&self, event: &AuditEvent) {
// Always emit a structured log line
log!(AuditLog::AuditEvent {
actor: event.actor.clone(),
action: event.action.clone()
});
// Attempt DB insert; on failure, log a warning but do not panic
if let Err(e) = self.db.insert_audit_log(&event.actor, &event.action, &event.detail) {
log!(AuditLog::AuditDbWriteFailed {
error: e.to_string(),
actor: event.actor.clone(),
action: event.action.clone()
});
}
}
fn handle_drift_event(&self, event: &DriftDetectedEvent) {
let detail = serde_json::json!({
"drifted_features": event.drifted_features,
"max_deviation": event.max_deviation,
})
.to_string();
log!(AuditLog::AuditDriftEvent {
count: event.drifted_features.len()
});
if let Err(e) = self.db.insert_audit_log("system", "ml_drift_detected", &detail) {
log!(AuditLog::AuditDriftDbWriteFailed { error: e.to_string() });
}
}
}

View File

@ -2,16 +2,14 @@ use crate::interface::communication::command::*;
use crate::interface::communication::event::Event;
use crate::interface::communication::event::EventBroadcaster;
use crate::interface::communication::query::*;
use crate::model::error::misc::MiscError;
use crate::model::config::constants::DEFAULT_EVENT_CHANNEL_CAPACITY;
use crate::model::error::Error;
use crate::model::error::misc::MiscError;
use dashmap::DashMap;
use std::any::{Any, TypeId};
use std::sync::Arc;
use tokio::sync::broadcast;
/// Default broadcast channel capacity for event types.
const DEFAULT_CHANNEL_CAPACITY: usize = 256;
/// Inline TypedEventBroadcaster (adapted from MirrorSphere's model).
pub struct TypedEventBroadcaster<E: Event> {
pub sender: broadcast::Sender<E>,
@ -44,28 +42,20 @@ impl CommunicationManager {
command_handlers: DashMap::new(),
query_handlers: DashMap::new(),
event_broadcasters: DashMap::new(),
channel_capacity: DEFAULT_CHANNEL_CAPACITY,
channel_capacity: DEFAULT_EVENT_CHANNEL_CAPACITY,
}
}
pub fn with_service<S: Send + Sync + 'static>(
self: Arc<Self>,
service: Arc<S>,
) -> ServiceRegistrar<S> {
pub fn with_service<S: Send + Sync + 'static>(self: Arc<Self>, service: Arc<S>) -> ServiceRegistrar<S> {
ServiceRegistrar::new(service, self)
}
pub fn register_command_handler<C: Command + 'static>(
&self,
handler: Arc<dyn CommandHandler<C> + Send + Sync>,
) {
pub fn register_command_handler<C: Command + 'static>(&self, handler: Arc<dyn CommandHandler<C> + Send + Sync>) {
let type_id = TypeId::of::<C>();
let boxed_handler: CommandHandlerFn = Box::new(move |command: Box<dyn Any + Send>| {
let handler = handler.clone();
Box::pin(async move {
let command = *command
.downcast::<C>()
.map_err(|_| MiscError::TypeMismatch)?;
let command = *command.downcast::<C>().map_err(|_| MiscError::TypeMismatch)?;
handler.handle_command(command).await
}) as CommandFuture
});
@ -82,10 +72,7 @@ impl CommunicationManager {
}
}
pub fn register_query_handler<Q: Query + 'static>(
&self,
handler: Arc<dyn QueryHandler<Q> + Send + Sync>,
) {
pub fn register_query_handler<Q: Query + 'static>(&self, handler: Arc<dyn QueryHandler<Q> + Send + Sync>) {
let type_id = TypeId::of::<Q>();
let boxed_handler: QueryHandlerFn = Box::new(move |query: Box<dyn Any + Send>| {
let handler = handler.clone();
@ -115,8 +102,7 @@ impl CommunicationManager {
let type_id = TypeId::of::<E>();
let (tx, _) = broadcast::channel::<E>(self.channel_capacity);
let broadcaster = TypedEventBroadcaster { sender: tx };
self.event_broadcasters
.insert(type_id, Box::new(broadcaster));
self.event_broadcasters.insert(type_id, Box::new(broadcaster));
}
pub fn subscribe_event<E: Event + 'static>(&self) -> Result<broadcast::Receiver<E>, Error> {
@ -171,8 +157,6 @@ impl<S: Send + Sync + 'static> ServiceRegistrar<S> {
self
}
pub fn build(self) -> Arc<CommunicationManager> {
self.comm
}
@ -181,10 +165,10 @@ impl<S: Send + Sync + 'static> ServiceRegistrar<S> {
#[cfg(test)]
mod tests {
use super::*;
use crate::interface::communication::message::Message;
use crate::interface::communication::command::Command;
use crate::interface::communication::query::Query;
use crate::interface::communication::event::Event;
use crate::interface::communication::message::Message;
use crate::interface::communication::query::Query;
use async_trait::async_trait;
// ── Test Command ─────────────────────────────────────────────────
@ -243,7 +227,9 @@ mod tests {
#[tokio::test]
async fn test_command_dispatch() {
let received = Arc::new(std::sync::Mutex::new(Vec::new()));
let handler = Arc::new(TestCommandHandler { received: received.clone() });
let handler = Arc::new(TestCommandHandler {
received: received.clone(),
});
let comm = Arc::new(CommunicationManager::new());
comm.register_command_handler::<TestCommand>(handler);
@ -307,7 +293,11 @@ mod tests {
let mut rx1 = comm.subscribe_event::<TestEvent>().unwrap();
let mut rx2 = comm.subscribe_event::<TestEvent>().unwrap();
comm.publish_event(TestEvent { message: "broadcast".into() }).await.unwrap();
comm.publish_event(TestEvent {
message: "broadcast".into(),
})
.await
.unwrap();
assert_eq!(rx1.recv().await.unwrap().message, "broadcast");
assert_eq!(rx2.recv().await.unwrap().message, "broadcast");
@ -316,18 +306,20 @@ mod tests {
#[tokio::test]
async fn test_service_registrar() {
let received = Arc::new(std::sync::Mutex::new(Vec::new()));
let handler = Arc::new(TestCommandHandler { received: received.clone() });
let handler = Arc::new(TestCommandHandler {
received: received.clone(),
});
let comm = Arc::new(CommunicationManager::new());
let _comm = comm.clone()
.with_service(handler)
.command::<TestCommand>()
.build();
let _comm = comm.clone().with_service(handler).command::<TestCommand>().build();
comm.send_command(TestCommand { value: "via_registrar".into() }).await.unwrap();
comm.send_command(TestCommand {
value: "via_registrar".into(),
})
.await
.unwrap();
let msgs = received.lock().unwrap();
assert_eq!(msgs[0], "via_registrar");
}
}

View File

@ -1,24 +1,43 @@
use async_trait::async_trait;
use std::sync::Arc;
use std::sync::atomic::{AtomicU8, Ordering};
use macros::log;
use crate::infrastructure::communication_manager::CommunicationManager;
use crate::interface::communication::command::CommandHandler;
use crate::interface::communication::command_types::ChangeEnforceModeCommand;
use crate::interface::communication::query::QueryHandler;
use crate::interface::communication::query_types::GetEnforceModeQuery;
use crate::interface::port::repository::RepositoryPort;
use crate::model::error::Error;
use crate::model::event::AuditEvent;
use crate::model::log::system::SystemLog;
/// Map enforce-mode string to u8: monitor=0, ml_only=1, enforce=2.
pub fn enforce_mode_to_u8(mode: &str) -> u8 {
match mode {
"enforce" => 2,
"ml_only" => 1,
_ => 0, // "monitor" or unknown → safest default
}
}
/// Handles enforce-mode commands and queries by delegating to the repository.
pub struct EnforceModeHandler {
db: Arc<dyn RepositoryPort>,
comm: Arc<CommunicationManager>,
/// Shared AtomicU8 cache: Monitor=0, MlOnly=1, Enforce=2.
enforce_cache: Arc<AtomicU8>,
}
impl EnforceModeHandler {
pub fn new(db: Arc<dyn RepositoryPort>) -> Self {
Self { db }
pub fn new(db: Arc<dyn RepositoryPort>, comm: Arc<CommunicationManager>, enforce_cache: Arc<AtomicU8>) -> Self {
Self {
db,
comm,
enforce_cache,
}
}
}
@ -26,7 +45,20 @@ impl EnforceModeHandler {
impl CommandHandler<ChangeEnforceModeCommand> for EnforceModeHandler {
async fn handle_command(&self, command: ChangeEnforceModeCommand) -> Result<(), Error> {
self.db.set_setting("enforce_mode", &command.mode)?;
log!(SystemLog::EnforceModeChanged(command.mode));
self.enforce_cache
.store(enforce_mode_to_u8(&command.mode), Ordering::SeqCst);
log!(SystemLog::EnforceModeChanged(command.mode.clone()));
// Publish audit event for the mode change
let _ = self
.comm
.publish_event(AuditEvent {
actor: "admin".to_string(),
action: "enforce_mode_changed".to_string(),
detail: serde_json::json!({ "new_mode": command.mode }).to_string(),
})
.await;
Ok(())
}
}
@ -51,9 +83,12 @@ mod tests {
fn test_handler() -> (Arc<EnforceModeHandler>, Arc<CommunicationManager>) {
let db = Arc::new(Database::new(":memory:").unwrap()) as Arc<dyn RepositoryPort>;
let handler = Arc::new(EnforceModeHandler::new(db));
let cache = Arc::new(AtomicU8::new(0));
let comm = Arc::new(CommunicationManager::new());
let _ = comm.clone()
comm.register_event_type::<crate::model::event::AuditEvent>();
let handler = Arc::new(EnforceModeHandler::new(db, comm.clone(), cache));
let _ = comm
.clone()
.with_service(handler.clone())
.command::<ChangeEnforceModeCommand>()
.query::<GetEnforceModeQuery>()
@ -71,7 +106,9 @@ mod tests {
#[tokio::test]
async fn test_change_to_enforce() {
let (_, comm) = test_handler();
comm.send_command(ChangeEnforceModeCommand { mode: "enforce".into() }).await.unwrap();
comm.send_command(ChangeEnforceModeCommand { mode: "enforce".into() })
.await
.unwrap();
let mode = comm.send_query(GetEnforceModeQuery).await.unwrap();
assert_eq!(mode, "enforce");
}
@ -79,9 +116,35 @@ mod tests {
#[tokio::test]
async fn test_change_back_to_monitor() {
let (_, comm) = test_handler();
comm.send_command(ChangeEnforceModeCommand { mode: "enforce".into() }).await.unwrap();
comm.send_command(ChangeEnforceModeCommand { mode: "monitor".into() }).await.unwrap();
comm.send_command(ChangeEnforceModeCommand { mode: "enforce".into() })
.await
.unwrap();
comm.send_command(ChangeEnforceModeCommand { mode: "monitor".into() })
.await
.unwrap();
let mode = comm.send_query(GetEnforceModeQuery).await.unwrap();
assert_eq!(mode, "monitor");
}
#[tokio::test]
async fn test_change_to_ml_only() {
let (_, comm) = test_handler();
comm.send_command(ChangeEnforceModeCommand { mode: "ml_only".into() })
.await
.unwrap();
let mode = comm.send_query(GetEnforceModeQuery).await.unwrap();
assert_eq!(mode, "ml_only");
}
#[tokio::test]
async fn test_cycle_all_modes() {
let (_, comm) = test_handler();
for mode_str in ["enforce", "ml_only", "monitor"] {
comm.send_command(ChangeEnforceModeCommand { mode: mode_str.into() })
.await
.unwrap();
let mode = comm.send_query(GetEnforceModeQuery).await.unwrap();
assert_eq!(mode, mode_str);
}
}
}

Some files were not shown because too many files have changed in this diff Show More