mirror of
https://github.com/DaLaw2/NetGuardia.git
synced 2026-08-24 14:10:28 +09:00
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:
parent
5ce32b93d7
commit
4216906dd3
9
.gitignore
vendored
9
.gitignore
vendored
@ -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
4
.gitmodules
vendored
@ -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
125
Cargo.lock
generated
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
340
cli/src/main.rs
340
cli/src/main.rs
@ -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),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 = []
|
||||
@ -23,6 +23,7 @@ RUN dnf install -y epel-release && \
|
||||
nodejs24-npm \
|
||||
m4 \
|
||||
make pkg-config \
|
||||
openssl-devel \
|
||||
&& dnf clean all
|
||||
|
||||
# Rust toolchain
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
134
deploy/scripts/install.sh
Executable 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"
|
||||
@ -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 "$@"
|
||||
@ -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
1
net-guardia-trainer
Submodule
@ -0,0 +1 @@
|
||||
Subproject commit 1f5cbb8b9ba69a5bd16cc15055c230715d6bb9ae
|
||||
@ -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 }
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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()})),
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()})),
|
||||
29
net-guardia/src/adapter/http/audit.rs
Normal file
29
net-guardia/src/adapter/http/audit.rs
Normal 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!([])),
|
||||
}
|
||||
}
|
||||
@ -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(®.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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
use actix_web::{web, HttpResponse, Responder, Scope};
|
||||
use actix_web::{HttpResponse, Responder, Scope, web};
|
||||
|
||||
use crate::infrastructure::health::SystemHealth;
|
||||
|
||||
|
||||
157
net-guardia/src/adapter/http/logs.rs
Normal file
157
net-guardia/src/adapter/http/logs.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
@ -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!({
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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()})),
|
||||
|
||||
@ -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()})),
|
||||
|
||||
@ -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}")
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)?;
|
||||
}
|
||||
|
||||
|
||||
@ -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()})),
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
@ -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
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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! {
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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};
|
||||
|
||||
147
net-guardia/src/core/auth/https_redirect.rs
Normal file
147
net-guardia/src/core/auth/https_redirect.rs
Normal 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())
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
|
||||
@ -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());
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
pub mod extractor;
|
||||
pub mod https_redirect;
|
||||
pub mod jwt;
|
||||
pub mod middleware;
|
||||
pub mod password;
|
||||
|
||||
@ -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)]
|
||||
|
||||
@ -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())
|
||||
})
|
||||
}
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
190
net-guardia/src/core/correlation/botnet.rs
Normal file
190
net-guardia/src/core/correlation/botnet.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
76
net-guardia/src/core/correlation/engine.rs
Normal file
76
net-guardia/src/core/correlation/engine.rs
Normal 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
229
net-guardia/src/core/correlation/lateral.rs
Normal file
229
net-guardia/src/core/correlation/lateral.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
4
net-guardia/src/core/correlation/mod.rs
Normal file
4
net-guardia/src/core/correlation/mod.rs
Normal file
@ -0,0 +1,4 @@
|
||||
pub mod botnet;
|
||||
pub mod engine;
|
||||
pub mod lateral;
|
||||
pub mod scan;
|
||||
173
net-guardia/src/core/correlation/scan.rs
Normal file
173
net-guardia/src/core/correlation/scan.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
277
net-guardia/src/core/detection/beaconing.rs
Normal file
277
net-guardia/src/core/detection/beaconing.rs
Normal 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(×tamps);
|
||||
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(×tamps);
|
||||
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(×tamps);
|
||||
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);
|
||||
}
|
||||
}
|
||||
2
net-guardia/src/core/detection/mod.rs
Normal file
2
net-guardia/src/core/detection/mod.rs
Normal file
@ -0,0 +1,2 @@
|
||||
pub mod beaconing;
|
||||
pub mod orchestrator;
|
||||
205
net-guardia/src/core/detection/orchestrator.rs
Normal file
205
net-guardia/src/core/detection/orchestrator.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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(())
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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>,
|
||||
|
||||
@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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"));
|
||||
}
|
||||
|
||||
156
net-guardia/src/core/ml/drift_detector.rs
Normal file
156
net-guardia/src/core/ml/drift_detector.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>()?
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(¶ms_str)
|
||||
.unwrap_or(serde_json::Value::Null),
|
||||
params: serde_json::from_str(¶ms_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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,2 +1,2 @@
|
||||
pub mod engine;
|
||||
pub mod data;
|
||||
pub mod engine;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
87
net-guardia/src/core/soar/frequency.rs
Normal file
87
net-guardia/src/core/soar/frequency.rs
Normal 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
|
||||
}
|
||||
}
|
||||
@ -1,2 +1,3 @@
|
||||
pub mod engine;
|
||||
pub mod frequency;
|
||||
pub mod scheduler;
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
103
net-guardia/src/infrastructure/audit_logger.rs
Normal file
103
net-guardia/src/infrastructure/audit_logger.rs
Normal 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() });
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user