Refactor backend architecture and code organization (#20)

This commit is contained in:
DaLaw2 2026-05-06 00:52:50 +08:00 committed by GitHub
parent 01773cb15a
commit 20fbf401b9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
335 changed files with 16072 additions and 12508 deletions

View File

@ -37,7 +37,7 @@ jobs:
cache-dependency-path: net-guardia-frontend/package-lock.json
- name: Install frontend dependencies
run: npm install
run: npm ci
working-directory: net-guardia-frontend
- name: Install Rust stable toolchain
@ -72,14 +72,25 @@ jobs:
ls -la "$HOME/.cargo/bin/bpf-linker"
timeout-minutes: 45
- name: cargo check
run: cargo check --package net-guardia
- name: cargo check default workspace members
run: cargo check
- name: cargo test
run: cargo test --package net-guardia
- name: cargo test default workspace members
run: cargo test
- name: cargo clippy
run: cargo clippy --package net-guardia -- -D warnings
- name: cargo clippy default workspace members
run: cargo clippy -- -D warnings
- name: Frontend build
run: npm run build
working-directory: net-guardia-frontend
- name: Frontend tests
run: npm test
working-directory: net-guardia-frontend
- name: Trainer Python compile check
run: python3 -m compileall -q net-guardia-trainer/src
integration-test:
name: Integration Test (placeholder)

17
.gitignore vendored
View File

@ -30,20 +30,13 @@ net-guardia/static/web
*.profraw
*.profdata
# License keys
license-generator/target/
*.hex
license.key
license_priv.key
license_pub.key
.gstack/
interfaces.txt
traffic_log.csv
# Project docs (local only)
# CLAUDE.md — tracked on dev branches; MUST be untracked before PR to master
# (see CLAUDE.md "Branch discipline" section)
# CLAUDE.md
CLAUDE.md
AGENT.md
DESIGN.md
TODOS.md
VERSION
@ -51,11 +44,7 @@ CHANGELOG.md
# Benchmark data/results (local only)
benchmark/
# Generated docs
# docs/ — tracked on dev branches; MUST be untracked before PR to master
# (see CLAUDE.md "Branch discipline" section)
# docs/
docs/
# SQLite database files
*.db

163
Cargo.lock generated
View File

@ -337,7 +337,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures 0.2.17",
"cpufeatures",
]
[[package]]
@ -490,7 +490,7 @@ checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
dependencies = [
"base64ct",
"blake2",
"cpufeatures 0.2.17",
"cpufeatures",
"password-hash",
]
@ -500,6 +500,18 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9"
[[package]]
name = "async-sqlite"
version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1025c093eea82d9b8a37bbf3065c83814654c5b57f156bd48d49c2a38f99ac18"
dependencies = [
"crossbeam-channel",
"futures-channel",
"futures-util",
"rusqlite",
]
[[package]]
name = "async-trait"
version = "0.1.89"
@ -841,17 +853,6 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chacha20"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"rand_core 0.10.0",
]
[[package]]
name = "chrono"
version = "0.4.44"
@ -990,15 +991,6 @@ dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.0"
@ -1249,7 +1241,7 @@ checksum = "15401da73a9ed8c80e3b2d4dc05fe10e7b72d7243b9f614e516a44fa99986e88"
[[package]]
name = "egress-ebpf"
version = "0.1.0"
version = "1.0.0"
dependencies = [
"aya-ebpf",
"aya-log-ebpf",
@ -1302,7 +1294,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@ -1492,7 +1484,6 @@ dependencies = [
"cfg-if",
"libc",
"r-efi 6.0.0",
"rand_core 0.10.0",
"wasip2",
"wasip3",
]
@ -1583,11 +1574,11 @@ checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
[[package]]
name = "hashlink"
version = "0.10.0"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230"
dependencies = [
"hashbrown 0.15.5",
"hashbrown 0.16.1",
]
[[package]]
@ -1741,7 +1732,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
"socket2 0.5.10",
"socket2 0.6.3",
"tokio",
"tower-service",
"tracing",
@ -1906,7 +1897,7 @@ dependencies = [
[[package]]
name = "ingress-ebpf"
version = "0.1.0"
version = "1.0.0"
dependencies = [
"aya-ebpf",
"aya-log-ebpf",
@ -2183,9 +2174,9 @@ dependencies = [
[[package]]
name = "libsqlite3-sys"
version = "0.32.0"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fbb8270bb4060bd76c6e96f20c52d80620f1d82a3470885694e41e0f81ef6fe7"
checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1"
dependencies = [
"cc",
"pkg-config",
@ -2318,7 +2309,7 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "macros"
version = "0.1.0"
version = "1.0.0"
dependencies = [
"proc-macro2",
"quote",
@ -2365,7 +2356,7 @@ dependencies = [
[[package]]
name = "mcp-server"
version = "0.1.0"
version = "1.0.0"
dependencies = [
"clap",
"reqwest",
@ -2478,7 +2469,7 @@ dependencies = [
[[package]]
name = "net-guardia"
version = "0.1.0"
version = "1.0.0"
dependencies = [
"actix",
"actix-cors",
@ -2489,12 +2480,14 @@ dependencies = [
"aes-gcm",
"arc-swap",
"argon2",
"async-sqlite",
"async-trait",
"aya",
"aya-log",
"base64",
"cargo_metadata",
"chrono",
"clap",
"common",
"crossbeam",
"dashmap",
@ -2515,8 +2508,6 @@ dependencies = [
"network-types",
"notify",
"parking_lot",
"r2d2",
"r2d2_sqlite",
"rand 0.9.2",
"reqwest",
"rusqlite",
@ -2552,7 +2543,7 @@ dependencies = [
[[package]]
name = "ng-cli"
version = "0.1.0"
version = "1.0.0"
dependencies = [
"clap",
"libc",
@ -2892,7 +2883,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"cpufeatures",
"opaque-debug",
"universal-hash",
]
@ -3012,7 +3003,7 @@ dependencies = [
"quinn-udp",
"rustc-hash",
"rustls",
"socket2 0.5.10",
"socket2 0.6.3",
"thiserror 2.0.18",
"tokio",
"tracing",
@ -3049,7 +3040,7 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2 0.5.10",
"socket2 0.6.3",
"tracing",
"windows-sys 0.52.0",
]
@ -3081,28 +3072,6 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "r2d2"
version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51de85fb3fb6524929c8a2eb85e6b6d363de4e8c48f9e2c2eac4944abc181c93"
dependencies = [
"log",
"parking_lot",
"scheduled-thread-pool",
]
[[package]]
name = "r2d2_sqlite"
version = "0.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "180da684f0a188977d3968f139eb44260192ef8d9a5b7b7cbd01d881e0353179"
dependencies = [
"r2d2",
"rusqlite",
"uuid",
]
[[package]]
name = "rand"
version = "0.8.5"
@ -3124,17 +3093,6 @@ dependencies = [
"rand_core 0.9.5",
]
[[package]]
name = "rand"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8"
dependencies = [
"chacha20",
"getrandom 0.4.2",
"rand_core 0.10.0",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
@ -3173,12 +3131,6 @@ dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rand_core"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba"
[[package]]
name = "rand_distr"
version = "0.4.3"
@ -3301,10 +3253,20 @@ dependencies = [
]
[[package]]
name = "rusqlite"
version = "0.34.0"
name = "rsqlite-vfs"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37e34486da88d8e051c7c0e23c3f15fd806ea8546260aa2fec247e97242ec143"
checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d"
dependencies = [
"hashbrown 0.16.1",
"thiserror 2.0.18",
]
[[package]]
name = "rusqlite"
version = "0.39.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e"
dependencies = [
"bitflags 2.11.0",
"fallible-iterator",
@ -3312,6 +3274,7 @@ dependencies = [
"hashlink",
"libsqlite3-sys",
"smallvec",
"sqlite-wasm-rs",
]
[[package]]
@ -3387,7 +3350,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@ -3466,15 +3429,6 @@ dependencies = [
"regex",
]
[[package]]
name = "scheduled-thread-pool"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3cbc66816425a074528352f5789333ecff06ca41b36b0b0efdfbb29edc391a19"
dependencies = [
"parking_lot",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
@ -3593,7 +3547,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"cpufeatures",
"digest",
]
@ -3604,7 +3558,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"cpufeatures",
"digest",
]
@ -3683,6 +3637,18 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "sqlite-wasm-rs"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b2c760607300407ddeaee518acf28c795661b7108c75421303dbefb237d3a36"
dependencies = [
"cc",
"js-sys",
"rsqlite-vfs",
"wasm-bindgen",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
@ -3804,10 +3770,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@ -4418,7 +4384,6 @@ checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37"
dependencies = [
"getrandom 0.4.2",
"js-sys",
"rand 0.10.0",
"wasm-bindgen",
]
@ -4638,7 +4603,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]

View File

@ -4,6 +4,10 @@ members = ["net-guardia", "common", "macros", "ingress-ebpf", "egress-ebpf", "mc
default-members = ["net-guardia", "common", "mcp-server", "cli"]
[workspace.dependencies]
# Local crates
common = { path = "common" }
macros = { path = "macros" }
# eBPF - kernel side (pinned: aya-ebpf 0.1.2 was yanked, see aya-rs/aya#1400)
aya-ebpf = { version = "=0.1.1", default-features = false }
aya-log-ebpf = { version = "=0.1.0", default-features = false }
@ -30,6 +34,9 @@ actix = "0.13.5"
actix-web = "4.13.0"
actix-cors = "0.7.1"
actix-ws = "0.4.0"
actix-multipart = "0.7"
actix-files = "0.6"
tokio-tungstenite = "0.28.0"
# Logging / tracing
tracing = "0.1.44"
@ -51,17 +58,37 @@ sysinfo = "0.38.4"
maxminddb = "0.27.3"
ipnetwork = "0.21.1"
lru = "0.16.3"
rusqlite = { version = "0.34", features = ["bundled"] }
rusqlite = { version = "0.39", features = ["bundled-sqlcipher"] }
async-sqlite = { version = "0.5.7", default-features = false, features = ["bundled-sqlcipher"] }
jsonwebtoken = "9"
argon2 = "0.5"
rand = "0.9"
ed25519-dalek = { version = "2", features = ["std", "rand_core"] }
base64 = "0.22"
clap = { version = "4", features = ["derive"] }
uuid = { version = "1", features = ["v4"] }
rust-embed = "8.11.0"
mime_guess = "2.0.5"
url = "2.5.8"
toml = "1.0.7"
lettre = { version = "0.11", default-features = false, features = ["builder", "hostname", "smtp-transport", "tokio1-rustls-tls"] }
chrono = { version = "0.4", default-features = false, features = ["clock", "std"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
async-trait = "0.1"
dashmap = "6"
arc-swap = "1"
moka = { version = "0.12", features = ["sync"] }
notify = "7"
sha2 = "0.10"
hmac = "0.12"
aes-gcm = "0.10"
hkdf = "0.12"
sd-notify = "0.4"
# Build dependencies
cargo_metadata = { version = "0.23.1", default-features = false }
which = "8.0.2"
dotenvy = "0.15.7"
# Proc macro
proc-macro2 = "1.0.106"

View File

@ -1,6 +1,6 @@
# NetGuardia
Inline network security platform built on eBPF/XDP. Runs four independent detectors (per-packet ML, temporal beaconing, graph correlation, Suricata) over the same data plane, fuses their verdicts, drives SOAR playbooks, and writes every decision into a WORM audit chain.
Inline network security platform built on eBPF/XDP. Combines ONNX-based ML, temporal beaconing, correlation heuristics, and Suricata `eve.json` alerts in one fusion path, drives SOAR playbooks, and writes decisions into a WORM audit chain.
## Stack
@ -8,7 +8,7 @@ Inline network security platform built on eBPF/XDP. Runs four independent detect
- **Detection** — Rust + tract-onnx for ML, custom temporal / graph engines, Suricata `eve.json` ingest
- **Control plane** — actix-web REST + WebSocket, SQLite + SQLCipher, argon2 / JWT / CSRF, per-playbook SOAR
- **Frontend** — Vue 3 + Pinia + Vue-i18n (en / zh-TW / zh-CN / ja)
- **Architecture** — hexagonal: `adapter/` · `core/` · `infrastructure/` · `interface/` · `model/`
- **Architecture** — hexagonal-ish Rust workspace: `domain/` · `interface/` · `core/` · `adapter/` · `infrastructure/`
## Screens

View File

@ -1,10 +1,10 @@
[package]
name = "ng-cli"
version = "0.1.0"
version = "1.0.0"
edition = "2024"
[dependencies]
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
reqwest = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }

View File

@ -1,3 +1,7 @@
use std::fs;
use std::io::Write;
#[cfg(unix)]
use std::os::unix::io::AsRawFd;
use std::path::PathBuf;
use std::time::Duration;
@ -5,6 +9,8 @@ use clap::{Parser, Subcommand};
use reqwest::Client;
use serde_json::Value;
const CSRF_HEADER: &str = "X-CSRF-Token";
/// NetGuardia CLI management tool.
#[derive(Parser)]
#[command(name = "ng", about = "NetGuardia CLI", version)]
@ -24,9 +30,7 @@ enum Commands {
/// ML engine status
Ml,
/// Add IP to source blacklist
Block {
ip: String,
},
Block { ip: String },
/// Remove IP from source blacklist
Unblock { ip: String },
/// List ACL rules (source blacklist by default)
@ -88,18 +92,22 @@ impl ApiClient {
let token_path = dirs_next().join("token");
Self { client, base_url, token_path }
Self {
client,
base_url,
token_path,
}
}
fn load_token(&self) -> Option<String> {
std::fs::read_to_string(&self.token_path).ok()
fs::read_to_string(&self.token_path).ok()
}
fn save_token(&self, token: &str) {
fn save_token(&self, token: &str) -> Result<(), String> {
if let Some(parent) = self.token_path.parent() {
let _ = std::fs::create_dir_all(parent);
fs::create_dir_all(parent).map_err(|e| format!("Failed to create token directory: {}", e))?;
}
let _ = std::fs::write(&self.token_path, token);
fs::write(&self.token_path, token).map_err(|e| format!("Failed to save token: {}", e))
}
async fn get(&self, path: &str) -> Result<Value, String> {
@ -114,15 +122,25 @@ impl ApiClient {
return Err("Session expired. Run `ng login` to re-authenticate.".into());
}
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)]))
serde_json::from_str(&text).map_err(|_| {
format!(
"Unexpected response (HTTP {}): {}",
status,
&text[..text.len().min(200)]
)
})
}
async fn request(&self, method: reqwest::Method, path: &str, body: Option<Value>) -> Result<Value, String> {
let url = format!("{}{}", self.base_url, path);
let include_csrf = should_send_csrf(&method);
let mut req = self.client.request(method, &url);
if let Some(token) = self.load_token() {
req = req.header("Authorization", format!("Bearer {}", token.trim()));
}
if include_csrf {
req = req.header(CSRF_HEADER, "ng-cli");
}
if let Some(b) = body {
req = req.json(&b);
}
@ -138,17 +156,35 @@ impl ApiClient {
}
return Err(format!("Empty response (HTTP {})", status));
}
serde_json::from_str(&text).map_err(|_| format!("Unexpected response (HTTP {}): {}", status, &text[..text.len().min(200)]))
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> {
let url = format!("{}/api/auth/login", self.base_url);
let body = serde_json::json!({"username": username, "password": password});
let resp = self.client.post(&url).json(&body).send().await
let resp = self
.client
.post(&url)
.json(&body)
.send()
.await
.map_err(|e| format!("Connection error: {}", e))?;
let data: Value = resp.json().await.map_err(|e| format!("Parse error: {}", e))?;
data.get("token").and_then(|t| t.as_str()).map(|s| s.to_string())
.ok_or_else(|| data.get("error").and_then(|e| e.as_str()).unwrap_or("Login failed").to_string())
data.get("token")
.and_then(|t| t.as_str())
.map(|s| s.to_string())
.ok_or_else(|| {
data.get("error")
.and_then(|e| e.as_str())
.unwrap_or("Login failed")
.to_string()
})
}
}
@ -161,11 +197,17 @@ fn print_json(data: &Value) {
println!("{}", serde_json::to_string_pretty(data).unwrap_or_default());
}
fn should_send_csrf(method: &reqwest::Method) -> bool {
!matches!(
*method,
reqwest::Method::GET | reqwest::Method::HEAD | reqwest::Method::OPTIONS
)
}
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) };
@ -194,25 +236,39 @@ async fn main() {
let api = ApiClient::new(cli.url);
let result = match cli.command {
Commands::Status => {
api.get("/api/health/status").await.map(|d| print_json(&d))
}
Commands::Ml => {
api.get("/api/ml/status").await.map(|d| print_json(&d))
}
Commands::Status => api.get("/api/health/status").await.map(|d| print_json(&d)),
Commands::Ml => api.get("/api/ml/status").await.map(|d| print_json(&d)),
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))
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 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))
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 { direction, list_type } => {
// Try both IPv4 and IPv6
@ -234,97 +290,89 @@ async fn main() {
// 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});
api.request(reqwest::Method::PUT, "/api/system/enforce-mode", Some(body))
.await.map(|d| print_json(&d))
}
None => {
api.get("/api/system/enforce-mode").await.map(|d| print_json(&d))
}
Commands::Mode { mode } => match mode {
Some(m) => {
let body = serde_json::json!({"mode": m});
api.request(reqwest::Method::PUT, "/api/system/enforce-mode", Some(body))
.await
.map(|d| print_json(&d))
}
}
None => api.get("/api/system/enforce-mode").await.map(|d| print_json(&d)),
},
Commands::Login => {
print!("Username: ");
std::io::Write::flush(&mut std::io::stdout()).unwrap();
let mut stdout = std::io::stdout();
stdout.flush().unwrap();
let mut username = String::new();
std::io::stdin().read_line(&mut username).unwrap();
let username = username.trim();
print!("Password: ");
std::io::Write::flush(&mut std::io::stdout()).unwrap();
stdout.flush().unwrap();
let password = read_password();
match api.login(username, &password).await {
Ok(token) => {
api.save_token(&token);
println!("Login successful. Token saved to ~/.ng/token");
Ok(())
}
Ok(token) => match api.save_token(&token) {
Ok(()) => {
println!("Login successful. Token saved to ~/.ng/token");
Ok(())
}
Err(e) => Err(e),
},
Err(e) => Err(e),
}
}
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 {
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 API key: {}", key);
println!("Name: {}, Level: {}", name, level);
println!("Set NETGUARDIA_API_KEY={} in your client config", key);
} else {
print_json(&data);
}
})
}
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"),
);
}
}
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 {
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 API key: {}", key);
println!("Name: {}, Level: {}", name, level);
println!("Set NETGUARDIA_API_KEY={} in your client config", key);
} else {
print_json(&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 {
print_json(&data);
}
})
}
}
}
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 {
print_json(&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 {
print_json(&data);
}
}),
},
};
if let Err(e) = result {
@ -332,3 +380,18 @@ async fn main() {
std::process::exit(1);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn csrf_header_is_only_needed_for_state_changing_methods() {
assert!(!should_send_csrf(&reqwest::Method::GET));
assert!(!should_send_csrf(&reqwest::Method::HEAD));
assert!(!should_send_csrf(&reqwest::Method::OPTIONS));
assert!(should_send_csrf(&reqwest::Method::POST));
assert!(should_send_csrf(&reqwest::Method::PUT));
assert!(should_send_csrf(&reqwest::Method::DELETE));
}
}

View File

@ -1,3 +1,5 @@
use core::mem::size_of;
use aya_ebpf::helpers::bpf_ktime_get_ns;
use network_types::eth::{EthHdr, EtherType};
use network_types::ip::{IpProto, Ipv4Hdr, Ipv6Hdr};
@ -7,6 +9,7 @@ use network_types::udp::UdpHdr;
use crate::define::offset::*;
use crate::model::parsed_packet::ParsedPacket;
#[allow(clippy::result_unit_err, clippy::not_unsafe_ptr_arg_deref)]
pub fn parse_packet(start: usize, end: usize, target: *mut ParsedPacket) -> Result<(), ()> {
unsafe {
if start + ETHER_HEADER_END > end {
@ -30,6 +33,8 @@ unsafe fn parse_ipv4_packet(start: usize, end: usize, target: *mut ParsedPacket)
unsafe {
let ipv4 = &*((start + IPV4_HEADER_START) as *const Ipv4Hdr);
let ipv4_header_len = parse_ipv4_header_len(start, end)?;
let l4_start = IPV4_HEADER_START + ipv4_header_len;
let packet_length = (end - start) as u32;
let t = &mut *target;
@ -41,12 +46,12 @@ unsafe fn parse_ipv4_packet(start: usize, end: usize, target: *mut ParsedPacket)
t.protocol = ipv4.proto;
let (src_port, dst_port, tcp_flags, l4_header_len) = match ipv4.proto {
IpProto::Tcp => parse_tcp(start, end, IPV4_TCP_HEADER_START, IPV4_TCP_HEADER_END)?,
IpProto::Udp => parse_udp(start, end, IPV4_UDP_HEADER_START, IPV4_UDP_HEADER_END)?,
IpProto::Tcp => parse_tcp(start, end, l4_start)?,
IpProto::Udp => parse_udp(start, end, l4_start)?,
_ => (0, 0, 0, 0),
};
t.payload_length = packet_length.saturating_sub((IPV4_HEADER_END + l4_header_len) as u32);
t.payload_length = packet_length.saturating_sub((l4_start + l4_header_len) as u32);
t.src_port = src_port;
t.dst_port = dst_port;
t.tcp_flags = tcp_flags;
@ -74,8 +79,8 @@ unsafe fn parse_ipv6_packet(start: usize, end: usize, target: *mut ParsedPacket)
t.protocol = ipv6.next_hdr;
let (src_port, dst_port, tcp_flags, l4_header_len) = match ipv6.next_hdr {
IpProto::Tcp => parse_tcp(start, end, IPV6_TCP_HEADER_START, IPV6_TCP_HEADER_END)?,
IpProto::Udp => parse_udp(start, end, IPV6_UDP_HEADER_START, IPV6_UDP_HEADER_END)?,
IpProto::Tcp => parse_tcp(start, end, IPV6_TCP_HEADER_START)?,
IpProto::Udp => parse_udp(start, end, IPV6_UDP_HEADER_START)?,
_ => (0, 0, 0, 0),
};
@ -89,8 +94,31 @@ unsafe fn parse_ipv6_packet(start: usize, end: usize, target: *mut ParsedPacket)
}
#[inline(always)]
unsafe fn parse_tcp(start: usize, end: usize, tcp_start: usize, tcp_end: usize) -> Result<(u16, u16, u8, usize), ()> {
if start + tcp_end > end {
#[allow(clippy::manual_range_contains)]
unsafe fn parse_ipv4_header_len(start: usize, end: usize) -> Result<usize, ()> {
if start + IPV4_HEADER_START + 1 > end {
return Err(());
}
let version_ihl = unsafe { *((start + IPV4_HEADER_START) as *const u8) };
let version = version_ihl >> 4;
let ihl = (version_ihl & 0x0f) as usize;
if version != 4 || ihl < 5 || ihl > 15 {
return Err(());
}
let header_len = ihl * 4;
if start + IPV4_HEADER_START + header_len > end {
return Err(());
}
Ok(header_len)
}
#[inline(always)]
#[allow(clippy::manual_range_contains)]
unsafe fn parse_tcp(start: usize, end: usize, tcp_start: usize) -> Result<(u16, u16, u8, usize), ()> {
if start + tcp_start + size_of::<TcpHdr>() > end {
return Err(());
}
@ -115,8 +143,8 @@ unsafe fn parse_tcp(start: usize, end: usize, tcp_start: usize, tcp_end: usize)
}
#[inline(always)]
unsafe fn parse_udp(start: usize, end: usize, udp_start: usize, udp_end: usize) -> Result<(u16, u16, u8, usize), ()> {
if start + udp_end > end {
unsafe fn parse_udp(start: usize, end: usize, udp_start: usize) -> Result<(u16, u16, u8, usize), ()> {
if start + udp_start + size_of::<UdpHdr>() > end {
return Err(());
}

View File

@ -1,8 +1,9 @@
#[cfg(feature = "user")]
use aya::Pod;
#[cfg(feature = "user")]
use std::vec::Vec;
#[cfg(feature = "user")]
use aya::Pod;
use crate::define::setting::MAX_RULES_PORT;
use crate::model::ip_address::Port;

View File

@ -17,7 +17,6 @@ RUN dnf install -y epel-release && \
git \
gh \
vim \
openssh-server \
ethtool \
nodejs24 \
nodejs24-npm \
@ -39,9 +38,5 @@ RUN ln -s /usr/bin/node-24 /usr/local/bin/node && \
ln -s /usr/bin/npm-24 /usr/local/bin/npm && \
ln -s /usr/bin/npx-24 /usr/local/bin/npx
RUN echo 'root:@Server20040421@' | chpasswd && \
sed -i 's/^#PermitRootLogin.*/PermitRootLogin yes/' /etc/ssh/sshd_config && \
sed -i 's/^#PasswordAuthentication.*/PasswordAuthentication yes/' /etc/ssh/sshd_config
WORKDIR /root/NetGuardia
CMD sh -c "ssh-keygen -A && /usr/sbin/sshd && sleep infinity"
CMD ["sleep", "infinity"]

View File

@ -22,7 +22,6 @@ services:
mgmt-net:
ipv4_address: 10.10.3.10
ports:
- "2222:22"
- "8080:8080"
volumes:
- /home/dalaw2/NetGuardia:/root/NetGuardia:z

View File

@ -1,146 +1,465 @@
#!/bin/bash
set -e
#!/usr/bin/env bash
# Build the NetGuardia development containers and inline veth topology.
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
set -Eeuo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DEPLOY_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
COMPOSE_FILE="$DEPLOY_DIR/compose/podman-compose.yml"
ROOT_DIR="$(cd "$DEPLOY_DIR/.." && pwd)"
BASE_COMPOSE_FILE="$DEPLOY_DIR/compose/podman-compose.yml"
COMPOSE_FILE="/tmp/netguardia-compose-$$.yml"
COMPOSE_PROJECT="compose"
LOG_FILE="/tmp/netguardia-dev-$(date +%Y%m%d-%H%M%S).log"
if command -v podman-compose &>/dev/null; then
COMPOSE="podman-compose -f $COMPOSE_FILE"
RT="podman"
elif command -v docker &>/dev/null && docker compose version &>/dev/null 2>&1; then
COMPOSE="docker compose -f $COMPOSE_FILE"
RT="docker"
else
echo "ERROR: No container runtime found"
exit 1
fi
VERBOSE=0
CLEANUP_FIRST=1
RT=""
declare -a RT_CMD=()
declare -a COMPOSE_CMD=()
echo "=== Runtime: $RT ==="
echo "=== Kernel: $(uname -r) ==="
echo ""
echo "=== Building containers ==="
$COMPOSE build
echo "=== Starting containers ==="
$COMPOSE up -d
echo ""
echo "=== Containers running ==="
$RT ps --format "table {{.Names}}\t{{.Status}}" 2>/dev/null || $RT ps
get_pid() {
$RT inspect --format '{{.State.Pid}}' "$1"
info() {
printf '[INFO] %s\n' "$*"
}
mkdir -p /var/run/netns
warn() {
printf '[WARN] %s\n' "$*" >&2
}
EXT_PID=$(get_pid external)
INT_PID=$(get_pid internal)
RTR_PID=$(get_pid router)
NG_PID=$(get_pid netguardia)
ln -sf /proc/$EXT_PID/ns/net /var/run/netns/external
ln -sf /proc/$INT_PID/ns/net /var/run/netns/internal
ln -sf /proc/$RTR_PID/ns/net /var/run/netns/router
ln -sf /proc/$NG_PID/ns/net /var/run/netns/netguardia
fatal() {
printf '[ERROR] %s\n' "$*" >&2
exit 1
}
# ============================================================
# Segment 1: external <-> router (10.10.1.0/24)
# Direct connection, no inspection needed
# ============================================================
echo ""
echo "=== Segment 1: external <-> router (10.10.1.0/24) ==="
usage() {
cat <<EOF
Usage: sudo bash deploy/scripts/dev.sh [--verbose] [--no-cleanup]
ip link add ext-eth0 type veth peer name rtr-ext
ip link set ext-eth0 netns external
ip link set rtr-ext netns router
Options:
--verbose Print compose build/up output in addition to writing the log.
--no-cleanup Skip the default preflight cleanup of old containers/veth links.
-h, --help Show this help.
EOF
}
ip netns exec external ip link set lo up
ip netns exec external ip link set ext-eth0 up
ip netns exec external ip addr add 10.10.1.2/24 dev ext-eth0
for i in 3 4 5 6 7; do
ip netns exec external ip addr add 10.10.1.${i}/24 dev ext-eth0
done
ip netns exec external ip route add default via 10.10.1.1
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--verbose)
VERBOSE=1
shift
;;
--no-cleanup)
CLEANUP_FIRST=0
shift
;;
-h|--help)
usage
exit 0
;;
*)
fatal "Unknown argument: $1"
;;
esac
done
}
ip netns exec router ip link set lo up
ip netns exec router ip link set rtr-ext up
ip netns exec router ip addr add 10.10.1.1/24 dev rtr-ext
cleanup_netns_links() {
rm -f \
/var/run/netns/external \
/var/run/netns/internal \
/var/run/netns/router \
/var/run/netns/netguardia
}
echo " external: ext-eth0 10.10.1.{2-7}/24, gw 10.10.1.1"
echo " router: rtr-ext 10.10.1.1/24"
cleanup_temp_files() {
cleanup_netns_links
rm -f "$COMPOSE_FILE"
}
# ============================================================
# Segment 2: router <-> netguardia <-> internal (10.10.2.0/24)
# NetGuardia inline: XDP on ng-ext (router side) and ng-int (internal side)
# No bridges, no inline veth pair — direct XSK forwarding
# ============================================================
echo ""
echo "=== Segment 2: router <-> [NetGuardia] <-> internal (10.10.2.0/24) ==="
trap cleanup_temp_files EXIT
# router <-> netguardia: ng-ext is the netguardia side
ip link add rtr-int type veth peer name ng-ext
ip link set rtr-int netns router
ip link set ng-ext netns netguardia
require_root() {
[[ "$(id -u)" -eq 0 ]] || fatal "dev.sh must run as root. Use: sudo bash deploy/scripts/dev.sh"
}
# netguardia <-> internal: ng-int is the netguardia side
ip link add int-eth0 type veth peer name ng-int
ip link set int-eth0 netns internal
ip link set ng-int netns netguardia
require_linux_host() {
[[ "$(uname -s)" == "Linux" ]] || fatal "dev.sh supports Linux hosts only."
if grep -qiE 'microsoft|wsl' /proc/version 2>/dev/null; then
fatal "WSL2 is not supported for this XDP/AF_XDP development topology."
fi
}
# Router internal side
ip netns exec router ip link set rtr-int up
ip netns exec router ip addr add 10.10.2.1/24 dev rtr-int
ip netns exec router sh -c 'echo 1 > /proc/sys/net/ipv4/ip_forward'
package_manager() {
if command -v dnf >/dev/null 2>&1; then
printf 'dnf'
elif command -v apt-get >/dev/null 2>&1; then
printf 'apt-get'
elif command -v zypper >/dev/null 2>&1; then
printf 'zypper'
elif command -v pacman >/dev/null 2>&1; then
printf 'pacman'
fi
}
# Internal container
ip netns exec internal ip link set lo up
ip netns exec internal ip link set int-eth0 up
ip netns exec internal ip addr add 10.10.2.2/24 dev int-eth0
for i in 3 4 5 6; do
ip netns exec internal ip addr add 10.10.2.${i}/24 dev int-eth0
done
ip netns exec internal ip route add default via 10.10.2.1
package_for_command() {
local manager="$1"
local command_name="$2"
# NetGuardia interfaces (no IP, transparent)
ip netns exec netguardia ip link set ng-ext up
ip netns exec netguardia ip link set ng-int up
case "$manager:$command_name" in
dnf:ip) printf 'iproute' ;;
dnf:ping) printf 'iputils' ;;
dnf:ethtool) printf 'ethtool' ;;
dnf:curl) printf 'curl' ;;
dnf:ln|dnf:mkdir|dnf:rm|dnf:uname) printf 'coreutils' ;;
apt-get:ip) printf 'iproute2' ;;
apt-get:ping) printf 'iputils-ping' ;;
apt-get:ethtool) printf 'ethtool' ;;
apt-get:curl) printf 'curl' ;;
apt-get:ln|apt-get:mkdir|apt-get:rm|apt-get:uname) printf 'coreutils' ;;
zypper:ip) printf 'iproute2' ;;
zypper:ping) printf 'iputils' ;;
zypper:ethtool) printf 'ethtool' ;;
zypper:curl) printf 'curl' ;;
zypper:ln|zypper:mkdir|zypper:rm|zypper:uname) printf 'coreutils' ;;
pacman:ip) printf 'iproute2' ;;
pacman:ping) printf 'iputils' ;;
pacman:ethtool) printf 'ethtool' ;;
pacman:curl) printf 'curl' ;;
pacman:ln|pacman:mkdir|pacman:rm|pacman:uname) printf 'coreutils' ;;
esac
}
# Disable checksum offload on ALL veth endpoints.
# AF_XDP TX bypasses the kernel stack, so checksums are not computed.
# Without this, TCP packets forwarded through XSK have bad checksums and get dropped.
ip netns exec router ethtool -K rtr-int tx off rx off 2>/dev/null || true
ip netns exec router ethtool -K rtr-ext tx off rx off 2>/dev/null || true
ip netns exec internal ethtool -K int-eth0 tx off rx off 2>/dev/null || true
ip netns exec external ethtool -K ext-eth0 tx off rx off 2>/dev/null || true
ip netns exec netguardia ethtool -K ng-ext tx off rx off 2>/dev/null || true
ip netns exec netguardia ethtool -K ng-int tx off rx off 2>/dev/null || true
append_unique() {
local value="$1"
shift
local existing
echo " router: rtr-int (10.10.2.1) <-> ng-ext (XDP ingress)"
echo " netguardia: ng-ext <-> [XSK forwarding] <-> ng-int"
echo " internal: int-eth0 (10.10.2.{2-6}) <-> ng-int (XDP egress)"
echo " checksum offload disabled on all veth endpoints"
for existing in "$@"; do
[[ "$existing" == "$value" ]] && return 1
done
return 0
}
# ============================================================
# Verify
# ============================================================
echo ""
echo "=== Interfaces inside netguardia ==="
ip netns exec netguardia ip -br link show
install_packages() {
local manager="$1"
shift
echo ""
echo "=== Testing connectivity ==="
case "$manager" in
dnf)
dnf install -y "$@"
;;
apt-get)
DEBIAN_FRONTEND=noninteractive apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y "$@"
;;
zypper)
zypper --non-interactive install "$@"
;;
pacman)
pacman -Sy --noconfirm "$@"
;;
*)
return 1
;;
esac
}
echo -n " external -> router: "
ip netns exec external ping -c 1 -W 2 10.10.1.1 >/dev/null 2>&1 && echo "OK" || echo "FAIL"
manual_install_command() {
local manager="$1"
shift
# Without net-guardia, traffic between router and internal won't pass
# because ng-ext/ng-int are just veth endpoints with no forwarding
echo -n " router -> internal: "
ip netns exec router ping -c 1 -W 2 10.10.2.2 >/dev/null 2>&1 && echo "OK" || echo "FAIL (expected - needs net-guardia)"
case "$manager" in
dnf) printf 'dnf install -y %s\n' "$*" ;;
apt-get) printf 'apt-get update && apt-get install -y %s\n' "$*" ;;
zypper) printf 'zypper --non-interactive install %s\n' "$*" ;;
pacman) printf 'pacman -Sy --noconfirm %s\n' "$*" ;;
*) printf 'Install packages manually: %s\n' "$*" ;;
esac
}
cat > /tmp/netguardia_interfaces.txt << IEOF
check_host_tools() {
local required_commands=(ip ping ethtool curl ln mkdir rm uname)
local missing_commands=()
local packages=()
local command_name manager package answer
for command_name in "${required_commands[@]}"; do
if ! command -v "$command_name" >/dev/null 2>&1; then
missing_commands+=("$command_name")
fi
done
if ((${#missing_commands[@]} == 0)); then
info "Host tools OK"
return 0
fi
manager="$(package_manager || true)"
[[ -n "$manager" ]] || fatal "Missing host commands: ${missing_commands[*]}. No supported package manager found."
for command_name in "${missing_commands[@]}"; do
package="$(package_for_command "$manager" "$command_name")"
[[ -n "$package" ]] || fatal "No package mapping for missing command '$command_name' on $manager."
if append_unique "$package" "${packages[@]}"; then
packages+=("$package")
fi
done
warn "Missing host commands: ${missing_commands[*]}"
warn "Package manager: $manager"
warn "Packages to install: ${packages[*]}"
if [[ ! -t 0 ]]; then
manual_install_command "$manager" "${packages[@]}" >&2
fatal "Non-interactive shell; refusing to install packages without consent."
fi
read -r -p "Install missing packages? [y/N] " answer
case "$answer" in
y|Y|yes|YES)
install_packages "$manager" "${packages[@]}"
;;
*)
manual_install_command "$manager" "${packages[@]}" >&2
fatal "Required host packages were not installed."
;;
esac
}
runtime_install_hint() {
cat >&2 <<'EOF'
Install a supported container runtime first.
Examples:
dnf install -y podman podman-compose
apt-get install -y podman podman-compose
apt-get install -y docker.io docker-compose-plugin
EOF
}
check_docker_supported() {
local context security_options operating_system
context="$(docker context show 2>/dev/null || true)"
if [[ "$context" == "desktop-linux" ]]; then
fatal "Docker Desktop is not supported for this XDP/netns topology."
fi
operating_system="$(docker info --format '{{.OperatingSystem}}' 2>/dev/null || true)"
if [[ "$operating_system" == *"Docker Desktop"* ]]; then
fatal "Docker Desktop is not supported for this XDP/netns topology."
fi
security_options="$(docker info --format '{{json .SecurityOptions}}' 2>/dev/null || true)"
if grep -qi rootless <<<"$security_options"; then
fatal "Rootless Docker is not supported for this privileged XDP/netns topology."
fi
}
detect_runtime() {
if command -v podman-compose >/dev/null 2>&1 && command -v podman >/dev/null 2>&1; then
RT="podman"
RT_CMD=(podman)
COMPOSE_CMD=(podman-compose -p "$COMPOSE_PROJECT" -f "$COMPOSE_FILE")
elif command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then
check_docker_supported
RT="docker"
RT_CMD=(docker)
COMPOSE_CMD=(docker compose -p "$COMPOSE_PROJECT" -f "$COMPOSE_FILE")
else
runtime_install_hint
fatal "No supported runtime found. Need podman + podman-compose or docker + docker compose."
fi
info "Runtime: $RT"
}
generate_compose_file() {
local yaml_deploy
local yaml_root
[[ -f "$BASE_COMPOSE_FILE" ]] || fatal "Compose file not found: $BASE_COMPOSE_FILE"
yaml_deploy="${DEPLOY_DIR//\'/\'\'}"
yaml_root="${ROOT_DIR//\'/\'\'}"
: >"$COMPOSE_FILE"
while IFS= read -r line; do
case "$line" in
" context: ..")
printf " context: '%s'\n" "$yaml_deploy" >>"$COMPOSE_FILE"
;;
" - /home/dalaw2/NetGuardia:/root/NetGuardia:z")
printf " - '%s:/root/NetGuardia:z'\n" "$yaml_root" >>"$COMPOSE_FILE"
;;
*)
printf '%s\n' "$line" >>"$COMPOSE_FILE"
;;
esac
done <"$BASE_COMPOSE_FILE"
}
run_logged() {
local label="$1"
shift
info "$label"
if ((VERBOSE)); then
"$@" 2>&1 | tee -a "$LOG_FILE"
elif [[ -t 1 ]]; then
run_with_spinner "$label" "$@"
elif ! "$@" >>"$LOG_FILE" 2>&1; then
warn "$label failed. Last log lines:"
tail -n 80 "$LOG_FILE" >&2 || true
fatal "Full log: $LOG_FILE"
fi
}
run_with_spinner() {
local label="$1"
shift
local pid status
"$@" >>"$LOG_FILE" 2>&1 &
pid=$!
spinner "$pid" "$label"
set +e
wait "$pid"
status=$?
set -e
clear_spinner_line
if ((status != 0)); then
warn "$label failed. Last log lines:"
tail -n 80 "$LOG_FILE" >&2 || true
fatal "Full log: $LOG_FILE"
fi
}
spinner() {
local pid="$1"
local label="$2"
local frames='⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
local i=0
local frame
local started_at=$SECONDS
while kill -0 "$pid" 2>/dev/null; do
frame="${frames:i++%${#frames}:1}"
printf '\r%s %s... %02ds' "$frame" "$label" "$((SECONDS - started_at))"
sleep 0.12
done
}
clear_spinner_line() {
printf '\r\033[K'
}
runtime_rm_containers() {
"${RT_CMD[@]}" rm -f netguardia router external internal >/dev/null 2>&1 || true
}
delete_host_link() {
local link_name="$1"
ip link del "$link_name" >/dev/null 2>&1 || true
}
preflight_cleanup() {
((CLEANUP_FIRST)) || return 0
info "Cleaning old development topology"
runtime_rm_containers
cleanup_netns_links
delete_host_link ext-eth0
delete_host_link rtr-ext
delete_host_link rtr-int
delete_host_link ng-ext
delete_host_link int-eth0
delete_host_link ng-int
}
container_pid() {
"${RT_CMD[@]}" inspect --format '{{.State.Pid}}' "$1"
}
link_netns() {
local container="$1"
local pid
pid="$(container_pid "$container")"
[[ -n "$pid" && "$pid" != "0" ]] || fatal "Container '$container' is not running."
ln -sf "/proc/$pid/ns/net" "/var/run/netns/$container"
}
link_container_namespaces() {
mkdir -p /var/run/netns
link_netns external
link_netns internal
link_netns router
link_netns netguardia
}
netns() {
ip netns exec "$@"
}
disable_offload() {
local namespace="$1"
local interface="$2"
netns "$namespace" ethtool -K "$interface" tx off rx off >/dev/null 2>&1 || true
}
create_topology() {
info "Creating inline veth topology"
ip link add ext-eth0 type veth peer name rtr-ext
ip link set ext-eth0 netns external
ip link set rtr-ext netns router
netns external ip link set lo up
netns external ip link set ext-eth0 up
netns external ip addr add 10.10.1.2/24 dev ext-eth0
for i in 3 4 5 6 7; do
netns external ip addr add "10.10.1.$i/24" dev ext-eth0
done
netns external ip route replace default via 10.10.1.1
netns router ip link set lo up
netns router ip link set rtr-ext up
netns router ip addr add 10.10.1.1/24 dev rtr-ext
ip link add rtr-int type veth peer name ng-ext
ip link set rtr-int netns router
ip link set ng-ext netns netguardia
ip link add int-eth0 type veth peer name ng-int
ip link set int-eth0 netns internal
ip link set ng-int netns netguardia
netns router ip link set rtr-int up
netns router ip addr add 10.10.2.1/24 dev rtr-int
netns router sh -c 'echo 1 > /proc/sys/net/ipv4/ip_forward'
netns internal ip link set lo up
netns internal ip link set int-eth0 up
netns internal ip addr add 10.10.2.2/24 dev int-eth0
for i in 3 4 5 6; do
netns internal ip addr add "10.10.2.$i/24" dev int-eth0
done
netns internal ip route replace default via 10.10.2.1
netns netguardia ip link set ng-ext up
netns netguardia ip link set ng-int up
disable_offload router rtr-int
disable_offload router rtr-ext
disable_offload internal int-eth0
disable_offload external ext-eth0
disable_offload netguardia ng-ext
disable_offload netguardia ng-int
}
write_interface_mapping() {
cat >/tmp/netguardia_interfaces.txt <<'IEOF'
# NetGuardia interface mapping - realistic inline deployment
# Router handles L3 (10.10.1.0/24 <-> 10.10.2.0/24)
# NetGuardia inline on 10.10.2.0/24 (no IP, no bridge)
@ -149,30 +468,54 @@ cat > /tmp/netguardia_interfaces.txt << IEOF
# XSK forwards packets: ng-ext RX -> ng-int TX and ng-int RX -> ng-ext TX
# Management: eth0 (10.10.3.10)
IEOF
$RT cp /tmp/netguardia_interfaces.txt netguardia:/root/NetGuardia/interfaces.txt 2>/dev/null || true
"${RT_CMD[@]}" cp /tmp/netguardia_interfaces.txt netguardia:/root/NetGuardia/interfaces.txt >/dev/null 2>&1 || true
}
rm -f /var/run/netns/external /var/run/netns/internal /var/run/netns/router /var/run/netns/netguardia
connectivity_check() {
local external_router="FAIL"
echo ""
echo "=========================================="
echo " NetGuardia realistic inline deployment!"
echo ""
echo " external (10.10.1.{2-7})"
echo " |"
echo " [router] 10.10.1.1 <-> 10.10.2.1"
echo " | rtr-int"
echo " |"
echo " ng-ext (no IP) <- XDP ingress"
echo " |"
echo " [net-guardia XSK]"
echo " |"
echo " ng-int (no IP) <- XDP egress"
echo " |"
echo " | int-eth0"
echo " internal (10.10.2.{2-6})"
echo ""
echo " All 10.10.2.0/24 traffic requires net-guardia!"
echo " Mgmt: 10.10.3.10"
echo " SSH: ssh -p 2222 root@<host-ip>"
echo " Web: http://<host-ip>:8080"
echo "=========================================="
if netns external ping -c 1 -W 2 10.10.1.1 >/dev/null 2>&1; then
external_router="OK"
fi
info "Connectivity: external -> router: $external_router"
}
print_summary() {
cat <<EOF
NetGuardia development topology is ready.
Mgmt: http://<host-ip>:8080
external (10.10.1.{2-7}) -> router -> ng-ext
ng-ext <-> net-guardia XSK <-> ng-int
ng-int -> internal (10.10.2.{2-6})
EOF
}
main() {
parse_args "$@"
require_root
require_linux_host
check_host_tools
generate_compose_file
detect_runtime
preflight_cleanup
: >"$LOG_FILE"
info "Compose log: $LOG_FILE"
run_logged "Building containers" "${COMPOSE_CMD[@]}" build
run_logged "Starting containers" "${COMPOSE_CMD[@]}" up -d
info "Containers running"
"${RT_CMD[@]}" ps --format "table {{.Names}}\t{{.Status}}" 2>/dev/null || "${RT_CMD[@]}" ps
link_container_namespaces
create_topology
write_interface_mapping
connectivity_check
print_summary
}
main "$@"

View File

@ -1,10 +1,10 @@
[package]
name = "egress-ebpf"
version = "0.1.0"
version = "1.0.0"
edition = "2024"
[dependencies]
common = { path = "../common", features = ["kernel"] }
common = { workspace = true, features = ["kernel"] }
aya-ebpf = { workspace = true }
aya-log-ebpf = { workspace = true }
network-types = { workspace = true }

View File

@ -19,9 +19,7 @@ static EGRESS_XSKS_MAP: XskMap = XskMap::pinned(64, 0);
#[xdp]
pub fn net_guardia(ctx: XdpContext) -> u32 {
let queue_id = unsafe {
compute_symmetric_queue_id(&ctx).unwrap_or((*ctx.ctx).rx_queue_index)
};
let queue_id = unsafe { compute_symmetric_queue_id(&ctx).unwrap_or((*ctx.ctx).rx_queue_index) };
match EGRESS_XSKS_MAP.redirect(queue_id, 0) {
Ok(action) => action,
Err(_) => xdp_action::XDP_PASS,

View File

@ -1,10 +1,10 @@
[package]
name = "ingress-ebpf"
version = "0.1.0"
version = "1.0.0"
edition = "2024"
[dependencies]
common = { path = "../common", features = ["kernel"] }
common = { workspace = true, features = ["kernel"] }
aya-ebpf = { workspace = true }
aya-log-ebpf = { workspace = true }
network-types = { workspace = true }

View File

@ -2,9 +2,9 @@ use aya_ebpf::macros::map;
use aya_ebpf::maps::HashMap;
use aya_ebpf::maps::LpmTrie;
use aya_ebpf::maps::lpm_trie::Key;
use common::define::setting::{MAX_RULES, MAX_GEO_ENTRIES};
use common::model::parsed_packet::ParsedPacket;
use common::define::setting::{MAX_GEO_ENTRIES, MAX_RULES};
use common::model::ip_address::{IPv4, IPv6};
use common::model::parsed_packet::ParsedPacket;
use common::model::port_rule::PortRule;
#[map]

View File

@ -1,3 +1,3 @@
pub mod access_control;
pub mod rate_limit;
pub mod protocol_filter;
pub mod rate_limit;

View File

@ -50,19 +50,11 @@ fn get_config(index: u32, default: u64) -> u64 {
#[inline(always)]
fn is_syn_only(pkt: &ParsedPacket) -> bool {
matches!(pkt.protocol, IpProto::Tcp)
&& (pkt.tcp_flags & TCP_SYN != 0)
&& (pkt.tcp_flags & TCP_ACK == 0)
matches!(pkt.protocol, IpProto::Tcp) && (pkt.tcp_flags & TCP_SYN != 0) && (pkt.tcp_flags & TCP_ACK == 0)
}
#[inline(always)]
fn check_rate<K>(
map: &LruHashMap<K, RateState>,
key: &K,
now: u64,
window: u64,
limit: u64,
) -> bool {
fn check_rate<K>(map: &LruHashMap<K, RateState>, key: &K, now: u64, window: u64, limit: u64) -> bool {
unsafe {
if let Some(state) = map.get_ptr_mut(key) {
if now - (*state).window_start >= window {
@ -91,24 +83,48 @@ fn ipv4_should_drop(pkt: &ParsedPacket) -> Option<u8> {
let window = get_config(CFG_WINDOW_NS, DEFAULT_WINDOW_NS);
let src_ip = pkt.src_ip_v4();
if check_rate(&IPV4_PACKET_RATE_MAP, &src_ip, now, window, get_config(CFG_PACKET_RATE, DEFAULT_PACKET_RATE)) {
if check_rate(
&IPV4_PACKET_RATE_MAP,
&src_ip,
now,
window,
get_config(CFG_PACKET_RATE, DEFAULT_PACKET_RATE),
) {
return Some(DROP_REASON_RATE_LIMIT_PKT);
}
if is_syn_only(pkt) {
if check_rate(&IPV4_SYN_RATE_MAP, &src_ip, now, window, get_config(CFG_SYN_RATE, DEFAULT_SYN_RATE)) {
if check_rate(
&IPV4_SYN_RATE_MAP,
&src_ip,
now,
window,
get_config(CFG_SYN_RATE, DEFAULT_SYN_RATE),
) {
return Some(DROP_REASON_RATE_LIMIT_SYN);
}
}
if matches!(pkt.protocol, IpProto::Udp) {
if check_rate(&IPV4_UDP_RATE_MAP, &src_ip, now, window, get_config(CFG_UDP_RATE, DEFAULT_UDP_RATE)) {
if check_rate(
&IPV4_UDP_RATE_MAP,
&src_ip,
now,
window,
get_config(CFG_UDP_RATE, DEFAULT_UDP_RATE),
) {
return Some(DROP_REASON_RATE_LIMIT_UDP);
}
}
if matches!(pkt.protocol, IpProto::Udp) && pkt.dst_port == 53 {
if check_rate(&IPV4_DNS_RATE_MAP, &src_ip, now, window, get_config(CFG_DNS_RATE, DEFAULT_DNS_RATE)) {
if check_rate(
&IPV4_DNS_RATE_MAP,
&src_ip,
now,
window,
get_config(CFG_DNS_RATE, DEFAULT_DNS_RATE),
) {
return Some(DROP_REASON_RATE_LIMIT_DNS);
}
}
@ -122,24 +138,48 @@ fn ipv6_should_drop(pkt: &ParsedPacket) -> Option<u8> {
let window = get_config(CFG_WINDOW_NS, DEFAULT_WINDOW_NS);
let src_ip = pkt.src_ip_v6();
if check_rate(&IPV6_PACKET_RATE_MAP, &src_ip, now, window, get_config(CFG_PACKET_RATE, DEFAULT_PACKET_RATE)) {
if check_rate(
&IPV6_PACKET_RATE_MAP,
&src_ip,
now,
window,
get_config(CFG_PACKET_RATE, DEFAULT_PACKET_RATE),
) {
return Some(DROP_REASON_RATE_LIMIT_PKT);
}
if is_syn_only(pkt) {
if check_rate(&IPV6_SYN_RATE_MAP, &src_ip, now, window, get_config(CFG_SYN_RATE, DEFAULT_SYN_RATE)) {
if check_rate(
&IPV6_SYN_RATE_MAP,
&src_ip,
now,
window,
get_config(CFG_SYN_RATE, DEFAULT_SYN_RATE),
) {
return Some(DROP_REASON_RATE_LIMIT_SYN);
}
}
if matches!(pkt.protocol, IpProto::Udp) {
if check_rate(&IPV6_UDP_RATE_MAP, &src_ip, now, window, get_config(CFG_UDP_RATE, DEFAULT_UDP_RATE)) {
if check_rate(
&IPV6_UDP_RATE_MAP,
&src_ip,
now,
window,
get_config(CFG_UDP_RATE, DEFAULT_UDP_RATE),
) {
return Some(DROP_REASON_RATE_LIMIT_UDP);
}
}
if matches!(pkt.protocol, IpProto::Udp) && pkt.dst_port == 53 {
if check_rate(&IPV6_DNS_RATE_MAP, &src_ip, now, window, get_config(CFG_DNS_RATE, DEFAULT_DNS_RATE)) {
if check_rate(
&IPV6_DNS_RATE_MAP,
&src_ip,
now,
window,
get_config(CFG_DNS_RATE, DEFAULT_DNS_RATE),
) {
return Some(DROP_REASON_RATE_LIMIT_DNS);
}
}

View File

@ -5,17 +5,17 @@ mod action;
use aya_ebpf::bindings::xdp_action;
use aya_ebpf::macros::{map, xdp};
use aya_ebpf::maps::{Array, PerCpuArray, ProgramArray, RingBuf, XskMap};
use common::ebpf::symmetric_hash::symmetric_queue_id;
use aya_ebpf::programs::XdpContext;
#[allow(unused_imports)]
use aya_log_ebpf::info;
use common::ebpf::parsing;
use common::define::pipeline::*;
use common::define::drop_reason::*;
use common::define::pipeline::*;
use common::ebpf::parsing;
use common::ebpf::symmetric_hash::symmetric_queue_id;
use common::model::drop_event::DropEvent;
use common::model::parsed_packet::ParsedPacket;
use crate::action::{access_control, rate_limit, protocol_filter};
use crate::action::{access_control, protocol_filter, rate_limit};
#[map]
static PROGRAM_ARRAY: ProgramArray = ProgramArray::with_max_entries(MAX_STAGES, 0);
@ -70,7 +70,9 @@ unsafe fn emit_drop_event(pkt: &ParsedPacket, reason: u8) {
#[inline(always)]
unsafe fn packet_intake(ctx: &XdpContext) {
let Some(ptr) = PARSED_PACKET.get_ptr_mut(0) else { return };
let Some(ptr) = PARSED_PACKET.get_ptr_mut(0) else {
return;
};
if parsing::parse_packet(ctx.data(), ctx.data_end(), ptr).is_ok() {
chain_next(ctx, STAGE_ENTRY);
}
@ -209,9 +211,7 @@ unsafe fn compute_symmetric_queue_id() -> Option<u32> {
#[xdp]
pub fn transmission(ctx: XdpContext) -> u32 {
let queue_id = unsafe {
compute_symmetric_queue_id().unwrap_or((*ctx.ctx).rx_queue_index)
};
let queue_id = unsafe { compute_symmetric_queue_id().unwrap_or((*ctx.ctx).rx_queue_index) };
match INGRESS_XSKS_MAP.redirect(queue_id, 0) {
Ok(action) => action,
Err(_) => xdp_action::XDP_PASS,

View File

@ -1,6 +1,6 @@
[package]
name = "macros"
version = "0.1.0"
version = "1.0.0"
edition = "2024"
[lib]

569
macros/src/config.rs Normal file
View File

@ -0,0 +1,569 @@
use std::collections::BTreeMap;
use proc_macro::TokenStream;
use proc_macro2::TokenStream as TokenStream2;
use quote::{format_ident, quote};
use syn::parse::{Parse, ParseStream};
use syn::{Fields, Ident, ItemStruct, LitBool, LitStr, Result, Token, Type};
// ── Attribute parsing ──────────────────────────────────────────────
struct StructAttr {
default_section: Option<String>,
}
impl Parse for StructAttr {
fn parse(input: ParseStream) -> Result<Self> {
let mut section = None;
while !input.is_empty() {
let key: Ident = input.parse()?;
input.parse::<Token![=]>()?;
let val: LitStr = input.parse()?;
if key == "section" {
section = Some(val.value());
}
if !input.is_empty() {
input.parse::<Token![,]>()?;
}
}
Ok(Self {
default_section: section,
})
}
}
// ── Field model ────────────────────────────────────────────────────
enum ConfigField {
Setting(SettingField),
Flatten(FlattenField),
MappedParent(MappedParent),
}
struct SettingField {
ident: Ident,
ty: Type,
key: String,
default: String,
default_debug: Option<String>,
section: Option<String>,
api: bool,
}
struct FlattenField {
ident: Ident,
ty: Type,
}
struct MappedSetting {
key: String,
default: String,
default_debug: Option<String>,
parent: String,
sub_field: String,
section: Option<String>,
api: bool,
}
struct MappedParent {
ident: Ident,
ty: Type,
settings: Vec<MappedSetting>,
}
// ── Parsing ────────────────────────────────────────────────────────
fn parse_struct_mapped_settings(input: &mut ItemStruct, default_section: &Option<String>) -> Vec<MappedSetting> {
let mut mapped = Vec::new();
input.attrs.retain(|attr| {
if !attr.path().is_ident("setting") {
return true;
}
let mut key = None;
let mut default = None;
let mut default_debug = None;
let mut path = None;
let mut section = None;
let mut api = true;
let _ = attr.parse_nested_meta(|meta| {
if meta.path.is_ident("key") {
let val: LitStr = meta.value()?.parse()?;
key = Some(val.value());
} else if meta.path.is_ident("default") {
let val: LitStr = meta.value()?.parse()?;
default = Some(val.value());
} else if meta.path.is_ident("default_debug") {
let val: LitStr = meta.value()?.parse()?;
default_debug = Some(val.value());
} else if meta.path.is_ident("path") {
let val: LitStr = meta.value()?.parse()?;
path = Some(val.value());
} else if meta.path.is_ident("section") {
let val: LitStr = meta.value()?.parse()?;
section = Some(val.value());
} else if meta.path.is_ident("api") {
let val: LitBool = meta.value()?.parse()?;
api = val.value();
}
Ok(())
});
if let (Some(key), Some(default), Some(path)) = (key, default, path) {
let (parent, sub_field) = path
.split_once('.')
.expect("#[setting] `path` must be `parent.sub_field`");
mapped.push(MappedSetting {
key,
default,
default_debug,
parent: parent.to_string(),
sub_field: sub_field.to_string(),
section: section.or_else(|| default_section.clone()),
api,
});
false
} else {
true
}
});
mapped
}
fn parse_field(field: &mut syn::Field, default_section: &Option<String>) -> Option<ConfigField> {
let idx = field.attrs.iter().position(|a| a.path().is_ident("setting"))?;
let attr = field.attrs.remove(idx);
let mut is_flatten = false;
let mut key = None;
let mut default = None;
let mut default_debug = None;
let mut section = None;
let mut api = true;
attr.parse_nested_meta(|meta| {
if meta.path.is_ident("flatten") {
is_flatten = true;
} else if meta.path.is_ident("key") {
let val: LitStr = meta.value()?.parse()?;
key = Some(val.value());
} else if meta.path.is_ident("default") {
let val: LitStr = meta.value()?.parse()?;
default = Some(val.value());
} else if meta.path.is_ident("default_debug") {
let val: LitStr = meta.value()?.parse()?;
default_debug = Some(val.value());
} else if meta.path.is_ident("section") {
let val: LitStr = meta.value()?.parse()?;
section = Some(val.value());
} else if meta.path.is_ident("api") {
let val: LitBool = meta.value()?.parse()?;
api = val.value();
}
Ok(())
})
.unwrap_or_else(|e| panic!("invalid #[setting]: {e}"));
let ident = field.ident.clone().expect("named field");
let ty = field.ty.clone();
if is_flatten {
return Some(ConfigField::Flatten(FlattenField { ident, ty }));
}
Some(ConfigField::Setting(SettingField {
ident,
ty,
key: key.expect("#[setting] requires `key`"),
default: default.expect("#[setting] requires `default`"),
default_debug,
section: section.or_else(|| default_section.clone()),
api,
}))
}
// ── Type detection ─────────────────────────────────────────────────
fn is_type(ty: &Type, name: &str) -> bool {
matches!(ty, Type::Path(tp) if tp.path.is_ident(name))
}
fn is_vec_string(ty: &Type) -> bool {
if let Type::Path(tp) = ty
&& let Some(seg) = tp.path.segments.last()
{
return seg.ident == "Vec";
}
false
}
// ── Code generation: defaults() ────────────────────────────────────
fn make_default_val(ty: &Type, default: &str) -> TokenStream2 {
if is_type(ty, "String") {
quote! { #default.to_string() }
} else if is_type(ty, "bool") {
let val = default == "true" || default == "1";
quote! { #val }
} else if is_vec_string(ty) {
if default.is_empty() {
quote! { Vec::new() }
} else {
let items: Vec<&str> = default.split(',').map(|v| v.trim()).collect();
quote! { vec![#(#items.to_string()),*] }
}
} else {
// SAFETY: literal default, validated by tests
quote! { #default.parse().unwrap() }
}
}
fn gen_default(f: &SettingField) -> TokenStream2 {
let ident = &f.ident;
let ty = &f.ty;
match &f.default_debug {
Some(dbg) => {
let release_val = make_default_val(ty, &f.default);
let debug_val = make_default_val(ty, dbg);
quote! { #ident: if cfg!(debug_assertions) { #debug_val } else { #release_val } }
}
None => {
let val = make_default_val(ty, &f.default);
quote! { #ident: #val }
}
}
}
fn gen_flatten_default(f: &FlattenField) -> TokenStream2 {
let ident = &f.ident;
let ty = &f.ty;
quote! { #ident: #ty::defaults() }
}
fn gen_mapped_default(mp: &MappedParent) -> TokenStream2 {
let ident = &mp.ident;
let ty = &mp.ty;
let sub_fields: Vec<_> = mp
.settings
.iter()
.map(|s| {
let sub = format_ident!("{}", s.sub_field);
let val: TokenStream2 = match &s.default_debug {
Some(dbg) => {
let release = &s.default;
// SAFETY: literal default, validated by tests
quote! { if cfg!(debug_assertions) { #dbg.parse().unwrap() } else { #release.parse().unwrap() } }
}
None => {
let default = &s.default;
// SAFETY: literal default, validated by tests
quote! { #default.parse().unwrap() }
}
};
quote! { #sub: #val }
})
.collect();
quote! { #ident: #ty { #(#sub_fields,)* } }
}
// ── Code generation: from_config_repo() ───────────────────────────────
fn gen_override(f: &SettingField) -> TokenStream2 {
let ident = &f.ident;
let key = &f.key;
let ty = &f.ty;
if is_type(ty, "String") {
quote! {
crate::domain::common::config::helpers::override_string_nonempty(
&mut cfg.#ident, repo, #key,
).await?;
}
} else if is_type(ty, "bool") {
quote! {
crate::domain::common::config::helpers::override_bool(
&mut cfg.#ident, repo, #key,
).await?;
}
} else if is_vec_string(ty) {
quote! {
crate::domain::common::config::helpers::override_csv(
&mut cfg.#ident, repo, #key,
).await?;
}
} else {
quote! {
crate::domain::common::config::helpers::override_parsed(
&mut cfg.#ident, repo, #key,
).await?;
}
}
}
fn gen_flatten_override(f: &FlattenField) -> TokenStream2 {
let ident = &f.ident;
let ty = &f.ty;
quote! { cfg.#ident = #ty::from_config_repo(repo).await?; }
}
fn gen_mapped_overrides(mp: &MappedParent) -> TokenStream2 {
let parent = &mp.ident;
let calls: Vec<_> = mp
.settings
.iter()
.map(|s| {
let sub = format_ident!("{}", s.sub_field);
let key = &s.key;
quote! {
crate::domain::common::config::helpers::override_parsed(
&mut cfg.#parent.#sub, repo, #key,
).await?;
}
})
.collect();
quote! { #(#calls)* }
}
// ── Code generation: seed_config_defaults() ───────────────────────────────
fn gen_seed(f: &SettingField) -> TokenStream2 {
let key = &f.key;
let default = &f.default;
match &f.default_debug {
Some(dbg) => quote! {
crate::domain::common::config::helpers::seed_key(
repo, #key,
if cfg!(debug_assertions) { #dbg } else { #default },
).await?;
},
None => quote! {
crate::domain::common::config::helpers::seed_key(repo, #key, #default).await?;
},
}
}
fn gen_flatten_seed(f: &FlattenField) -> TokenStream2 {
let ty = &f.ty;
quote! { #ty::seed_config_defaults(repo).await?; }
}
fn gen_mapped_seeds(mp: &MappedParent) -> TokenStream2 {
let calls: Vec<_> = mp
.settings
.iter()
.map(|s| {
let key = &s.key;
let default = &s.default;
match &s.default_debug {
Some(dbg) => quote! {
crate::domain::common::config::helpers::seed_key(
repo, #key,
if cfg!(debug_assertions) { #dbg } else { #default },
).await?;
},
None => quote! {
crate::domain::common::config::helpers::seed_key(repo, #key, #default).await?;
},
}
})
.collect();
quote! { #(#calls)* }
}
// ── Code generation: API_KEYS ──────────────────────────────────────
fn collect_api_keys(fields: &[ConfigField]) -> Vec<(&str, &str)> {
let mut keys = Vec::new();
for f in fields {
match f {
ConfigField::Setting(s) if s.api => {
let sec = s.section.as_deref().unwrap_or("default");
keys.push((sec, s.key.as_str()));
}
ConfigField::MappedParent(mp) => {
for s in &mp.settings {
if s.api {
let sec = s.section.as_deref().unwrap_or("default");
keys.push((sec, s.key.as_str()));
}
}
}
_ => {}
}
}
keys
}
fn gen_keys_consts(fields: &[ConfigField]) -> TokenStream2 {
let api_keys = collect_api_keys(fields);
if api_keys.is_empty() {
return quote! {};
}
let mut sections: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
for (sec, key) in &api_keys {
sections.entry(sec).or_default().push(key);
}
let single = sections.len() == 1;
sections
.iter()
.map(|(section, keys)| {
let name = if single {
format_ident!("API_KEYS")
} else {
format_ident!("{}_KEYS", section.to_uppercase())
};
quote! { pub const #name: &[&str] = &[#(#keys),*]; }
})
.collect()
}
// ── Code generation: api_values() ─────────────────────────────────
fn value_to_string_expr(ty: &Type, expr: TokenStream2) -> TokenStream2 {
if is_type(ty, "String") {
quote! { #expr.clone() }
} else if is_vec_string(ty) {
quote! { #expr.join(",") }
} else {
quote! { #expr.to_string() }
}
}
fn gen_api_values(fields: &[ConfigField]) -> TokenStream2 {
let entries: Vec<_> = fields
.iter()
.flat_map(|f| match f {
ConfigField::Setting(s) if s.api => {
let key = &s.key;
let ident = &s.ident;
let value = value_to_string_expr(&s.ty, quote! { self.#ident });
vec![quote! { values.push((#key, #value)); }]
}
ConfigField::Flatten(f) => {
let ident = &f.ident;
vec![quote! { values.extend(self.#ident.api_values()); }]
}
ConfigField::MappedParent(mp) => mp
.settings
.iter()
.filter(|s| s.api)
.map(|s| {
let key = &s.key;
let parent = &mp.ident;
let sub = format_ident!("{}", s.sub_field);
quote! { values.push((#key, self.#parent.#sub.to_string())); }
})
.collect(),
_ => Vec::new(),
})
.collect();
quote! {
pub fn api_values(&self) -> Vec<(&'static str, String)> {
let mut values = Vec::new();
#(#entries)*
values
}
}
}
// ── Entry point ────────────────────────────────────────────────────
pub fn config_settings_impl(attr: TokenStream, item: TokenStream) -> TokenStream {
let struct_attr = syn::parse_macro_input!(attr as StructAttr);
let mut input = syn::parse_macro_input!(item as ItemStruct);
let mapped_settings = parse_struct_mapped_settings(&mut input, &struct_attr.default_section);
let mut mapped_groups: BTreeMap<String, Vec<MappedSetting>> = BTreeMap::new();
for ms in mapped_settings {
mapped_groups.entry(ms.parent.clone()).or_default().push(ms);
}
let fields = match &mut input.fields {
Fields::Named(f) => f,
_ => panic!("config_settings only supports named fields"),
};
let mut config_fields = Vec::new();
for field in &mut fields.named {
let field_name = field.ident.as_ref().expect("named field").to_string();
if let Some(settings) = mapped_groups.remove(&field_name) {
config_fields.push(ConfigField::MappedParent(MappedParent {
ident: field.ident.clone().unwrap(),
ty: field.ty.clone(),
settings,
}));
} else if let Some(cf) = parse_field(field, &struct_attr.default_section) {
config_fields.push(cf);
}
}
let struct_name = &input.ident;
let keys_consts = gen_keys_consts(&config_fields);
let api_values = gen_api_values(&config_fields);
let default_fields: Vec<_> = config_fields
.iter()
.map(|f| match f {
ConfigField::Setting(s) => gen_default(s),
ConfigField::Flatten(s) => gen_flatten_default(s),
ConfigField::MappedParent(mp) => gen_mapped_default(mp),
})
.collect();
let override_calls: Vec<_> = config_fields
.iter()
.map(|f| match f {
ConfigField::Setting(s) => gen_override(s),
ConfigField::Flatten(s) => gen_flatten_override(s),
ConfigField::MappedParent(mp) => gen_mapped_overrides(mp),
})
.collect();
let seed_calls: Vec<_> = config_fields
.iter()
.map(|f| match f {
ConfigField::Setting(s) => gen_seed(s),
ConfigField::Flatten(s) => gen_flatten_seed(s),
ConfigField::MappedParent(mp) => gen_mapped_seeds(mp),
})
.collect();
let expanded = quote! {
#input
impl #struct_name {
#keys_consts
#api_values
pub fn defaults() -> Self {
Self {
#(#default_fields,)*
}
}
pub async fn from_config_repo(
repo: &dyn crate::interface::config_repo::ConfigRepo,
) -> Result<Self, crate::domain::common::error::Error> {
let mut cfg = Self::defaults();
#(#override_calls)*
Ok(cfg)
}
pub async fn seed_config_defaults(
repo: &dyn crate::interface::config_repo::ConfigRepo,
) -> Result<(), crate::domain::common::error::Error> {
#(#seed_calls)*
Ok(())
}
}
};
TokenStream::from(expanded)
}

View File

@ -1,3 +1,4 @@
mod config;
mod error_enum;
mod log;
mod loggable;
@ -5,6 +6,11 @@ mod traceable;
use proc_macro::TokenStream;
#[proc_macro_attribute]
pub fn config_settings(attr: TokenStream, item: TokenStream) -> TokenStream {
config::config_settings_impl(attr, item)
}
#[proc_macro]
pub fn log(input: TokenStream) -> TokenStream {
log::log_impl(input)

View File

@ -1,7 +1,7 @@
use proc_macro::TokenStream;
use quote::quote;
use syn::parse::{Parse, ParseStream};
use syn::{parse_macro_input, Expr, Token};
use syn::{Expr, Token, parse_macro_input};
struct LogInput {
error: Expr,
@ -62,5 +62,5 @@ pub fn log_impl(input: TokenStream) -> TokenStream {
}
}
}
.into()
.into()
}

View File

@ -1,10 +1,10 @@
[package]
name = "mcp-server"
version = "0.1.0"
version = "1.0.0"
edition = "2024"
[dependencies]
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
reqwest = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }

View File

@ -57,7 +57,11 @@ impl McpServer {
.timeout(Duration::from_secs(30))
.build()
.expect("Failed to create HTTP client");
Self { client, api_url, api_key }
Self {
client,
api_url,
api_key,
}
}
async fn handle_request(&self, req: JsonRpcRequest) -> JsonRpcResponse {
@ -69,7 +73,10 @@ impl McpServer {
jsonrpc: "2.0".into(),
id: req.id,
result: None,
error: Some(JsonRpcError { code: -32601, message: "Method not found".into() }),
error: Some(JsonRpcError {
code: -32601,
message: "Method not found".into(),
}),
},
}
}
@ -120,7 +127,10 @@ impl McpServer {
async fn handle_tool_call(&self, id: Option<Value>, params: Value) -> JsonRpcResponse {
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 arguments = params
.get("arguments")
.cloned()
.unwrap_or(Value::Object(Default::default()));
let (method, path, body): (&str, String, Option<Value>) = match tool_name {
"get_health" => ("GET", "/api/health/status".into(), None),
@ -136,34 +146,65 @@ impl McpServer {
let ip = arguments.get("ip").and_then(|v| v.as_str()).unwrap_or("");
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)))
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 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)))
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");
("PUT", "/api/system/enforce-mode".into(), Some(serde_json::json!({"mode": mode})))
(
"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("");
("PUT", "/api/filter/dns/blacklist".into(), Some(serde_json::json!({"domains": [domain]})))
(
"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("");
("PUT", "/api/acl/geo/block".into(), Some(serde_json::json!({"country_codes": [code]})))
(
"PUT",
"/api/acl/geo/block".into(),
Some(serde_json::json!({"country_codes": [code]})),
)
}
_ => {
return JsonRpcResponse {
jsonrpc: "2.0".into(),
id,
result: None,
error: Some(JsonRpcError { code: -32602, message: format!("Unknown tool: {}", tool_name) }),
error: Some(JsonRpcError {
code: -32602,
message: format!("Unknown tool: {}", tool_name),
}),
};
}
};
@ -208,17 +249,15 @@ impl McpServer {
}
}
}
Err(e) => {
JsonRpcResponse {
jsonrpc: "2.0".into(),
id,
result: Some(serde_json::json!({
"content": [{ "type": "text", "text": format!("Connection error: {}", e) }],
"isError": true
})),
error: None,
}
}
Err(e) => JsonRpcResponse {
jsonrpc: "2.0".into(),
id,
result: Some(serde_json::json!({
"content": [{ "type": "text", "text": format!("Connection error: {}", e) }],
"isError": true
})),
error: None,
},
}
}
}
@ -227,7 +266,8 @@ impl McpServer {
async fn main() {
let args = Args::parse();
let api_key = args.api_key
let api_key = args
.api_key
.or_else(|| std::env::var("NETGUARDIA_API_KEY").ok())
.unwrap_or_else(|| {
eprintln!("Error: No API key provided. Set NETGUARDIA_API_KEY env var or use --api-key flag.");
@ -256,7 +296,10 @@ async fn main() {
jsonrpc: "2.0".into(),
id: None,
result: None,
error: Some(JsonRpcError { code: -32700, message: format!("Parse error: {}", e) }),
error: Some(JsonRpcError {
code: -32700,
message: format!("Parse error: {}", e),
}),
};
let _ = writeln!(stdout, "{}", serde_json::to_string(&err_resp).unwrap());
let _ = stdout.flush();

@ -1 +1 @@
Subproject commit 00d347c5eae3ed32f595b0a3553601f16bedfa7e
Subproject commit 4fd1b9027860ce86b29cd54eb3f5eea523a7e692

View File

@ -1,11 +1,11 @@
[package]
name = "net-guardia"
version = "0.1.0"
version = "1.0.0"
edition = "2024"
[dependencies]
common = { path = "../common", features = ["user"] }
macros = { path = "../macros" }
common = { workspace = true, features = ["user"] }
macros = { workspace = true }
# eBPF userspace
aya = { workspace = true }
@ -20,19 +20,19 @@ actix = { workspace = true }
actix-web = { workspace = true }
actix-cors = { workspace = true }
actix-ws = { workspace = true }
actix-multipart = "0.7"
actix-files = "0.6"
uuid = { version = "1", features = ["v4"] }
rust-embed = "8.11.0"
mime_guess = "2.0.5"
url = "2.5.8"
tokio-tungstenite = "0.28.0"
actix-multipart = { workspace = true }
actix-files = { workspace = true }
uuid = { workspace = true }
rust-embed = { workspace = true }
mime_guess = { workspace = true }
url = { workspace = true }
tokio-tungstenite = { workspace = true }
# Serialization
serde = { workspace = true }
serde_json = { workspace = true }
serde_yaml_ng = { workspace = true }
toml = "1.0.7"
toml = { workspace = true }
# Async
tokio = { workspace = true }
@ -48,18 +48,21 @@ tracing-subscriber = { workspace = true }
tract-onnx = { workspace = true }
# Email
lettre = { version = "0.11", default-features = false, features = ["builder", "hostname", "smtp-transport", "tokio1-rustls-tls"] }
chrono = { version = "0.4", default-features = false, features = ["clock", "std"] }
lettre = { workspace = true }
chrono = { workspace = true }
# HTTP client (Telegram, MCP proxy)
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
reqwest = { workspace = true }
# CLI
clap = { workspace = true, features = ["env"] }
# Architecture
async-trait = "0.1"
dashmap = "6"
arc-swap = "1"
moka = { version = "0.12", features = ["sync"] }
notify = "7"
async-trait = { workspace = true }
dashmap = { workspace = true }
arc-swap = { workspace = true }
moka = { workspace = true }
notify = { workspace = true }
# Utilities
parking_lot = { workspace = true }
@ -68,17 +71,16 @@ sysinfo = { workspace = true }
maxminddb = { workspace = true }
ipnetwork = { workspace = true }
lru = { workspace = true }
rusqlite = { version = "0.34", features = ["bundled-sqlcipher"] }
r2d2 = "0.8"
r2d2_sqlite = "0.27"
rusqlite = { workspace = true }
async-sqlite = { workspace = true }
jsonwebtoken = { workspace = true }
argon2 = { workspace = true }
sha2 = "0.10"
hmac = "0.12"
aes-gcm = "0.10"
hkdf = "0.12"
sha2 = { workspace = true }
hmac = { workspace = true }
aes-gcm = { workspace = true }
hkdf = { workspace = true }
base64 = { workspace = true }
sd-notify = "0.4"
sd-notify = { workspace = true }
rand = { workspace = true }
[features]
@ -87,7 +89,7 @@ default = []
[build-dependencies]
cargo_metadata = { workspace = true }
which = { workspace = true }
dotenvy = "0.15.7"
dotenvy = { workspace = true }
[[bin]]
name = "net-guardia"

View File

@ -2,24 +2,24 @@ use std::net::{IpAddr, SocketAddrV4, SocketAddrV6};
use std::sync::Arc;
use crate::adapter::ebpf::access_control::AccessControl;
use crate::interface::port::access_control::AccessControlPort;
use crate::model::access_control::list_type::ListType;
use crate::model::error::Error;
use crate::model::error::ebpf::EbpfError;
use crate::model::monitoring::direction::FlowDirection;
use crate::domain::common::error::Error;
use crate::domain::data_plane::direction::FlowDirection;
use crate::domain::data_plane::error::EbpfError;
use crate::domain::data_plane::list_type::ListType;
use crate::interface::access_control::AccessControlPort;
/// Adapter that implements AccessControlPort by delegating to the eBPF AccessControl.
pub struct EbpfAccessControlAdapter {
pub struct AccessControlAdapter {
access_control: Arc<AccessControl>,
}
impl EbpfAccessControlAdapter {
impl AccessControlAdapter {
pub fn new(access_control: Arc<AccessControl>) -> Self {
Self { access_control }
}
}
impl AccessControlPort for EbpfAccessControlAdapter {
impl AccessControlPort for AccessControlAdapter {
fn block_ip(&self, ip: &str) -> Result<(), Error> {
let addr: IpAddr = ip
.parse()

View File

@ -7,12 +7,12 @@ use common::model::ip_address::{IPv4, IPv6, Port};
use common::model::port_rule::PortRule;
use parking_lot::RwLock;
use crate::interface::port::access_control_admin::AccessControlAdminPort;
use crate::model::access_control::ip_address::NativeConvert;
use crate::model::access_control::list_type::ListType;
use crate::model::error::Error;
use crate::model::error::ebpf::EbpfError;
use crate::model::monitoring::direction::FlowDirection;
use crate::domain::common::error::Error;
use crate::domain::data_plane::direction::FlowDirection;
use crate::domain::data_plane::error::EbpfError;
use crate::domain::data_plane::ip_address::NativeConvert;
use crate::domain::data_plane::list_type::ListType;
use crate::interface::access_control_admin::AccessControlAdminPort;
pub struct AccessControl {
ipv4_src_whitelist: RwLock<MapWrapper<IPv4>>,
@ -40,9 +40,6 @@ impl AccessControl {
Ok(access_control)
}
/// Construct an AccessControl backed by no eBPF maps. Used when eBPF
/// failed to load at startup; every mutating call returns `EbpfError::NotLoaded`,
/// and list queries return empty maps.
pub fn unavailable() -> Self {
Self {
ipv4_src_whitelist: RwLock::new(MapWrapper::unavailable()),
@ -146,12 +143,22 @@ impl AccessControl {
}
impl AccessControlAdminPort for AccessControl {
fn get_ipv4_list(&self, direction: FlowDirection, list_type: ListType) -> HashMap<Ipv4Addr, Vec<Port>> {
self.get_ipv4_list(direction, list_type)
}
fn get_ipv6_list(&self, direction: FlowDirection, list_type: ListType) -> HashMap<Ipv6Addr, Vec<Port>> {
self.get_ipv6_list(direction, list_type)
}
fn add_ipv4_list(&self, direction: FlowDirection, list_type: ListType, address: SocketAddrV4) -> Result<(), Error> {
self.add_ipv4_list(direction, list_type, address)
}
fn add_ipv6_list(&self, direction: FlowDirection, list_type: ListType, address: SocketAddrV6) -> Result<(), Error> {
self.add_ipv6_list(direction, list_type, address)
}
fn remove_ipv4_list(
&self,
direction: FlowDirection,
@ -160,6 +167,7 @@ impl AccessControlAdminPort for AccessControl {
) -> Result<(), Error> {
self.remove_ipv4_list(direction, list_type, address)
}
fn remove_ipv6_list(
&self,
direction: FlowDirection,
@ -168,12 +176,6 @@ impl AccessControlAdminPort for AccessControl {
) -> Result<(), Error> {
self.remove_ipv6_list(direction, list_type, address)
}
fn get_ipv4_list(&self, direction: FlowDirection, list_type: ListType) -> HashMap<Ipv4Addr, Vec<Port>> {
self.get_ipv4_list(direction, list_type)
}
fn get_ipv6_list(&self, direction: FlowDirection, list_type: ListType) -> HashMap<Ipv6Addr, Vec<Port>> {
self.get_ipv6_list(direction, list_type)
}
}
struct MapWrapper<T> {

View File

@ -1,18 +1,45 @@
use std::mem;
use std::net::Ipv6Addr;
use std::sync::Arc;
use std::sync::atomic::Ordering;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use aya::maps::{MapData, RingBuf};
use common::define::drop_reason::*;
use common::model::drop_event::DropEvent as RawDropEvent;
use tokio::sync::{broadcast, oneshot};
use tokio::time::interval;
use common::define::drop_reason::*;
use common::model::drop_event::DropEvent as RawDropEvent;
use crate::domain::data_plane::drop_event::{DropCounters, DropEventMessage};
use crate::interface::drop_stats::DropStatsPort;
use crate::model::config::constants::DROP_CHANNEL_CAPACITY;
use crate::model::monitoring::drop_event::{DropCounters, DropCountersAtomic, DropEventMessage};
#[derive(Default)]
pub struct DropCountersAtomic {
acl_blacklist: AtomicU64,
rate_limit_pkt: AtomicU64,
rate_limit_syn: AtomicU64,
rate_limit_udp: AtomicU64,
rate_limit_dns: AtomicU64,
protocol_filter: AtomicU64,
dns_blacklist: AtomicU64,
geo_block: AtomicU64,
total: AtomicU64,
}
impl DropCountersAtomic {
pub fn snapshot(&self) -> DropCounters {
DropCounters {
acl_blacklist: self.acl_blacklist.load(Ordering::Relaxed),
rate_limit_pkt: self.rate_limit_pkt.load(Ordering::Relaxed),
rate_limit_syn: self.rate_limit_syn.load(Ordering::Relaxed),
rate_limit_udp: self.rate_limit_udp.load(Ordering::Relaxed),
rate_limit_dns: self.rate_limit_dns.load(Ordering::Relaxed),
protocol_filter: self.protocol_filter.load(Ordering::Relaxed),
dns_blacklist: self.dns_blacklist.load(Ordering::Relaxed),
geo_block: self.geo_block.load(Ordering::Relaxed),
total: self.total.load(Ordering::Relaxed),
}
}
}
pub struct DropMonitor {
broadcast_tx: broadcast::Sender<DropEventMessage>,
@ -20,8 +47,8 @@ pub struct DropMonitor {
}
impl DropMonitor {
pub fn new() -> Self {
let (tx, _) = broadcast::channel(DROP_CHANNEL_CAPACITY);
pub fn new(channel_capacity: usize) -> Self {
let (tx, _) = broadcast::channel(channel_capacity.max(1));
Self {
broadcast_tx: tx,
counters: DropCountersAtomic::default(),
@ -32,18 +59,8 @@ impl DropMonitor {
self.broadcast_tx.subscribe()
}
pub fn get_counters(&self) -> DropCounters {
self.counters.snapshot()
}
/// Record a userspace drop decision (XSK worker's DNS filter) by the
/// per-reason counter. Callers at this layer haven't parsed src/dst yet,
/// so no broadcast event is emitted — `/api/stats/drops` stays correct,
/// `/ws/drops` simply does not surface the individual packet. Parse the
/// packet upstream if you need a structured event.
pub fn record_userspace_drop_count_only(&self, reason: u8) {
self.counters.total.fetch_add(1, Ordering::Relaxed);
let bucket = match reason {
fn bucket_for(&self, reason: u8) -> Option<&AtomicU64> {
match reason {
DROP_REASON_ACL_BLACKLIST => Some(&self.counters.acl_blacklist),
DROP_REASON_RATE_LIMIT_PKT => Some(&self.counters.rate_limit_pkt),
DROP_REASON_RATE_LIMIT_SYN => Some(&self.counters.rate_limit_syn),
@ -53,32 +70,25 @@ impl DropMonitor {
DROP_REASON_DNS_BLACKLIST => Some(&self.counters.dns_blacklist),
DROP_REASON_GEO_BLOCK => Some(&self.counters.geo_block),
_ => None,
};
if let Some(counter) = bucket {
}
}
fn record_drop(&self, reason: u8) {
self.counters.total.fetch_add(1, Ordering::Relaxed);
if let Some(counter) = self.bucket_for(reason) {
counter.fetch_add(1, Ordering::Relaxed);
}
}
pub fn record_userspace_drop_count_only(&self, reason: u8) {
self.record_drop(reason);
}
fn process_event(&self, raw: &RawDropEvent) {
self.counters.total.fetch_add(1, Ordering::Relaxed);
let bucket = match raw.reason {
DROP_REASON_ACL_BLACKLIST => Some(&self.counters.acl_blacklist),
DROP_REASON_RATE_LIMIT_PKT => Some(&self.counters.rate_limit_pkt),
DROP_REASON_RATE_LIMIT_SYN => Some(&self.counters.rate_limit_syn),
DROP_REASON_RATE_LIMIT_UDP => Some(&self.counters.rate_limit_udp),
DROP_REASON_RATE_LIMIT_DNS => Some(&self.counters.rate_limit_dns),
DROP_REASON_PROTOCOL_FILTER => Some(&self.counters.protocol_filter),
DROP_REASON_DNS_BLACKLIST => Some(&self.counters.dns_blacklist),
DROP_REASON_GEO_BLOCK => Some(&self.counters.geo_block),
_ => None,
};
if let Some(counter) = bucket {
counter.fetch_add(1, Ordering::Relaxed);
}
self.record_drop(raw.reason);
let reason_str = reason_to_str(raw.reason);
// Format IPs based on version
let (src_ip, dst_ip) = format_ips(raw);
let msg = DropEventMessage {
@ -96,9 +106,9 @@ impl DropMonitor {
}
}
impl Default for DropMonitor {
fn default() -> Self {
Self::new()
impl DropStatsPort for DropMonitor {
fn get_counters(&self) -> DropCounters {
self.counters.snapshot()
}
}
@ -116,7 +126,6 @@ fn format_ips(raw: &RawDropEvent) -> (String, String) {
(src, dst)
}
_ => {
// IPv6 - format as hex
let src = format_ipv6(&raw.src_ip);
let dst = format_ipv6(&raw.dst_ip);
(src, dst)
@ -142,12 +151,12 @@ 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<()> {
let (shutdown_tx, mut shutdown_rx) = oneshot::channel();
tokio::spawn(async move {
let mut ring_buf = ring_buf;
// todo add interval value to config
let mut interval = interval(Duration::from_millis(100));
loop {
@ -157,7 +166,7 @@ pub async fn start_consumer(ring_buf: RingBuf<MapData>, monitor: Arc<DropMonitor
}
while let Some(item) = ring_buf.next() {
if item.len() >= mem::size_of::<RawDropEvent>() {
if item.len() >= size_of::<RawDropEvent>() {
let event = unsafe { &*(item.as_ptr() as *const RawDropEvent) };
monitor.process_event(event);
}

View File

@ -9,15 +9,14 @@ use ipnetwork::IpNetwork;
use maxminddb::{Reader, geoip2};
use parking_lot::RwLock;
use crate::infrastructure::app_config::AppConfig;
use crate::interface::port::geo_block_api::GeoBlockPort;
use crate::model::error::Error;
use crate::model::error::ebpf::EbpfError;
use crate::model::error::misc::MiscError;
use crate::domain::common::config::AppConfig;
use crate::domain::common::error::Error;
use crate::domain::common::error::misc::MiscError;
use crate::domain::data_plane::error::EbpfError;
use crate::interface::geo_block_api::GeoBlockPort;
/// 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)>>,
v6: StdHashMap<String, Vec<(u128, u32)>>,
}
@ -29,15 +28,16 @@ pub struct GeoBlock {
}
impl GeoBlock {
pub fn new(ebpf: &mut Ebpf, app_config: &AppConfig) -> Result<Self, Error> {
pub fn new(ebpf: &mut Ebpf, app_config: Arc<ArcSwap<AppConfig>>) -> Result<Self, Error> {
let v4_map = ebpf.take_map("GEO_BLOCK_V4").ok_or(EbpfError::MapNotFound)?;
let v4_trie = LpmTrie::try_from(v4_map).map_err(EbpfError::MapOperationError)?;
let v6_map = ebpf.take_map("GEO_BLOCK_V6").ok_or(EbpfError::MapNotFound)?;
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(db_path.clone(), e))?;
// todo read config from AppConfig, not db
let db_path = app_config.load().acl.geoip_db_path.clone();
let reader = Reader::open_readfile(&db_path).map_err(|e| MiscError::GeoIPDatabaseError(db_path.clone(), e))?;
let index = Self::build_index(&reader)?;
@ -49,12 +49,8 @@ impl GeoBlock {
})
}
/// Construct a GeoBlock with no eBPF trie backing. Attempts to still load
/// the GeoIP index so the frontend can list what *would* be enforced;
/// mutating calls (`block_countries`, `unblock_countries`) return
/// `EbpfError::NotLoaded`.
pub fn unavailable(app_config: &AppConfig) -> Self {
let index = Reader::open_readfile(&app_config.misc.geoip_db_name)
pub fn unavailable(app_config: Arc<ArcSwap<AppConfig>>) -> Self {
let index = Reader::open_readfile(&app_config.load().acl.geoip_db_path)
.ok()
.and_then(|reader| Self::build_index(&reader).ok())
.unwrap_or(GeoIndex {
@ -69,7 +65,6 @@ impl GeoBlock {
}
}
/// Build index from MaxMind DB at startup. One-time cost.
fn build_index(reader: &Reader<Vec<u8>>) -> Result<GeoIndex, Error> {
let mut v4: StdHashMap<String, Vec<(u32, u32)>> = StdHashMap::new();
let mut v6: StdHashMap<String, Vec<(u128, u32)>> = StdHashMap::new();
@ -115,7 +110,10 @@ impl GeoBlock {
Ok(GeoIndex { v4, v6 })
}
/// Block multiple countries at once, rebuilding tries only once.
pub fn get_blocked_countries(&self) -> Vec<String> {
self.blocked_countries.load().iter().cloned().collect()
}
pub fn block_countries(&self, country_codes: &[String]) -> Result<u64, Error> {
self.blocked_countries.rcu(|cur| {
let mut next: HashSet<String> = (**cur).clone();
@ -130,7 +128,6 @@ impl GeoBlock {
self.rebuild_tries()
}
/// Unblock multiple countries at once, rebuilding tries only once.
pub fn unblock_countries(&self, country_codes: &[String]) -> Result<u64, Error> {
self.blocked_countries.rcu(|cur| {
let mut next: HashSet<String> = (**cur).clone();
@ -142,15 +139,9 @@ impl GeoBlock {
self.rebuild_tries()
}
pub fn get_blocked_countries(&self) -> Vec<String> {
self.blocked_countries.load().iter().cloned().collect()
}
/// Rebuild LPM tries from pre-indexed data. Fast — no DB scan.
fn rebuild_tries(&self) -> Result<u64, Error> {
let countries = self.blocked_countries.load_full();
// Collect entries from index (no DB scan)
let mut v4_entries: Vec<(Key<u32>, u8)> = Vec::new();
let mut v6_entries: Vec<(Key<u128>, u8)> = Vec::new();
@ -167,7 +158,6 @@ impl GeoBlock {
}
}
// Lock, clear, insert
let mut v4_guard = self.geo_block_v4.write();
let mut v6_guard = self.geo_block_v6.write();
let (v4_trie, v6_trie) = match (v4_guard.as_mut(), v6_guard.as_mut()) {
@ -208,13 +198,15 @@ impl GeoBlock {
}
impl GeoBlockPort for GeoBlock {
fn block_countries(&self, codes: &[String]) -> Result<u64, Error> {
self.block_countries(codes)
}
fn unblock_countries(&self, codes: &[String]) -> Result<u64, Error> {
self.unblock_countries(codes)
}
fn list_blocked(&self) -> Vec<String> {
self.get_blocked_countries()
}
fn block_countries(&self, codes: &[String]) -> Result<u64, Error> {
self.block_countries(codes)
}
fn unblock_countries(&self, codes: &[String]) -> Result<u64, Error> {
self.unblock_countries(codes)
}
}

View File

@ -1,5 +1,4 @@
pub mod access_control;
pub mod dns_filter;
pub mod drop_monitor;
pub mod geo_block;
pub mod protocol_filter;
@ -8,6 +7,7 @@ pub mod xsk_manager;
use std::sync::Arc;
use arc_swap::ArcSwap;
use aya::Ebpf;
use aya::maps::{MapData, RingBuf};
use crossbeam::queue::SegQueue;
@ -16,24 +16,23 @@ use parking_lot::Mutex;
use tokio::sync::oneshot;
use crate::adapter::ebpf::access_control::AccessControl;
use crate::adapter::ebpf::dns_filter::DnsFilter;
use crate::adapter::ebpf::drop_monitor::DropMonitor;
use crate::adapter::ebpf::geo_block::GeoBlock;
use crate::adapter::ebpf::protocol_filter::ProtocolFilter;
use crate::adapter::ebpf::rate_limit::RateLimitConfig;
use crate::adapter::ebpf::xsk_manager::XskManager;
use crate::infrastructure::app_config::AppConfig;
use crate::interface::port::dns_query_filter::DnsQueryFilter;
use crate::interface::port::packet_sink::PacketSinkFactory;
use crate::model::error::Error;
use crate::model::error::ebpf::EbpfError;
use crate::model::error::system::SystemError;
use crate::domain::common::config::AppConfig;
use crate::domain::common::error::Error;
use crate::domain::common::error::system::SystemError;
use crate::domain::data_plane::error::EbpfError;
use crate::interface::dns_query_filter::DnsQueryFilter;
use crate::interface::packet_sink::PacketSinkFactory;
pub struct EbpfServices {
pub xsk_manager: Arc<XskManager>,
pub access_control: Arc<AccessControl>,
pub protocol_filter: Arc<ProtocolFilter>,
pub dns_filter: Arc<DnsFilter>,
pub dns_query_filter: Arc<dyn DnsQueryFilter>,
pub geo_block: Arc<GeoBlock>,
pub rate_limit: Arc<RateLimitConfig>,
pub drop_monitor: Arc<DropMonitor>,
@ -42,14 +41,18 @@ pub struct EbpfServices {
}
impl EbpfServices {
pub fn new(app_config: Arc<AppConfig>, ingress_ebpf: &mut Ebpf, egress_ebpf: &mut Ebpf) -> Result<Self, Error> {
pub fn new(
app_config: Arc<ArcSwap<AppConfig>>,
ingress_ebpf: &mut Ebpf,
egress_ebpf: &mut Ebpf,
dns_query_filter: Arc<dyn DnsQueryFilter>,
) -> Result<Self, Error> {
let xsk_manager = XskManager::new(app_config.clone(), ingress_ebpf, egress_ebpf)?;
let access_control = AccessControl::new(ingress_ebpf)?;
let protocol_filter = ProtocolFilter::new(ingress_ebpf)?;
let dns_filter = DnsFilter::new();
let geo_block = GeoBlock::new(ingress_ebpf, &app_config)?;
let geo_block = GeoBlock::new(ingress_ebpf, app_config.clone())?;
let rate_limit = RateLimitConfig::new(ingress_ebpf)?;
let drop_monitor = Arc::new(DropMonitor::new());
let drop_monitor = Arc::new(DropMonitor::new(app_config.load().observability.drop_channel_capacity));
let drop_ring_buf = {
let map = ingress_ebpf.take_map("DROP_EVENTS").ok_or(EbpfError::MapNotFound)?;
RingBuf::try_from(map).map_err(EbpfError::MapOperationError)?
@ -58,7 +61,7 @@ impl EbpfServices {
xsk_manager: Arc::new(xsk_manager),
access_control: Arc::new(access_control),
protocol_filter: Arc::new(protocol_filter),
dns_filter: Arc::new(dns_filter),
dns_query_filter,
geo_block: Arc::new(geo_block),
rate_limit: Arc::new(rate_limit),
drop_monitor,
@ -67,18 +70,15 @@ impl EbpfServices {
})
}
/// Build an EbpfServices with every eBPF-backed subservice in the
/// "unavailable" state. Used when eBPF failed to load at startup.
/// Queries return empty results; mutating calls return `EbpfError::NotLoaded`.
pub fn unavailable(app_config: Arc<AppConfig>) -> Self {
pub fn unavailable(app_config: Arc<ArcSwap<AppConfig>>, dns_query_filter: Arc<dyn DnsQueryFilter>) -> Self {
Self {
xsk_manager: Arc::new(XskManager::unavailable(app_config.clone())),
access_control: Arc::new(AccessControl::unavailable()),
protocol_filter: Arc::new(ProtocolFilter::unavailable()),
dns_filter: Arc::new(DnsFilter::new()),
geo_block: Arc::new(GeoBlock::unavailable(&app_config)),
dns_query_filter,
geo_block: Arc::new(GeoBlock::unavailable(app_config.clone())),
rate_limit: Arc::new(RateLimitConfig::unavailable()),
drop_monitor: Arc::new(DropMonitor::new()),
drop_monitor: Arc::new(DropMonitor::new(app_config.load().observability.drop_channel_capacity)),
drop_ring_buf: Mutex::new(None),
shutdowns: SegQueue::new(),
}
@ -86,7 +86,7 @@ impl EbpfServices {
pub async fn run(self: Arc<Self>, sink_factory: Arc<dyn PacketSinkFactory>) -> Result<(), Error> {
let xsk_manager = self.xsk_manager.clone();
let dns: Arc<dyn DnsQueryFilter> = self.dns_filter.clone();
let dns = self.dns_query_filter.clone();
xsk_manager.run(
Some(sink_factory),
Some(dns),

View File

@ -1,5 +1,5 @@
use std::collections::HashMap;
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddrV4, SocketAddrV6};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, SocketAddrV4, SocketAddrV6};
use aya::maps::{Array as AyaArray, HashMap as AyaHashMap, MapData};
use aya::{Ebpf, Pod};
@ -8,9 +8,10 @@ use common::model::ip_address::{AddrPortV4, AddrPortV6, IPv4, IPv6};
use common::model::placeholder::PlaceHolder;
use parking_lot::RwLock;
use crate::model::access_control::ip_address::NativeConvert;
use crate::model::error::Error;
use crate::model::error::ebpf::EbpfError;
use crate::domain::common::error::Error;
use crate::domain::data_plane::error::EbpfError;
use crate::domain::data_plane::ip_address::NativeConvert;
use crate::interface::protocol_filter::{IpVersion, ProtocolFilterPort};
pub struct ProtocolFilter {
ipv4_http_service: RwLock<HttpServiceWrapper<AddrPortV4>>,
@ -40,7 +41,6 @@ impl ProtocolFilter {
Ok(service)
}
/// Construct a ProtocolFilter backed by no eBPF maps.
pub fn unavailable() -> Self {
Self {
ipv4_http_service: RwLock::new(HttpServiceWrapper::unavailable()),
@ -54,125 +54,196 @@ impl ProtocolFilter {
ipv6_ssh_black_list: RwLock::new(EntryMap::unavailable()),
}
}
}
pub fn get_ipv4_http_service(&self) -> HashMap<SocketAddrV4, Vec<HttpMethod>> {
self.ipv4_http_service.read().get_http_method()
fn require_v4_socket(addr: SocketAddr) -> Result<SocketAddrV4, Error> {
match addr {
SocketAddr::V4(a) => Ok(a),
SocketAddr::V6(_) => Err(EbpfError::IpVersionMismatch("IPv4".to_string()))?,
}
}
fn require_v6_socket(addr: SocketAddr) -> Result<SocketAddrV6, Error> {
match addr {
SocketAddr::V6(a) => Ok(a),
SocketAddr::V4(_) => Err(EbpfError::IpVersionMismatch("IPv6".to_string()))?,
}
}
fn require_v4_ip(ip: IpAddr) -> Result<Ipv4Addr, Error> {
match ip {
IpAddr::V4(a) => Ok(a),
IpAddr::V6(_) => Err(EbpfError::IpVersionMismatch("IPv4".to_string()))?,
}
}
fn require_v6_ip(ip: IpAddr) -> Result<Ipv6Addr, Error> {
match ip {
IpAddr::V6(a) => Ok(a),
IpAddr::V4(_) => Err(EbpfError::IpVersionMismatch("IPv6".to_string()))?,
}
}
impl ProtocolFilterPort for ProtocolFilter {
fn get_http_service(&self, version: IpVersion) -> HashMap<SocketAddr, Vec<HttpMethod>> {
match version {
IpVersion::V4 => self
.ipv4_http_service
.read()
.get_http_method()
.into_iter()
.map(|(k, v)| (SocketAddr::V4(k), v))
.collect(),
IpVersion::V6 => self
.ipv6_http_service
.read()
.get_http_method()
.into_iter()
.map(|(k, v)| (SocketAddr::V6(k), v))
.collect(),
}
}
pub fn get_ipv6_http_service(&self) -> HashMap<SocketAddrV6, Vec<HttpMethod>> {
self.ipv6_http_service.read().get_http_method()
fn add_http_service(&self, version: IpVersion, address: SocketAddr, methods: Vec<HttpMethod>) -> Result<(), Error> {
match version {
IpVersion::V4 => self
.ipv4_http_service
.write()
.add_http_service(require_v4_socket(address)?, methods),
IpVersion::V6 => self
.ipv6_http_service
.write()
.add_http_service(require_v6_socket(address)?, methods),
}
}
pub fn add_ipv4_http_service(&self, address: SocketAddrV4, http_method: Vec<HttpMethod>) -> Result<(), Error> {
self.ipv4_http_service.write().add_http_service(address, http_method)
}
pub fn add_ipv6_http_service(&self, address: SocketAddrV6, http_method: Vec<HttpMethod>) -> Result<(), Error> {
self.ipv6_http_service.write().add_http_service(address, http_method)
}
pub fn remove_ipv4_http_service(
fn remove_http_service(
&self,
address: SocketAddrV4,
removed_http_method: Vec<HttpMethod>,
version: IpVersion,
address: SocketAddr,
methods: Vec<HttpMethod>,
) -> Result<(), Error> {
self.ipv4_http_service
.write()
.remove_http_service(address, removed_http_method)
match version {
IpVersion::V4 => self
.ipv4_http_service
.write()
.remove_http_service(require_v4_socket(address)?, methods),
IpVersion::V6 => self
.ipv6_http_service
.write()
.remove_http_service(require_v6_socket(address)?, methods),
}
}
pub fn remove_ipv6_http_service(
&self,
address: SocketAddrV6,
removed_http_method: Vec<HttpMethod>,
) -> Result<(), Error> {
self.ipv6_http_service
.write()
.remove_http_service(address, removed_http_method)
}
pub fn is_ssh_white_list_enable(&self) -> bool {
fn is_ssh_white_list_enable(&self) -> bool {
self.ssh_white_list_enable.read().is_white_list_enable()
}
pub fn enable_ssh_white_list(&self) -> Result<(), Error> {
fn enable_ssh_white_list(&self) -> Result<(), Error> {
self.ssh_white_list_enable.write().enable_white_list()
}
pub fn disable_ssh_white_list(&self) -> Result<(), Error> {
fn disable_ssh_white_list(&self) -> Result<(), Error> {
self.ssh_white_list_enable.write().disable_white_list()
}
pub fn get_ipv4_ssh_service(&self) -> Vec<SocketAddrV4> {
self.ipv4_ssh_service.read().get_all()
fn get_ssh_service(&self, version: IpVersion) -> Vec<SocketAddr> {
match version {
IpVersion::V4 => self
.ipv4_ssh_service
.read()
.get_all()
.into_iter()
.map(SocketAddr::V4)
.collect(),
IpVersion::V6 => self
.ipv6_ssh_service
.read()
.get_all()
.into_iter()
.map(SocketAddr::V6)
.collect(),
}
}
pub fn get_ipv6_ssh_service(&self) -> Vec<SocketAddrV6> {
self.ipv6_ssh_service.read().get_all()
fn add_ssh_service(&self, version: IpVersion, address: SocketAddr) -> Result<(), Error> {
match version {
IpVersion::V4 => self.ipv4_ssh_service.write().add(require_v4_socket(address)?),
IpVersion::V6 => self.ipv6_ssh_service.write().add(require_v6_socket(address)?),
}
}
pub fn add_ipv4_ssh_service(&self, address: SocketAddrV4) -> Result<(), Error> {
self.ipv4_ssh_service.write().add(address)
fn remove_ssh_service(&self, version: IpVersion, address: SocketAddr) -> Result<(), Error> {
match version {
IpVersion::V4 => self.ipv4_ssh_service.write().remove(require_v4_socket(address)?),
IpVersion::V6 => self.ipv6_ssh_service.write().remove(require_v6_socket(address)?),
}
}
pub fn add_ipv6_ssh_service(&self, address: SocketAddrV6) -> Result<(), Error> {
self.ipv6_ssh_service.write().add(address)
fn get_ssh_white_list(&self, version: IpVersion) -> Vec<IpAddr> {
match version {
IpVersion::V4 => self
.ipv4_ssh_white_list
.read()
.get_all()
.into_iter()
.map(IpAddr::V4)
.collect(),
IpVersion::V6 => self
.ipv6_ssh_white_list
.read()
.get_all()
.into_iter()
.map(IpAddr::V6)
.collect(),
}
}
pub fn remove_ipv4_ssh_service(&self, address: SocketAddrV4) -> Result<(), Error> {
self.ipv4_ssh_service.write().remove(address)
fn add_ssh_white_list(&self, version: IpVersion, ip: IpAddr) -> Result<(), Error> {
match version {
IpVersion::V4 => self.ipv4_ssh_white_list.write().add(require_v4_ip(ip)?),
IpVersion::V6 => self.ipv6_ssh_white_list.write().add(require_v6_ip(ip)?),
}
}
pub fn remove_ipv6_ssh_service(&self, address: SocketAddrV6) -> Result<(), Error> {
self.ipv6_ssh_service.write().remove(address)
fn remove_ssh_white_list(&self, version: IpVersion, ip: IpAddr) -> Result<(), Error> {
match version {
IpVersion::V4 => self.ipv4_ssh_white_list.write().remove(require_v4_ip(ip)?),
IpVersion::V6 => self.ipv6_ssh_white_list.write().remove(require_v6_ip(ip)?),
}
}
pub fn get_ipv4_ssh_white_list(&self) -> Vec<Ipv4Addr> {
self.ipv4_ssh_white_list.read().get_all()
fn get_ssh_black_list(&self, version: IpVersion) -> Vec<IpAddr> {
match version {
IpVersion::V4 => self
.ipv4_ssh_black_list
.read()
.get_all()
.into_iter()
.map(IpAddr::V4)
.collect(),
IpVersion::V6 => self
.ipv6_ssh_black_list
.read()
.get_all()
.into_iter()
.map(IpAddr::V6)
.collect(),
}
}
pub fn get_ipv6_ssh_white_list(&self) -> Vec<Ipv6Addr> {
self.ipv6_ssh_white_list.read().get_all()
fn add_ssh_black_list(&self, version: IpVersion, ip: IpAddr) -> Result<(), Error> {
match version {
IpVersion::V4 => self.ipv4_ssh_black_list.write().add(require_v4_ip(ip)?),
IpVersion::V6 => self.ipv6_ssh_black_list.write().add(require_v6_ip(ip)?),
}
}
pub fn add_ipv4_ssh_white_list(&self, ip: Ipv4Addr) -> Result<(), Error> {
self.ipv4_ssh_white_list.write().add(ip)
}
pub fn add_ipv6_ssh_white_list(&self, ip: Ipv6Addr) -> Result<(), Error> {
self.ipv6_ssh_white_list.write().add(ip)
}
pub fn remove_ipv4_ssh_white_list(&self, ip: Ipv4Addr) -> Result<(), Error> {
self.ipv4_ssh_white_list.write().remove(ip)
}
pub fn remove_ipv6_ssh_white_list(&self, ip: Ipv6Addr) -> Result<(), Error> {
self.ipv6_ssh_white_list.write().remove(ip)
}
pub fn get_ipv4_ssh_black_list(&self) -> Vec<Ipv4Addr> {
self.ipv4_ssh_black_list.read().get_all()
}
pub fn get_ipv6_ssh_black_list(&self) -> Vec<Ipv6Addr> {
self.ipv6_ssh_black_list.read().get_all()
}
pub fn add_ipv4_ssh_black_list(&self, ip: Ipv4Addr) -> Result<(), Error> {
self.ipv4_ssh_black_list.write().add(ip)
}
pub fn add_ipv6_ssh_black_list(&self, ip: Ipv6Addr) -> Result<(), Error> {
self.ipv6_ssh_black_list.write().add(ip)
}
pub fn remove_ipv4_ssh_black_list(&self, ip: Ipv4Addr) -> Result<(), Error> {
self.ipv4_ssh_black_list.write().remove(ip)
}
pub fn remove_ipv6_ssh_black_list(&self, ip: Ipv6Addr) -> Result<(), Error> {
self.ipv6_ssh_black_list.write().remove(ip)
fn remove_ssh_black_list(&self, version: IpVersion, ip: IpAddr) -> Result<(), Error> {
match version {
IpVersion::V4 => self.ipv4_ssh_black_list.write().remove(require_v4_ip(ip)?),
IpVersion::V6 => self.ipv6_ssh_black_list.write().remove(require_v6_ip(ip)?),
}
}
}

View File

@ -2,9 +2,9 @@ use aya::Ebpf;
use aya::maps::{Array, MapData};
use parking_lot::Mutex;
use crate::interface::port::rate_limit_api::RateLimitPort;
use crate::model::error::Error;
use crate::model::error::ebpf::EbpfError;
use crate::domain::common::error::Error;
use crate::domain::data_plane::error::EbpfError;
use crate::interface::rate_limit_api::RateLimitPort;
pub struct RateLimitConfig {
config_map: Mutex<Option<Array<MapData, u64>>>,
@ -83,30 +83,39 @@ impl RateLimitPort for RateLimitConfig {
fn set_packet_rate(&self, rate: u64) -> Result<(), Error> {
self.set_packet_rate(rate)
}
fn set_syn_rate(&self, rate: u64) -> Result<(), Error> {
self.set_syn_rate(rate)
}
fn set_udp_rate(&self, rate: u64) -> Result<(), Error> {
self.set_udp_rate(rate)
}
fn set_dns_rate(&self, rate: u64) -> Result<(), Error> {
self.set_dns_rate(rate)
}
fn set_window_ns(&self, ns: u64) -> Result<(), Error> {
self.set_window_ns(ns)
}
fn get_packet_rate(&self) -> Result<u64, Error> {
self.get_packet_rate()
}
fn get_syn_rate(&self) -> Result<u64, Error> {
self.get_syn_rate()
}
fn get_udp_rate(&self) -> Result<u64, Error> {
self.get_udp_rate()
}
fn get_dns_rate(&self) -> Result<u64, Error> {
self.get_dns_rate()
}
fn get_window_ns(&self) -> Result<u64, Error> {
self.get_window_ns()
}

View File

@ -6,8 +6,10 @@ use std::sync::Arc;
use std::thread;
use std::time::Duration;
use arc_swap::ArcSwap;
use aya::Ebpf;
use aya::maps::{MapData, XskMap};
use common::define::drop_reason::DROP_REASON_DNS_BLACKLIST;
use crossbeam::channel::{Receiver, Sender, TrySendError, bounded};
use crossbeam::queue::SegQueue;
use macros::log;
@ -16,21 +18,18 @@ use tokio::sync::oneshot::{self, error::TryRecvError};
use xsk_rs::config::{BindFlags, FrameSize, Interface, LibxdpFlags, QueueSize, SocketConfig, UmemConfig};
use xsk_rs::{CompQueue, FillQueue, FrameDesc, RxQueue, Socket, TxQueue, Umem};
use common::define::drop_reason::DROP_REASON_DNS_BLACKLIST;
use crate::adapter::ebpf::drop_monitor::DropMonitor;
use crate::infrastructure::app_config::AppConfig;
use crate::interface::port::dns_query_filter::DnsQueryFilter;
use crate::interface::port::packet_sink::{PacketSink, PacketSinkFactory};
use crate::model::error::Error;
use crate::model::error::ebpf::EbpfError;
use crate::model::error::system::SystemError;
use crate::model::log::ebpf::EbpfLog;
use crate::model::monitoring::direction::Direction;
use crate::model::system::config::NetworkConfig;
use crate::domain::common::config::AppConfig;
use crate::domain::common::config::ebpf::EbpfConfig;
use crate::domain::common::error::Error;
use crate::domain::common::error::system::SystemError;
use crate::domain::data_plane::direction::Direction;
use crate::domain::data_plane::error::EbpfError;
use crate::domain::data_plane::log::EbpfLog;
use crate::interface::dns_query_filter::DnsQueryFilter;
use crate::interface::packet_sink::{PacketSink, PacketSinkFactory};
use crate::utils::packet_parser::parse_packet;
/// Pre-allocated buffer pool to avoid per-packet malloc.
struct BufferPool {
buffers: Vec<Vec<u8>>,
buffer_size: usize,
@ -62,32 +61,36 @@ impl BufferPool {
}
pub struct XskManager {
app_config: Arc<AppConfig>,
xsk_map: Mutex<Option<XskMap<MapData>>>,
app_config: Arc<ArcSwap<AppConfig>>,
ingress_xsk_map: Mutex<Option<XskMap<MapData>>>,
egress_xsk_map: Mutex<Option<XskMap<MapData>>>,
}
impl XskManager {
pub fn new(app_config: Arc<AppConfig>, ingress_ebpf: &mut Ebpf, egress_ebpf: &mut Ebpf) -> Result<Self, Error> {
let map = ingress_ebpf
pub fn new(
app_config: Arc<ArcSwap<AppConfig>>,
ingress_ebpf: &mut Ebpf,
egress_ebpf: &mut Ebpf,
) -> Result<Self, Error> {
let ingress_map = ingress_ebpf
.take_map("INGRESS_XSKS_MAP")
.ok_or(EbpfError::MapNotFound)?;
let xsk_map = XskMap::try_from(map).map_err(EbpfError::MapOperationError)?;
let ingress_xsk_map = XskMap::try_from(ingress_map).map_err(EbpfError::MapOperationError)?;
let egress_map = egress_ebpf.take_map("EGRESS_XSKS_MAP").ok_or(EbpfError::MapNotFound)?;
let egress_xsk_map = XskMap::try_from(egress_map).map_err(EbpfError::MapOperationError)?;
Ok(Self {
app_config,
xsk_map: Mutex::new(Some(xsk_map)),
ingress_xsk_map: Mutex::new(Some(ingress_xsk_map)),
egress_xsk_map: Mutex::new(Some(egress_xsk_map)),
})
}
pub fn unavailable(app_config: Arc<AppConfig>) -> Self {
pub fn unavailable(app_config: Arc<ArcSwap<AppConfig>>) -> Self {
Self {
app_config,
xsk_map: Mutex::new(None),
ingress_xsk_map: Mutex::new(None),
egress_xsk_map: Mutex::new(None),
}
}
@ -99,14 +102,15 @@ impl XskManager {
drop_monitor: Option<Arc<DropMonitor>>,
shutdowns: &SegQueue<oneshot::Sender<()>>,
) -> Result<(), Error> {
// todo need to check logic
// If eBPF failed to load, there are no XSK maps to bind and no queues
// to start — skip silently. AF_XDP would have no maps to attach sockets
// to, and ML sees no packets, which is the designed behaviour.
if self.xsk_map.lock().is_none() || self.egress_xsk_map.lock().is_none() {
if self.ingress_xsk_map.lock().is_none() || self.egress_xsk_map.lock().is_none() {
return Ok(());
}
let network = self.app_config.network.clone();
let network = self.app_config.load().ebpf.clone();
let combined_queue_count = network.combined_queue_count;
for queue_id in 0..combined_queue_count {
@ -119,7 +123,6 @@ impl XskManager {
network.clone(),
queue_id,
&network.ingress_ifname,
&network.egress_ifname,
Direction::Ingress,
sink.clone(),
dns_filter.clone(),
@ -130,20 +133,19 @@ impl XskManager {
network.clone(),
queue_id,
&network.egress_ifname,
&network.ingress_ifname,
Direction::Egress,
sink,
None,
drop_monitor.clone(),
)?;
let mut xsk_guard = self.xsk_map.lock();
let mut ingress_guard = self.ingress_xsk_map.lock();
let mut egress_guard = self.egress_xsk_map.lock();
let xsk_map = xsk_guard.as_mut().ok_or(EbpfError::NotLoaded)?;
let ingress_xsk_map = ingress_guard.as_mut().ok_or(EbpfError::NotLoaded)?;
let egress_xsk_map = egress_guard.as_mut().ok_or(EbpfError::NotLoaded)?;
let ingress_fd = ingress_xsk.rx.fd().as_raw_fd();
xsk_map
ingress_xsk_map
.set(queue_id, ingress_fd, 0)
.map_err(EbpfError::AfXdpSetFailed)?;
@ -152,7 +154,7 @@ impl XskManager {
.set(queue_id, egress_fd, 0)
.map_err(EbpfError::AfXdpSetFailed)?;
drop(xsk_guard);
drop(ingress_guard);
drop(egress_guard);
let ingress_shutdown = ingress_xsk.run(ingress_to_egress_tx, egress_to_ingress_rx)?;
@ -181,15 +183,18 @@ pub struct XskPair {
drop_monitor: Option<Arc<DropMonitor>>,
packet_buffer_size: usize,
buffer_pool_capacity: usize,
completion_batch_size: usize,
rx_batch_size: usize,
tx_batch_size: usize,
tx_packet_buf: Vec<Vec<u8>>,
tx_frame_buf: Vec<FrameDesc>,
}
impl XskPair {
#[allow(clippy::too_many_arguments)]
pub fn new(
config: NetworkConfig,
config: EbpfConfig,
queue_id: u32,
rx_ifname: &str,
_tx_ifname: &str,
direction: Direction,
sink: Option<Arc<dyn PacketSink>>,
dns_filter: Option<Arc<dyn DnsQueryFilter>>,
@ -253,6 +258,11 @@ impl XskPair {
drop_monitor,
packet_buffer_size: config.packet_buffer_size,
buffer_pool_capacity: config.buffer_pool_capacity,
completion_batch_size: config.xsk_completion_batch_size,
rx_batch_size: config.xsk_rx_batch_size,
tx_batch_size: config.xsk_tx_batch_size,
tx_packet_buf: Vec::with_capacity(config.xsk_tx_batch_size),
tx_frame_buf: Vec::with_capacity(config.xsk_tx_batch_size),
};
Ok(xsk_pair)
@ -273,8 +283,8 @@ impl XskPair {
let mut shutdown_rx = Some(shutdown_rx);
let mut idle_count: u32 = 0;
let mut buffer_pool = BufferPool::new(self.buffer_pool_capacity, self.packet_buffer_size);
let mut comp_descs = vec![FrameDesc::default(); 256];
let mut rx_descs = vec![FrameDesc::default(); 64];
let mut comp_descs = vec![FrameDesc::default(); self.completion_batch_size];
let mut rx_descs = vec![FrameDesc::default(); self.rx_batch_size];
loop {
if let Some(ref mut rx) = shutdown_rx {
@ -309,13 +319,14 @@ impl XskPair {
idle_count = 0;
}
let sleep_us = match idle_count {
0..=10 => 1,
11..=100 => 10,
_ => 100,
};
thread::sleep(Duration::from_micros(sleep_us));
if idle_count > 0 {
let sleep_us = match idle_count {
1..=10 => 1,
11..=100 => 10,
_ => 100,
};
thread::sleep(Duration::from_micros(sleep_us));
}
}
log!(EbpfLog::XSKShutdown);
@ -418,15 +429,15 @@ impl XskPair {
buffer_pool: &mut BufferPool,
comp_descs: &mut [FrameDesc],
) -> Result<usize, EbpfError> {
let mut packets_to_send = Vec::with_capacity(64);
self.tx_packet_buf.clear();
while let Ok(packet) = forward_rx.try_recv() {
packets_to_send.push(packet);
if packets_to_send.len() >= 64 {
self.tx_packet_buf.push(packet);
if self.tx_packet_buf.len() >= self.tx_batch_size {
break;
}
}
if packets_to_send.is_empty() {
if self.tx_packet_buf.is_empty() {
return Ok(0);
}
@ -434,10 +445,10 @@ impl XskPair {
log!(EbpfLog::CompQueueError(format!("{:?}", e)));
}
let total_packets = packets_to_send.len();
let total_packets = self.tx_packet_buf.len();
if self.frame_pool.is_empty() {
for pkt in packets_to_send {
for pkt in self.tx_packet_buf.drain(..) {
buffer_pool.put(pkt);
}
log!(EbpfLog::FramePoolExhausted(total_packets));
@ -445,17 +456,19 @@ impl XskPair {
}
let available = self.frame_pool.len().min(total_packets);
let mut frames: Vec<FrameDesc> = self.frame_pool.drain(self.frame_pool.len() - available..).collect();
self.tx_frame_buf.clear();
self.tx_frame_buf
.extend(self.frame_pool.drain(self.frame_pool.len() - available..));
if frames.is_empty() {
for pkt in packets_to_send {
if self.tx_frame_buf.is_empty() {
for pkt in self.tx_packet_buf.drain(..) {
buffer_pool.put(pkt);
}
log!(EbpfLog::NoFramesAvailable);
return Ok(0);
}
for (frame, packet) in frames.iter_mut().zip(packets_to_send.iter()) {
for (frame, packet) in self.tx_frame_buf.iter_mut().zip(self.tx_packet_buf.iter()) {
unsafe {
self.umem
.data_mut(frame)
@ -465,11 +478,11 @@ impl XskPair {
}
}
let nb_submitted = unsafe { self.tx.produce(&frames) };
let nb_submitted = unsafe { self.tx.produce(&self.tx_frame_buf) };
// Return unsubmitted frames to pool to prevent frame leak
if nb_submitted < frames.len() {
for frame in frames[nb_submitted..].iter() {
if nb_submitted < self.tx_frame_buf.len() {
for frame in self.tx_frame_buf[nb_submitted..].iter() {
self.frame_pool.push(*frame);
}
}
@ -491,7 +504,7 @@ impl XskPair {
}
// Return all buffers to pool
for pkt in packets_to_send {
for pkt in self.tx_packet_buf.drain(..) {
buffer_pool.put(pkt);
}

View File

@ -1,10 +1,10 @@
use actix_web::{HttpResponse, Scope, web};
use crate::adapter::http::middleware::extractor::AuthClaims;
use crate::adapter::persistence::Database;
use crate::core::auth::extractor::AuthClaims;
use crate::interface::port::audit::AuditRepo;
use crate::model::error::Error;
use crate::model::error::database::DatabaseError;
use crate::domain::common::error::Error;
use crate::domain::common::error::database::DatabaseError;
use crate::interface::audit::AuditRepo;
pub fn initialize() -> Scope {
web::scope("/audit")
@ -13,7 +13,7 @@ pub fn initialize() -> Scope {
}
async fn list_audit_logs(_auth: AuthClaims, db: web::Data<Database>) -> HttpResponse {
match db.list_audit_logs() {
match db.list_audit_logs().await {
Ok(entries) => {
let json: Vec<serde_json::Value> = entries
.into_iter()
@ -40,8 +40,8 @@ async fn list_audit_logs(_auth: AuthClaims, db: web::Data<Database>) -> HttpResp
/// integrity without shell access. Any mismatch returns the offending
/// row id inside `error` so the dashboard can link straight to it.
async fn verify_chain(_auth: AuthClaims, audit: web::Data<dyn AuditRepo>) -> HttpResponse {
match audit.verify_audit_log_chain() {
Ok(count) => HttpResponse::Ok().json(serde_json::json!({
match audit.verify_audit_log_chain(0).await {
Ok((count, _last_id)) => HttpResponse::Ok().json(serde_json::json!({
"chain_intact": true,
"verified": count,
})),

View File

@ -3,9 +3,10 @@ use std::net::{SocketAddrV4, SocketAddrV6};
use actix_web::{HttpResponse, Responder, Scope, web};
use serde::Deserialize;
use crate::core::acl_service::AclService;
use crate::model::access_control::list_type::ListType;
use crate::model::monitoring::direction::FlowDirection;
use crate::adapter::http::helpers::ok_or_error;
use crate::core::data_plane::acl_service::AclService;
use crate::domain::data_plane::direction::FlowDirection;
use crate::domain::data_plane::list_type::ListType;
#[derive(Deserialize)]
struct CountryCodesRequest {
@ -43,10 +44,7 @@ async fn add_ipv4_list(
acl: web::Data<AclService>,
) -> impl Responder {
let (direction, list_type) = path.into_inner();
match acl.add_ipv4(direction, list_type, address.into_inner()) {
Ok(_) => HttpResponse::Ok().finish(),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
ok_or_error(acl.add_ipv4(direction, list_type, address.into_inner()).await)
}
async fn add_ipv6_list(
@ -55,10 +53,7 @@ async fn add_ipv6_list(
acl: web::Data<AclService>,
) -> impl Responder {
let (direction, list_type) = path.into_inner();
match acl.add_ipv6(direction, list_type, address.into_inner()) {
Ok(_) => HttpResponse::Ok().finish(),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
ok_or_error(acl.add_ipv6(direction, list_type, address.into_inner()).await)
}
async fn remove_ipv4_list(
@ -67,10 +62,7 @@ async fn remove_ipv4_list(
acl: web::Data<AclService>,
) -> impl Responder {
let (direction, list_type) = path.into_inner();
match acl.remove_ipv4(direction, list_type, address.into_inner()) {
Ok(_) => HttpResponse::Ok().finish(),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
ok_or_error(acl.remove_ipv4(direction, list_type, address.into_inner()).await)
}
async fn remove_ipv6_list(
@ -79,10 +71,7 @@ async fn remove_ipv6_list(
acl: web::Data<AclService>,
) -> impl Responder {
let (direction, list_type) = path.into_inner();
match acl.remove_ipv6(direction, list_type, address.into_inner()) {
Ok(_) => HttpResponse::Ok().finish(),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
ok_or_error(acl.remove_ipv6(direction, list_type, address.into_inner()).await)
}
async fn get_geo_blocked(acl: web::Data<AclService>) -> impl Responder {
@ -91,7 +80,7 @@ async fn get_geo_blocked(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) {
match acl.block_geo_countries(&codes).await {
Ok(total_prefixes) => HttpResponse::Ok().json(serde_json::json!({
"blocked_countries": acl.get_blocked_countries(),
"total_prefixes": total_prefixes,
@ -102,7 +91,7 @@ async fn block_geo_countries(body: web::Json<CountryCodesRequest>, acl: web::Dat
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) {
match acl.unblock_geo_countries(&codes).await {
Ok(total_prefixes) => HttpResponse::Ok().json(serde_json::json!({
"blocked_countries": acl.get_blocked_countries(),
"total_prefixes": total_prefixes,

View File

@ -0,0 +1,229 @@
use std::net::{IpAddr, SocketAddr};
use actix_web::{HttpResponse, Responder, Scope, web};
use common::model::http_method::HttpMethod;
use serde::Deserialize;
use crate::adapter::http::helpers::ok_or_error;
use crate::core::data_plane::dns_filter_service::DnsFilterService;
use crate::interface::protocol_filter::{IpVersion, ProtocolFilterPort};
pub fn initialize() -> Scope {
web::scope("/filter")
.service(http_scope())
.service(ssh_scope())
.service(dns_scope())
}
#[derive(Deserialize)]
struct DnsDomainsRequest {
domains: Vec<String>,
}
fn parse_ip_version(path: &str) -> Option<IpVersion> {
match path {
"ipv4" => Some(IpVersion::V4),
"ipv6" => Some(IpVersion::V6),
_ => None,
}
}
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)),
)
}
async fn get_dns_blacklist(service: web::Data<DnsFilterService>) -> impl Responder {
HttpResponse::Ok().json(serde_json::json!({"domains": service.list_domains()}))
}
async fn add_dns_blacklist(
payload: web::Json<DnsDomainsRequest>,
service: web::Data<DnsFilterService>,
) -> impl Responder {
let domains = payload.into_inner().domains;
match service.add_domains(&domains).await {
Ok(count) => HttpResponse::Ok().json(serde_json::json!({"added": count})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
}
async fn remove_dns_blacklist(
payload: web::Json<DnsDomainsRequest>,
service: web::Data<DnsFilterService>,
) -> impl Responder {
let domains = payload.into_inner().domains;
match service.remove_domains(&domains).await {
Ok(count) => HttpResponse::Ok().json(serde_json::json!({"removed": count})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
}
fn http_scope() -> Scope {
web::scope("/http")
.route("/{version}", web::get().to(get_http_service))
.route("/{version}", web::put().to(add_http_service))
.route("/{version}", web::delete().to(remove_http_service))
}
fn ssh_scope() -> Scope {
web::scope("/ssh")
.route("/{version}", web::get().to(get_ssh_service))
.route("/{version}", web::put().to(add_ssh_service))
.route("/{version}", web::delete().to(remove_ssh_service))
.service(ssh_whitelist_scope())
.service(ssh_blacklist_scope())
}
fn ssh_whitelist_scope() -> Scope {
web::scope("/whitelist")
.route("/status", web::get().to(is_ssh_white_list_enable))
.route("/enable", web::post().to(enable_ssh_white_list))
.route("/disable", web::post().to(disable_ssh_white_list))
.route("/{version}", web::get().to(get_ssh_white_list))
.route("/{version}", web::put().to(add_ssh_white_list))
.route("/{version}", web::delete().to(remove_ssh_white_list))
}
fn ssh_blacklist_scope() -> Scope {
web::scope("/blacklist")
.route("/{version}", web::get().to(get_ssh_black_list))
.route("/{version}", web::put().to(add_ssh_black_list))
.route("/{version}", web::delete().to(remove_ssh_black_list))
}
async fn get_http_service(path: web::Path<String>, service: web::Data<dyn ProtocolFilterPort>) -> impl Responder {
let Some(version) = parse_ip_version(&path) else {
return HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid IP version"}));
};
HttpResponse::Ok().json(service.get_http_service(version))
}
async fn add_http_service(
path: web::Path<String>,
payload: web::Json<(SocketAddr, Vec<HttpMethod>)>,
service: web::Data<dyn ProtocolFilterPort>,
) -> impl Responder {
let Some(version) = parse_ip_version(&path) else {
return HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid IP version"}));
};
let (addr, methods) = payload.into_inner();
ok_or_error(service.add_http_service(version, addr, methods))
}
async fn remove_http_service(
path: web::Path<String>,
payload: web::Json<(SocketAddr, Vec<HttpMethod>)>,
service: web::Data<dyn ProtocolFilterPort>,
) -> impl Responder {
let Some(version) = parse_ip_version(&path) else {
return HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid IP version"}));
};
let (addr, methods) = payload.into_inner();
ok_or_error(service.remove_http_service(version, addr, methods))
}
async fn get_ssh_service(path: web::Path<String>, service: web::Data<dyn ProtocolFilterPort>) -> impl Responder {
let Some(version) = parse_ip_version(&path) else {
return HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid IP version"}));
};
HttpResponse::Ok().json(service.get_ssh_service(version))
}
async fn add_ssh_service(
path: web::Path<String>,
payload: web::Json<SocketAddr>,
service: web::Data<dyn ProtocolFilterPort>,
) -> impl Responder {
let Some(version) = parse_ip_version(&path) else {
return HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid IP version"}));
};
ok_or_error(service.add_ssh_service(version, payload.into_inner()))
}
async fn remove_ssh_service(
path: web::Path<String>,
payload: web::Json<SocketAddr>,
service: web::Data<dyn ProtocolFilterPort>,
) -> impl Responder {
let Some(version) = parse_ip_version(&path) else {
return HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid IP version"}));
};
ok_or_error(service.remove_ssh_service(version, payload.into_inner()))
}
async fn is_ssh_white_list_enable(service: web::Data<dyn ProtocolFilterPort>) -> impl Responder {
HttpResponse::Ok().json(serde_json::json!({
"enabled": service.is_ssh_white_list_enable(),
}))
}
async fn enable_ssh_white_list(service: web::Data<dyn ProtocolFilterPort>) -> impl Responder {
ok_or_error(service.enable_ssh_white_list())
}
async fn disable_ssh_white_list(service: web::Data<dyn ProtocolFilterPort>) -> impl Responder {
ok_or_error(service.disable_ssh_white_list())
}
async fn get_ssh_white_list(path: web::Path<String>, service: web::Data<dyn ProtocolFilterPort>) -> impl Responder {
let Some(version) = parse_ip_version(&path) else {
return HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid IP version"}));
};
HttpResponse::Ok().json(service.get_ssh_white_list(version))
}
async fn add_ssh_white_list(
path: web::Path<String>,
payload: web::Json<IpAddr>,
service: web::Data<dyn ProtocolFilterPort>,
) -> impl Responder {
let Some(version) = parse_ip_version(&path) else {
return HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid IP version"}));
};
ok_or_error(service.add_ssh_white_list(version, payload.into_inner()))
}
async fn remove_ssh_white_list(
path: web::Path<String>,
payload: web::Json<IpAddr>,
service: web::Data<dyn ProtocolFilterPort>,
) -> impl Responder {
let Some(version) = parse_ip_version(&path) else {
return HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid IP version"}));
};
ok_or_error(service.remove_ssh_white_list(version, payload.into_inner()))
}
async fn get_ssh_black_list(path: web::Path<String>, service: web::Data<dyn ProtocolFilterPort>) -> impl Responder {
let Some(version) = parse_ip_version(&path) else {
return HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid IP version"}));
};
HttpResponse::Ok().json(service.get_ssh_black_list(version))
}
async fn add_ssh_black_list(
path: web::Path<String>,
payload: web::Json<IpAddr>,
service: web::Data<dyn ProtocolFilterPort>,
) -> impl Responder {
let Some(version) = parse_ip_version(&path) else {
return HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid IP version"}));
};
ok_or_error(service.add_ssh_black_list(version, payload.into_inner()))
}
async fn remove_ssh_black_list(
path: web::Path<String>,
payload: web::Json<IpAddr>,
service: web::Data<dyn ProtocolFilterPort>,
) -> impl Responder {
let Some(version) = parse_ip_version(&path) else {
return HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid IP version"}));
};
ok_or_error(service.remove_ssh_black_list(version, payload.into_inner()))
}

View File

@ -0,0 +1,3 @@
pub mod acl;
pub mod filter;
pub mod rate_limit;

View File

@ -1,8 +1,9 @@
use actix_web::{HttpResponse, Responder, Scope, web};
use common::define::setting::*;
use crate::core::rate_limit_service::RateLimitService;
use crate::model::system::rate_limit_settings::RateLimitSettings;
use crate::adapter::http::helpers::ok_or_error;
use crate::core::data_plane::rate_limit_service::RateLimitService;
use crate::domain::common::system::rate_limit_settings::RateLimitSettings;
pub fn initialize() -> Scope {
web::scope("/rate-limit")
@ -21,8 +22,5 @@ async fn get_config(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()})),
}
ok_or_error(service.update(&settings.into_inner()).await)
}

View File

@ -6,8 +6,8 @@
use actix_web::{HttpResponse, Scope, web};
use crate::core::auth::extractor::AuthClaims;
use crate::core::ml::feature_extractor::feature_registry_names;
use crate::adapter::http::middleware::extractor::AuthClaims;
use crate::domain::detection::feature_extractor::feature_registry_names;
pub fn initialize() -> Scope {
web::scope("/byo").route("/feature-registry", web::get().to(get_feature_registry))

View File

@ -10,8 +10,9 @@ use std::path::{Path, PathBuf};
use actix_files::NamedFile;
use actix_web::{HttpRequest, HttpResponse, Responder, Scope, web};
use crate::core::ml::engine::Engine;
use crate::core::ml::traffic_logger::{FLOW_TRACE_FILE_EXT, FLOW_TRACE_FILE_MARKER, list_flow_trace_files};
use crate::core::inference::engine::Engine;
use crate::core::inference::traffic_logger::list_flow_trace_files;
use crate::domain::common::config::constants::{FLOW_TRACE_FILE_EXT, FLOW_TRACE_FILE_MARKER};
pub fn initialize() -> Scope {
web::scope("/flow-trace")

View File

@ -8,24 +8,13 @@
//! logs by hand.
use actix_web::{HttpRequest, HttpResponse, Responder, Scope, web};
use arc_swap::ArcSwap;
use crate::core::detection::metrics::FusionMetrics;
use crate::interface::port::audit::{AuditLogEntry, AuditRepo};
/// Maximum audit rows scanned per explain request. Caps DB work in
/// case the audit chain grows large enough that a naive full-table
/// scan would be noticeable.
const FUSION_EXPLAIN_SCAN_LIMIT: i64 = 5_000;
/// Upper cap on entries returned to the client per explain request.
/// Guards against a UI rendering path that chokes on enormous JSON.
const FUSION_EXPLAIN_RESPONSE_CAP: usize = 200;
/// Stable audit action string the fusion engine emits — kept in sync
/// with `core::detection::orchestrator::FUSION_AUDIT_ACTION`. If that
/// constant changes, the explain endpoint silently returns nothing, so
/// keep this updated at the same time.
const FUSION_AUDIT_ACTION: &str = "fused_threat_emitted";
use crate::domain::common::audit::AuditLogEntry;
use crate::domain::common::config::AppConfig;
use crate::domain::common::config::constants::FUSION_AUDIT_ACTION;
use crate::interface::audit::AuditRepo;
pub fn initialize() -> Scope {
web::scope("/fusion")
@ -44,7 +33,11 @@ async fn get_metrics(metrics: web::Data<FusionMetrics>) -> impl Responder {
/// Scans the WORM audit chain for `fused_threat_emitted` entries that
/// match `src_ip`, returning them oldest-first so the UI can render a
/// chronological "why was this IP blocked" view.
async fn explain_ip(req: HttpRequest, audit: web::Data<dyn AuditRepo>) -> impl Responder {
async fn explain_ip(
req: HttpRequest,
audit: web::Data<dyn AuditRepo>,
app_config: web::Data<ArcSwap<AppConfig>>,
) -> impl Responder {
let src_ip = match req.match_info().get("src_ip") {
Some(ip) => ip.to_string(),
None => {
@ -54,7 +47,11 @@ async fn explain_ip(req: HttpRequest, audit: web::Data<dyn AuditRepo>) -> impl R
}
};
let entries = match audit.list_audit_logs_by_action(FUSION_AUDIT_ACTION, FUSION_EXPLAIN_SCAN_LIMIT) {
let obs = app_config.load().observability.clone();
let entries = match audit
.list_audit_logs_by_action(FUSION_AUDIT_ACTION, obs.fusion_explain_scan_limit)
.await
{
Ok(e) => e,
Err(e) => {
return HttpResponse::InternalServerError().json(serde_json::json!({
@ -63,7 +60,7 @@ async fn explain_ip(req: HttpRequest, audit: web::Data<dyn AuditRepo>) -> impl R
}
};
let (matches, truncated) = filter_fusion_evidence_for_ip(&entries, &src_ip, FUSION_EXPLAIN_RESPONSE_CAP);
let (matches, truncated) = filter_fusion_evidence_for_ip(&entries, &src_ip, obs.fusion_explain_response_cap);
HttpResponse::Ok().json(serde_json::json!({
"src_ip": src_ip,
"match_count": matches.len(),

View File

@ -1,11 +1,12 @@
use actix_web::{HttpResponse, Responder, Scope, web};
use tokio::sync::broadcast;
use crate::core::auth::extractor::AuthClaims;
use crate::core::ml::adapter::ModelSourceState;
use crate::core::ml::engine::Engine;
use crate::core::ml::inference::Inference;
use crate::infrastructure::communication_manager::CommunicationManager;
use crate::model::event::AuditEvent;
use crate::adapter::http::middleware::extractor::AuthClaims;
use crate::core::inference::engine::Engine;
use crate::core::inference::model_adapter::ModelSourceState;
use crate::core::inference::runner::Inference;
use crate::domain::common::config::constants::AUDIT_ACTOR_SECURITY_ADMIN_PREFIX;
use crate::domain::common::event::AuditEvent;
/// Permission required to forcibly revert the active ML source to dormant.
/// Mirrors the upload handler's gate so swap-out and revert are symmetric:
@ -13,11 +14,6 @@ use crate::model::event::AuditEvent;
/// detector silently while the upload path required `users:admin`.
const DORMANT_REQUIRED_PERMISSION: &str = "users:admin";
/// Actor prefix recorded on the WORM chain when an admin reverts the ML
/// source. Matches the prefix used by `model_swap` so downstream filters
/// see both events in the same admin-action stream.
const AUDIT_ACTOR_SECURITY_ADMIN_PREFIX: &str = "SecurityAdmin";
/// Action recorded on the WORM chain when the ML source is forced
/// dormant via this endpoint. Stable wire string — UI/audit tooling
/// filters on it, paired with `model_swap` from the upload path.
@ -48,7 +44,7 @@ async fn get_status(engine: web::Data<Engine>) -> impl Responder {
/// `GET /api/ml/models/current` — wire-format snapshot of the ML source
/// state the dashboard's ML Status panel renders.
async fn get_current_model(inference: web::Data<Inference>) -> impl Responder {
let status = inference.current_status();
let status = inference.model_source_status();
let label = if status.is_active() {
"active"
} else if status.is_dormant() {
@ -69,7 +65,7 @@ async fn get_current_model(inference: web::Data<Inference>) -> impl Responder {
/// the upload path's `model_swap` so both swap-in and revert are auditable.
async fn delete_current_model(
inference: web::Data<Inference>,
comm: web::Data<CommunicationManager>,
audit_tx: web::Data<broadcast::Sender<AuditEvent>>,
claims: AuthClaims,
) -> impl Responder {
if !claims.permissions.iter().any(|p| p == DORMANT_REQUIRED_PERMISSION) {
@ -78,7 +74,7 @@ async fn delete_current_model(
}));
}
let before_status = inference.current_status();
let before_status = inference.model_source_status();
if before_status.is_dormant() {
return HttpResponse::Ok().json(serde_json::json!({
"already_dormant": true,
@ -91,13 +87,11 @@ async fn delete_current_model(
"before": serde_json::to_value(&before_status).unwrap_or(serde_json::Value::Null),
})
.to_string();
let _ = comm
.publish_event(AuditEvent {
actor: format!("{AUDIT_ACTOR_SECURITY_ADMIN_PREFIX}@{}", claims.username),
action: AUDIT_ACTION_MODEL_DORMANT.to_string(),
detail: audit_detail,
})
.await;
let _ = audit_tx.send(AuditEvent {
actor: format!("{AUDIT_ACTOR_SECURITY_ADMIN_PREFIX}@{}", claims.username),
action: AUDIT_ACTION_MODEL_DORMANT.to_string(),
detail: audit_detail,
});
HttpResponse::Ok().json(serde_json::json!({
"already_dormant": false,

View File

@ -0,0 +1,7 @@
pub mod byo;
pub mod flow_trace;
pub mod fusion;
pub mod health;
pub mod ml;
pub mod model_upload;
pub mod stats;

View File

@ -15,33 +15,35 @@
//! down on any error path so failed uploads don't pile up in
//! `models/.staging/`.
use std::fs as std_fs;
use std::fs::File as StdFile;
use std::io;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, SystemTime};
use std::time::Duration;
use actix_multipart::Multipart;
use actix_web::{HttpResponse, Responder, Scope, web};
use arc_swap::ArcSwap;
use futures_util::TryStreamExt;
use serde_json::Value as JsonValue;
use sha2::{Digest, Sha256};
use tokio::fs;
use tokio::io::AsyncWriteExt;
use tokio::sync::broadcast;
use tokio::task;
use uuid::Uuid;
use crate::core::auth::extractor::AuthClaims;
use crate::core::ml::inference::Inference;
use crate::core::ml::manifest::{AdapterKind, ModelManifest};
use crate::core::ml::model_loader::build_adapter;
use crate::infrastructure::app_config::AppConfig;
use crate::infrastructure::communication_manager::CommunicationManager;
use crate::model::config::constants::{MANIFEST_FILENAME, MODELS_DIR, STAGING_SUBDIR};
use crate::model::event::AuditEvent;
use crate::model::system::config::MLInferenceConfig;
use crate::adapter::http::middleware::extractor::AuthClaims;
use crate::core::inference::model_loader::build_adapter;
use crate::core::inference::runner::Inference;
use crate::domain::common::config::AppConfig;
use crate::domain::common::config::constants::{
AUDIT_ACTOR_SECURITY_ADMIN_PREFIX, MANIFEST_FILENAME, MODELS_DIR, STAGING_SUBDIR,
};
use crate::domain::common::event::AuditEvent;
use crate::domain::detection::manifest::{AdapterKind, ModelManifest};
use crate::domain::detection::ml_inference_config::MLInferenceConfig;
/// Multipart field names the client must use. Stable wire contract —
/// the frontend form generator depends on these exact strings.
@ -61,14 +63,6 @@ const ONNX_SNIFF_BYTES: usize = 16;
/// swap the ML source.
const PROMOTE_REQUIRED_PERMISSION: &str = "users:admin";
/// Actor prefix on the WORM `model_swap` audit entry. Administrators
/// that trigger the upload endpoint land on the chain as
/// `SecurityAdmin@<username>` so downstream filters can separate
/// system-driven entries (actor="system") from human-driven ones
/// without parsing free-form text. Stable across releases — renaming
/// breaks downstream audit tooling that filters on this prefix.
const AUDIT_ACTOR_SECURITY_ADMIN_PREFIX: &str = "SecurityAdmin";
/// Action recorded on the WORM chain when a promote succeeds. Stable
/// wire string — fusion-explain tooling and future "who swapped the
/// model" views filter on it, so the rename must go through the audit
@ -136,9 +130,9 @@ pub fn initialize() -> Scope {
/// pre-swap ML source state. The staging directory is always torn
/// down on the way out, even on success (post-promote it's empty).
async fn upload(
app_config: web::Data<AppConfig>,
app_config: web::Data<ArcSwap<AppConfig>>,
inference: web::Data<Inference>,
comm: web::Data<CommunicationManager>,
audit_tx: web::Data<broadcast::Sender<AuditEvent>>,
promote_lock: web::Data<PromoteGate>,
claims: AuthClaims,
payload: Multipart,
@ -153,11 +147,15 @@ async fn upload(
let staging_id = Uuid::new_v4().to_string();
let staging_dir = staging_root.join(&staging_id);
let config = app_config.load();
let caps = UploadCaps {
manifest: app_config.inference.model_upload_max_manifest_bytes,
onnx: app_config.inference.model_upload_max_onnx_bytes,
scaler: app_config.inference.model_upload_max_scaler_bytes,
manifest: config.ml.model_upload_max_manifest_bytes,
onnx: config.ml.model_upload_max_onnx_bytes,
scaler: config.ml.model_upload_max_scaler_bytes,
};
let batch_size = config.ml.inference_batch_size;
let onnx_load_timeout = Duration::from_secs(config.ml.onnx_load_timeout_secs);
drop(config);
let summary = match ingest_multipart(payload, &staging_dir, caps).await {
Ok(s) => s,
Err(e) => {
@ -165,17 +163,16 @@ async fn upload(
return e.into_response();
}
};
let batch_size = app_config.inference.inference_batch_size;
let outcome = validate_and_promote(
&staging_dir,
&summary,
inference.get_ref(),
comm.get_ref(),
promote_lock.get_ref(),
&claims.username,
let outcome = validate_and_promote(&PromoteContext {
staging_dir: &staging_dir,
summary: &summary,
inference: inference.get_ref(),
audit_tx: audit_tx.get_ref(),
promote_lock: promote_lock.get_ref(),
actor_username: &claims.username,
batch_size,
)
onnx_load_timeout,
})
.await;
// Always sweep staging — successful promote renames the files out,
@ -514,15 +511,26 @@ pub fn looks_like_onnx(first_chunk: &[u8]) -> bool {
/// logged but does not roll back the rename; the chain prefers a
/// missing audit entry to a rolled-back promote that a downstream
/// subscriber may already have reacted to.
async fn validate_and_promote(
staging_dir: &Path,
summary: &UploadSummary,
inference: &Inference,
comm: &CommunicationManager,
promote_lock: &PromoteGate,
actor_username: &str,
struct PromoteContext<'a> {
staging_dir: &'a Path,
summary: &'a UploadSummary,
inference: &'a Inference,
audit_tx: &'a broadcast::Sender<AuditEvent>,
promote_lock: &'a PromoteGate,
actor_username: &'a str,
batch_size: usize,
) -> Result<PromoteReport, PromoteError> {
onnx_load_timeout: Duration,
}
async fn validate_and_promote(ctx: &PromoteContext<'_>) -> Result<PromoteReport, PromoteError> {
let staging_dir = ctx.staging_dir;
let summary = ctx.summary;
let inference = ctx.inference;
let audit_tx = ctx.audit_tx;
let promote_lock = ctx.promote_lock;
let actor_username = ctx.actor_username;
let batch_size = ctx.batch_size;
let onnx_load_timeout = ctx.onnx_load_timeout;
let staging_manifest = staging_dir.join(MANIFEST_FILENAME);
// Structural manifest validation. The full `build_adapter` pipeline
@ -558,8 +566,14 @@ async fn validate_and_promote(
// features, tract optimize+runnable under the 5s load budget.
let (config, manifest) = MLInferenceConfig::from_manifest_with_sidecar(&staging_manifest)
.map_err(|e| PromoteError::ValidationFailed(e.to_string()))?;
let _adapter = build_adapter(&manifest, Some(&staging_manifest), &config, batch_size)
.map_err(|e| PromoteError::ValidationFailed(e.to_string()))?;
let _adapter = build_adapter(
&manifest,
Some(&staging_manifest),
&config,
batch_size,
onnx_load_timeout,
)
.map_err(|e| PromoteError::ValidationFailed(e.to_string()))?;
let manifest_sha256 = sha256_file(&staging_manifest)
.await
@ -568,28 +582,34 @@ async fn validate_and_promote(
.await
.map_err(|e| PromoteError::StagingIo(format!("sha256 onnx: {e}")))?;
let before_status = inference.current_status();
let before_status = inference.model_source_status();
let _guard = promote_lock.try_acquire().ok_or(PromoteError::ConcurrentPromote)?;
let models_dir = PathBuf::from(MODELS_DIR);
let target_onnx = models_dir.join(&declared_onnx);
fs::rename(&staged_onnx, &target_onnx)
.await
.map_err(|e| PromoteError::PromoteIo(format!("rename onnx into models/: {e}")))?;
if let Some(ref pp) = manifest.preprocessing {
validate_manifest_basename("preprocessing.scaler_sidecar", &pp.scaler_sidecar)?;
let src = staging_dir.join(&pp.scaler_sidecar);
let dst = models_dir.join(&pp.scaler_sidecar);
fs::rename(&src, &dst)
.await
.map_err(|e| PromoteError::PromoteIo(format!("rename sidecar into models/: {e}")))?;
}
let target_manifest = models_dir.join(MANIFEST_FILENAME);
fs::rename(&staging_manifest, &target_manifest)
.await
.map_err(|e| PromoteError::PromoteIo(format!("rename manifest into models/: {e}")))?;
let staged_sidecar = if let Some(ref pp) = manifest.preprocessing {
validate_manifest_basename("preprocessing.scaler_sidecar", &pp.scaler_sidecar)?;
Some(staging_dir.join(&pp.scaler_sidecar))
} else {
None
};
let target_sidecar = manifest
.preprocessing
.as_ref()
.map(|pp| models_dir.join(&pp.scaler_sidecar));
let _guard = promote_lock.try_acquire().ok_or(PromoteError::ConcurrentPromote)?;
let backup_dir = models_dir.join(format!(".promote-backup-{}", Uuid::new_v4()));
promote_files_atomically(&PromoteFileSet {
staging_manifest: staging_manifest.clone(),
staged_onnx,
staged_sidecar,
target_manifest,
target_onnx,
target_sidecar,
backup_dir,
})
.await?;
drop(_guard);
let audit_detail = serde_json::json!({
@ -600,13 +620,11 @@ async fn validate_and_promote(
"before": serde_json::to_value(&before_status).unwrap_or(JsonValue::Null),
})
.to_string();
let _ = comm
.publish_event(AuditEvent {
actor: format!("{AUDIT_ACTOR_SECURITY_ADMIN_PREFIX}@{actor_username}"),
action: AUDIT_ACTION_MODEL_SWAP.to_string(),
detail: audit_detail,
})
.await;
let _ = audit_tx.send(AuditEvent {
actor: format!("{AUDIT_ACTOR_SECURITY_ADMIN_PREFIX}@{actor_username}"),
action: AUDIT_ACTION_MODEL_SWAP.to_string(),
detail: audit_detail,
});
Ok(PromoteReport {
manifest_name: manifest.name,
@ -616,6 +634,105 @@ async fn validate_and_promote(
})
}
struct PromoteFileSet {
staging_manifest: PathBuf,
staged_onnx: PathBuf,
staged_sidecar: Option<PathBuf>,
target_manifest: PathBuf,
target_onnx: PathBuf,
target_sidecar: Option<PathBuf>,
backup_dir: PathBuf,
}
async fn promote_files_atomically(files: &PromoteFileSet) -> Result<(), PromoteError> {
fs::create_dir_all(&files.backup_dir)
.await
.map_err(|e| PromoteError::PromoteIo(format!("create promote backup dir: {e}")))?;
let backup_manifest = backup_existing(&files.target_manifest, &files.backup_dir, "manifest.yaml").await?;
let backup_onnx = backup_existing(&files.target_onnx, &files.backup_dir, "model.onnx").await?;
let backup_sidecar = match &files.target_sidecar {
Some(target) => Some(backup_existing(target, &files.backup_dir, "sidecar").await?),
None => None,
};
let result = async {
move_file(&files.staged_onnx, &files.target_onnx, "rename onnx into models/").await?;
if let (Some(src), Some(dst)) = (&files.staged_sidecar, &files.target_sidecar) {
move_file(src, dst, "rename sidecar into models/").await?;
}
move_file(
&files.staging_manifest,
&files.target_manifest,
"rename manifest into models/",
)
.await
}
.await;
match result {
Ok(()) => {
let _ = fs::remove_dir_all(&files.backup_dir).await;
Ok(())
}
Err(err) => {
rollback_promote(files, backup_manifest, backup_onnx, backup_sidecar).await;
let _ = fs::remove_dir_all(&files.backup_dir).await;
Err(err)
}
}
}
async fn backup_existing(target: &Path, backup_dir: &Path, backup_name: &str) -> Result<Option<PathBuf>, PromoteError> {
if !target
.try_exists()
.map_err(|e| PromoteError::PromoteIo(format!("check existing target {}: {e}", target.display())))?
{
return Ok(None);
}
let backup = backup_dir.join(backup_name);
fs::rename(target, &backup)
.await
.map_err(|e| PromoteError::PromoteIo(format!("backup existing target {}: {e}", target.display())))?;
Ok(Some(backup))
}
async fn move_file(src: &Path, dst: &Path, op: &str) -> Result<(), PromoteError> {
fs::rename(src, dst)
.await
.map_err(|e| PromoteError::PromoteIo(format!("{op}: {e}")))
}
async fn rollback_promote(
files: &PromoteFileSet,
backup_manifest: Option<PathBuf>,
backup_onnx: Option<PathBuf>,
backup_sidecar: Option<Option<PathBuf>>,
) {
remove_if_exists(&files.target_manifest).await;
remove_if_exists(&files.target_onnx).await;
if let Some(target) = &files.target_sidecar {
remove_if_exists(target).await;
}
restore_backup(backup_manifest, &files.target_manifest).await;
restore_backup(backup_onnx, &files.target_onnx).await;
if let (Some(backup), Some(target)) = (backup_sidecar.flatten(), &files.target_sidecar) {
restore_backup(Some(backup), target).await;
}
}
async fn remove_if_exists(path: &Path) {
if let Ok(true) = path.try_exists() {
let _ = fs::remove_file(path).await;
}
}
async fn restore_backup(backup: Option<PathBuf>, target: &Path) {
if let Some(backup) = backup {
let _ = fs::rename(backup, target).await;
}
}
/// Metadata surfaced back to the client when the promote succeeds.
#[derive(Debug)]
struct PromoteReport {
@ -692,36 +809,12 @@ async fn sha256_file(path: &Path) -> io::Result<String> {
.unwrap_or_else(|e| Err(io::Error::other(format!("sha256 join: {e}"))))
}
/// Remove staging subdirectories older than `max_age`. Runs on startup
/// and on a periodic timer so failed uploads don't accumulate.
pub fn clean_staging_orphans(staging_root: &Path, max_age: Duration) -> io::Result<usize> {
if !staging_root.exists() {
return Ok(0);
}
let now = SystemTime::now();
let mut cleaned = 0usize;
for entry in std_fs::read_dir(staging_root)? {
let entry = entry?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let metadata = entry.metadata()?;
let mtime = metadata.modified()?;
let age = now.duration_since(mtime).unwrap_or_default();
if age >= max_age {
std_fs::remove_dir_all(&path)?;
cleaned += 1;
}
}
Ok(cleaned)
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::*;
use crate::utils::staging::clean_staging_orphans;
#[test]
fn onnx_sniff_rejects_empty() {
@ -870,6 +963,48 @@ mod tests {
assert_eq!(resp.status().as_u16(), 500);
}
#[tokio::test]
async fn promote_files_rolls_back_active_files_when_sidecar_move_fails() {
let tmp = std::env::temp_dir().join(format!("nguardia-promote-rollback-{}", Uuid::new_v4()));
let staging = tmp.join("staging");
let models = tmp.join("models");
let backup = models.join(".promote-backup-test");
fs::create_dir_all(&staging).await.unwrap();
fs::create_dir_all(&models).await.unwrap();
let staging_manifest = staging.join(MANIFEST_FILENAME);
let staged_onnx = staging.join("model.onnx");
let missing_sidecar = staging.join("missing-scaler.json");
let target_manifest = models.join(MANIFEST_FILENAME);
let target_onnx = models.join("model.onnx");
let target_sidecar = models.join("scaler.json");
fs::write(&staging_manifest, b"new manifest").await.unwrap();
fs::write(&staged_onnx, b"new onnx").await.unwrap();
fs::write(&target_manifest, b"old manifest").await.unwrap();
fs::write(&target_onnx, b"old onnx").await.unwrap();
fs::write(&target_sidecar, b"old sidecar").await.unwrap();
let err = promote_files_atomically(&PromoteFileSet {
staging_manifest: staging_manifest.clone(),
staged_onnx,
staged_sidecar: Some(missing_sidecar),
target_manifest: target_manifest.clone(),
target_onnx: target_onnx.clone(),
target_sidecar: Some(target_sidecar.clone()),
backup_dir: backup,
})
.await
.expect_err("missing sidecar should fail promote");
assert!(matches!(err, PromoteError::PromoteIo(_)));
assert_eq!(fs::read(&target_manifest).await.unwrap(), b"old manifest");
assert_eq!(fs::read(&target_onnx).await.unwrap(), b"old onnx");
assert_eq!(fs::read(&target_sidecar).await.unwrap(), b"old sidecar");
fs::remove_dir_all(&tmp).await.ok();
}
#[test]
fn upload_error_scaler_too_large_maps_to_413_and_echoes_cap() {
let resp = UploadError::ScalerTooLarge(1234).into_response();

View File

@ -1,7 +1,7 @@
use actix_web::{HttpResponse, Responder, Scope, web};
use crate::adapter::ebpf::drop_monitor::DropMonitor;
use crate::infrastructure::statistics::FlowStatistics;
use crate::core::common::statistics::FlowStatistics;
use crate::interface::drop_stats::DropStatsPort;
pub fn initialize() -> Scope {
web::scope("/stats")
@ -24,6 +24,6 @@ async fn get_summary(stats: web::Data<FlowStatistics>) -> impl Responder {
HttpResponse::Ok().json(stats.get_summary())
}
async fn get_drop_stats(monitor: web::Data<DropMonitor>) -> impl Responder {
async fn get_drop_stats(monitor: web::Data<dyn DropStatsPort>) -> impl Responder {
HttpResponse::Ok().json(monitor.get_counters())
}

View File

@ -1,259 +0,0 @@
use std::fmt;
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddrV4, SocketAddrV6};
use actix_web::{HttpResponse, Responder, Scope, web};
use common::model::http_method::HttpMethod;
use serde::Deserialize;
use crate::adapter::ebpf::protocol_filter::ProtocolFilter;
use crate::core::dns_filter_service::DnsFilterService;
/// Convert a fallible result into an Ok (200) or InternalServerError (500) response.
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()})),
}
}
pub fn initialize() -> Scope {
web::scope("/filter")
.service(http_scope())
.service(ssh_scope())
.service(dns_scope())
}
#[derive(Deserialize)]
struct DnsDomainsPayload {
domains: Vec<String>,
}
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)),
)
}
async fn get_dns_blacklist(service: web::Data<DnsFilterService>) -> impl Responder {
HttpResponse::Ok().json(serde_json::json!({"domains": service.list_domains()}))
}
async fn add_dns_blacklist(
payload: web::Json<DnsDomainsPayload>,
service: web::Data<DnsFilterService>,
) -> impl Responder {
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()})),
}
}
async fn remove_dns_blacklist(
payload: web::Json<DnsDomainsPayload>,
service: web::Data<DnsFilterService>,
) -> impl Responder {
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()})),
}
}
fn http_scope() -> Scope {
web::scope("/http")
.route("/ipv4", web::get().to(get_ipv4_http_service))
.route("/ipv6", web::get().to(get_ipv6_http_service))
.route("/ipv4", web::put().to(add_ipv4_http_service))
.route("/ipv6", web::put().to(add_ipv6_http_service))
.route("/ipv4", web::delete().to(remove_ipv4_http_service))
.route("/ipv6", web::delete().to(remove_ipv6_http_service))
}
fn ssh_scope() -> Scope {
web::scope("/ssh")
.route("/ipv4", web::get().to(get_ipv4_ssh_service))
.route("/ipv6", web::get().to(get_ipv6_ssh_service))
.route("/ipv4", web::put().to(add_ipv4_ssh_service))
.route("/ipv6", web::put().to(add_ipv6_ssh_service))
.route("/ipv4", web::delete().to(remove_ipv4_ssh_service))
.route("/ipv6", web::delete().to(remove_ipv6_ssh_service))
.service(ssh_whitelist_scope())
.service(ssh_blacklist_scope())
}
fn ssh_whitelist_scope() -> Scope {
web::scope("/whitelist")
.route("/status", web::get().to(is_ssh_white_list_enable))
.route("/enable", web::post().to(enable_ssh_white_list))
.route("/disable", web::post().to(disable_ssh_white_list))
.route("/ipv4", web::get().to(get_ipv4_ssh_white_list))
.route("/ipv6", web::get().to(get_ipv6_ssh_white_list))
.route("/ipv4", web::put().to(add_ipv4_ssh_white_list))
.route("/ipv6", web::put().to(add_ipv6_ssh_white_list))
.route("/ipv4", web::delete().to(remove_ipv4_ssh_white_list))
.route("/ipv6", web::delete().to(remove_ipv6_ssh_white_list))
}
fn ssh_blacklist_scope() -> Scope {
web::scope("/blacklist")
.route("/ipv4", web::get().to(get_ipv4_ssh_black_list))
.route("/ipv6", web::get().to(get_ipv6_ssh_black_list))
.route("/ipv4", web::put().to(add_ipv4_ssh_black_list))
.route("/ipv6", web::put().to(add_ipv6_ssh_black_list))
.route("/ipv4", web::delete().to(remove_ipv4_ssh_black_list))
.route("/ipv6", web::delete().to(remove_ipv6_ssh_black_list))
}
// --- HTTP service handlers ---
async fn get_ipv4_http_service(service: web::Data<ProtocolFilter>) -> impl Responder {
HttpResponse::Ok().json(service.get_ipv4_http_service())
}
async fn get_ipv6_http_service(service: web::Data<ProtocolFilter>) -> impl Responder {
HttpResponse::Ok().json(service.get_ipv6_http_service())
}
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))
}
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))
}
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))
}
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))
}
// --- SSH service handlers ---
async fn get_ipv4_ssh_service(service: web::Data<ProtocolFilter>) -> impl Responder {
HttpResponse::Ok().json(service.get_ipv4_ssh_service())
}
async fn get_ipv6_ssh_service(service: web::Data<ProtocolFilter>) -> impl Responder {
HttpResponse::Ok().json(service.get_ipv6_ssh_service())
}
async fn add_ipv4_ssh_service(ip_addr: web::Json<SocketAddrV4>, service: web::Data<ProtocolFilter>) -> impl Responder {
ok_or_error(service.add_ipv4_ssh_service(ip_addr.into_inner()))
}
async fn add_ipv6_ssh_service(ip_addr: web::Json<SocketAddrV6>, service: web::Data<ProtocolFilter>) -> impl Responder {
ok_or_error(service.add_ipv6_ssh_service(ip_addr.into_inner()))
}
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()))
}
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()))
}
// --- SSH whitelist handlers ---
async fn is_ssh_white_list_enable(service: web::Data<ProtocolFilter>) -> impl Responder {
HttpResponse::Ok().json(service.is_ssh_white_list_enable())
}
async fn enable_ssh_white_list(service: web::Data<ProtocolFilter>) -> impl Responder {
ok_or_error(service.enable_ssh_white_list())
}
async fn disable_ssh_white_list(service: web::Data<ProtocolFilter>) -> impl Responder {
ok_or_error(service.disable_ssh_white_list())
}
async fn get_ipv4_ssh_white_list(service: web::Data<ProtocolFilter>) -> impl Responder {
HttpResponse::Ok().json(service.get_ipv4_ssh_white_list())
}
async fn get_ipv6_ssh_white_list(service: web::Data<ProtocolFilter>) -> impl Responder {
HttpResponse::Ok().json(service.get_ipv6_ssh_white_list())
}
async fn add_ipv4_ssh_white_list(ip_addr: web::Json<Ipv4Addr>, service: web::Data<ProtocolFilter>) -> impl Responder {
ok_or_error(service.add_ipv4_ssh_white_list(ip_addr.into_inner()))
}
async fn add_ipv6_ssh_white_list(ip_addr: web::Json<Ipv6Addr>, service: web::Data<ProtocolFilter>) -> impl Responder {
ok_or_error(service.add_ipv6_ssh_white_list(ip_addr.into_inner()))
}
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()))
}
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()))
}
// --- SSH blacklist handlers ---
async fn get_ipv4_ssh_black_list(service: web::Data<ProtocolFilter>) -> impl Responder {
HttpResponse::Ok().json(service.get_ipv4_ssh_black_list())
}
async fn get_ipv6_ssh_black_list(service: web::Data<ProtocolFilter>) -> impl Responder {
HttpResponse::Ok().json(service.get_ipv6_ssh_black_list())
}
async fn add_ipv4_ssh_black_list(ip_addr: web::Json<Ipv4Addr>, service: web::Data<ProtocolFilter>) -> impl Responder {
ok_or_error(service.add_ipv4_ssh_black_list(ip_addr.into_inner()))
}
async fn add_ipv6_ssh_black_list(ip_addr: web::Json<Ipv6Addr>, service: web::Data<ProtocolFilter>) -> impl Responder {
ok_or_error(service.add_ipv6_ssh_black_list(ip_addr.into_inner()))
}
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()))
}
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()))
}

View File

@ -0,0 +1,18 @@
use std::fmt;
use actix_web::HttpResponse;
use serde::Serialize;
pub 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()})),
}
}
pub fn ok_json_or_error<T: Serialize, E: fmt::Display>(result: Result<T, E>) -> HttpResponse {
match result {
Ok(value) => HttpResponse::Ok().json(value),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
}

View File

@ -1,8 +1,9 @@
use actix_web::{HttpResponse, Scope, web};
use serde::Deserialize;
use crate::core::auth::extractor::AuthClaims;
use crate::interface::port::api_key::ApiKeyRepo;
use crate::adapter::http::middleware::extractor::AuthClaims;
use crate::domain::identity::auth::PermissionLevel;
use crate::interface::api_key::ApiKeyRepo;
pub fn initialize() -> Scope {
web::scope("/api-keys")
@ -12,17 +13,17 @@ pub fn initialize() -> Scope {
}
async fn list_keys(_auth: AuthClaims, db: web::Data<dyn ApiKeyRepo>) -> HttpResponse {
match db.list_api_keys() {
match db.list_api_keys().await {
Ok(keys) => {
let responses: Vec<serde_json::Value> = keys
.into_iter()
.map(|(id, name, level, created, last_used)| {
.map(|k| {
serde_json::json!({
"id": id,
"name": name,
"permission_level": level,
"created_at": created,
"last_used_at": last_used,
"id": k.id,
"name": k.name,
"permission_level": k.permission_level,
"created_at": k.created_at,
"last_used_at": k.last_used_at,
})
})
.collect();
@ -54,19 +55,19 @@ async fn generate_key(
let key_hash = db.hmac_api_key(&raw_key);
let level = body.level.as_deref().unwrap_or("read_only");
if !matches!(level, "read_only" | "read_write" | "full_access") {
let raw_level = body.level.as_deref().unwrap_or("read_only");
let Some(level) = PermissionLevel::from_str(raw_level) else {
return HttpResponse::BadRequest().json(serde_json::json!({
"error": "Invalid permission level. Must be: read_only, read_write, or full_access"
}));
}
};
match db.insert_api_key(&key_hash, &body.name, level) {
match db.insert_api_key(&key_hash, &body.name, level.as_str()).await {
Ok(id) => HttpResponse::Created().json(serde_json::json!({
"id": id,
"key": raw_key,
"name": body.name,
"permission_level": level,
"permission_level": level.as_str(),
})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
@ -74,7 +75,7 @@ async fn generate_key(
async fn delete_key(_auth: AuthClaims, db: web::Data<dyn ApiKeyRepo>, path: web::Path<i64>) -> HttpResponse {
let id = path.into_inner();
match db.delete_api_key(id) {
match db.delete_api_key(id).await {
Ok(true) => HttpResponse::Ok().json(serde_json::json!({"deleted": true})),
Ok(false) => HttpResponse::NotFound().json(serde_json::json!({"error": "Key not found"})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),

View File

@ -1,15 +1,20 @@
use actix_web::{HttpResponse, Responder, Scope, web};
use macros::log;
use serde::Deserialize;
use crate::core::auth::extractor::AuthClaims;
use crate::core::auth::jwt::JwtService;
use crate::core::auth::password;
use crate::interface::port::app_repo::AppRepo;
use crate::model::error::auth::AuthError;
use crate::adapter::http::helpers::ok_or_error;
use crate::adapter::http::middleware::extractor::AuthClaims;
use crate::core::identity::auth_service::{AuthService, LoginError, RegisterError};
use crate::domain::identity::auth::{DEFAULT_ADMIN_USERNAME, GROUP_ADMIN, GROUP_VIEWER, ROLE_ADMIN, ROLE_VIEWER};
use crate::domain::identity::password;
use crate::domain::identity::validation::validate_password;
use crate::interface::app_repo::AppRepo;
type Repo = dyn AppRepo;
fn parse_permissions(raw: &str) -> serde_json::Value {
serde_json::from_str(raw).unwrap_or(serde_json::json!([]))
}
#[derive(Deserialize)]
struct LoginRequest {
username: String,
@ -47,157 +52,50 @@ pub fn initialize() -> Scope {
.route("/groups/{id}", web::delete().to(delete_group))
}
fn validate_username(username: &str) -> Result<(), &'static str> {
if username.is_empty() || username.len() > 32 {
return Err("Username must be 1-32 characters");
}
if !username.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
return Err("Username must contain only alphanumeric characters and underscores");
}
Ok(())
}
fn validate_password(password: &str) -> Result<(), &'static str> {
if password.len() < 8 {
return Err("Password must be at least 8 characters");
}
Ok(())
}
/// Dummy Argon2 hash used to prevent timing-based username enumeration.
/// When a user doesn't exist, we still run verify_password against this
/// 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>, auth_svc: web::Data<AuthService>) -> 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,
}));
}
Err(_) => {}
Ok(None) => {}
}
let user = match db.find_user(&req.username) {
Ok(Some(u)) => u,
_ => {
// Run dummy hash verification to prevent timing-based username enumeration
let _ = password::verify_password(&req.password, DUMMY_HASH);
if let Err(e) = db.record_login_failure(&req.username) {
log!(AuthError::LoginFailureTrackingError(e));
}
return HttpResponse::Unauthorized().json(serde_json::json!({"error": "Invalid credentials"}));
}
};
let (id, username, hash, _db_role, force_password_change) = user;
match password::verify_password(&req.password, &hash) {
Ok(true) => {}
_ => {
if let Err(e) = db.record_login_failure(&req.username) {
log!(AuthError::LoginFailureTrackingError(e));
}
return HttpResponse::Unauthorized().json(serde_json::json!({"error": "Invalid credentials"}));
}
}
// Clear login failures on success
if let Err(e) = db.clear_login_failures(&req.username) {
log!(AuthError::LoginClearError(e));
}
// Permissions come exclusively from groups — no role-based fallback
let permissions = db.get_user_permissions(id).unwrap_or_default();
let groups = db.get_user_groups(id).unwrap_or_default();
let role = if groups.iter().any(|(_id, name, _desc, _perms)| name == "Administrator") {
"admin".to_string()
} else {
"viewer".to_string()
};
match jwt.create_token(id, &username, &role, permissions) {
Ok(token) => HttpResponse::Ok().json(serde_json::json!({
"token": token,
"role": role,
"force_password_change": force_password_change,
match auth_svc.login(&req.username, &req.password).await {
Ok(result) => HttpResponse::Ok().json(result),
Err(LoginError::Locked { retry_after_secs }) => HttpResponse::TooManyRequests().json(serde_json::json!({
"error": "Account temporarily locked due to too many failed login attempts",
"retry_after_secs": retry_after_secs,
})),
Err(_) => HttpResponse::InternalServerError().json(serde_json::json!({"error": "Failed to create token"})),
Err(LoginError::InvalidCredentials) => {
HttpResponse::Unauthorized().json(serde_json::json!({"error": "Invalid credentials"}))
}
Err(LoginError::InternalError) => {
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>,
auth_svc: web::Data<AuthService>,
) -> impl Responder {
let reg = body.into_inner();
// Validate input
if let Err(msg) = validate_username(&reg.username) {
return HttpResponse::BadRequest().json(serde_json::json!({"error": msg}));
}
if let Err(msg) = validate_password(&reg.password) {
return HttpResponse::BadRequest().json(serde_json::json!({"error": msg}));
}
// Validate role
if reg.role != "admin" && reg.role != "viewer" {
return HttpResponse::BadRequest().json(serde_json::json!({"error": "Role must be 'admin' or 'viewer'"}));
}
// Only admins can create admin accounts
if reg.role == "admin" && auth.role != "admin" {
return HttpResponse::Forbidden()
.json(serde_json::json!({"error": "Only administrators can create admin accounts"}));
}
let hash = match password::hash_password(&reg.password) {
Ok(h) => h,
Err(_) => {
return HttpResponse::InternalServerError().json(serde_json::json!({"error": "Failed to hash password"}));
match auth_svc
.register(&reg.username, &reg.password, &reg.role, &auth.role)
.await
{
Ok(_) => HttpResponse::Created().json(serde_json::json!({"username": reg.username, "role": reg.role})),
Err(RegisterError::Validation(msg)) => HttpResponse::BadRequest().json(serde_json::json!({"error": msg})),
Err(RegisterError::InvalidRole) => {
HttpResponse::BadRequest().json(serde_json::json!({"error": "Role must be 'admin' or 'viewer'"}))
}
};
match db.insert_user(&reg.username, &hash, &reg.role, false) {
Ok(new_user_id) => {
// 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 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(RegisterError::Forbidden) => HttpResponse::Forbidden()
.json(serde_json::json!({"error": "Only administrators can create admin accounts"})),
Err(RegisterError::HashFailed) => {
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Failed to hash password"}))
}
Err(e) => HttpResponse::Conflict().json(serde_json::json!({"error": e.to_string()})),
Err(RegisterError::Conflict(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()
.map(|(_id, name, _desc, _perms)| name.clone())
.collect();
let role = if group_names.iter().any(|n| n == "Administrator") {
"admin"
} else {
"viewer"
};
let permissions = db.get_user_permissions(auth.sub).unwrap_or_default();
HttpResponse::Ok().json(serde_json::json!({
"id": auth.sub,
"username": auth.username,
"role": role,
"permissions": permissions,
"groups": group_names,
}))
async fn me(auth: AuthClaims, auth_svc: web::Data<AuthService>) -> impl Responder {
let profile = auth_svc.user_profile(auth.sub, &auth.username).await;
HttpResponse::Ok().json(profile)
}
async fn change_password(
@ -205,32 +103,26 @@ async fn change_password(
body: web::Json<ChangePasswordRequest>,
db: web::Data<Repo>,
) -> impl Responder {
let claims = &*auth;
let change_req = body.into_inner();
// Validate new password
if let Err(msg) = validate_password(&change_req.new_password) {
return HttpResponse::BadRequest().json(serde_json::json!({"error": msg}));
}
// Verify current password
let user = match db.find_user(&claims.username) {
let user = match db.find_user(&auth.username).await {
Ok(Some(u)) => u,
_ => {
return HttpResponse::InternalServerError().json(serde_json::json!({"error": "User not found"}));
}
};
let (_id, _username, hash, _role, _force) = user;
match password::verify_password(&change_req.current_password, &hash) {
match password::verify_password(&change_req.current_password, &user.password_hash) {
Ok(true) => {}
_ => {
return HttpResponse::Unauthorized().json(serde_json::json!({"error": "Current password is incorrect"}));
}
}
// Hash and update
let new_hash = match password::hash_password(&change_req.new_password) {
Ok(h) => h,
Err(_) => {
@ -238,35 +130,34 @@ async fn change_password(
}
};
match db.update_user_password(claims.sub, &new_hash) {
match db.update_user_password(auth.sub, &new_hash).await {
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 {
match db.list_users_with_groups() {
match db.list_users_with_groups().await {
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
.map(|u| {
let groups: Vec<serde_json::Value> = u
.groups
.iter()
.map(|(gid, name)| serde_json::json!({"id": gid, "name": name}))
.map(|g| serde_json::json!({"id": g.group_id, "name": g.group_name}))
.collect();
let role = if user_groups.iter().any(|(_id, name)| name == "Administrator") {
"admin"
let role = if u.groups.iter().any(|g| g.group_name == GROUP_ADMIN) {
ROLE_ADMIN
} else {
"viewer"
ROLE_VIEWER
};
serde_json::json!({
"id": id,
"username": username,
"id": u.id,
"username": u.username,
"role": role,
"force_password_change": force_pw,
"created_at": created_at,
"force_password_change": u.force_password_change,
"created_at": u.created_at,
"groups": groups,
})
})
@ -280,21 +171,19 @@ async fn list_users(_auth: AuthClaims, 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"}));
}
// Protect the built-in admin account
match db.find_user_by_id(user_id) {
Ok(Some((_, ref username, _, _, _))) if username == "admin" => {
match db.find_user_by_id(user_id).await {
Ok(Some(ref u)) if u.username == DEFAULT_ADMIN_USERNAME => {
return HttpResponse::Forbidden()
.json(serde_json::json!({"error": "Cannot delete the built-in admin account"}));
}
_ => {}
}
match db.delete_user(user_id) {
match db.delete_user(user_id).await {
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()})),
@ -309,20 +198,18 @@ async fn update_role(
) -> impl Responder {
let user_id = path.into_inner();
// Can't change own role
if _auth.sub == user_id {
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,
Some(r) if r == ROLE_ADMIN || r == ROLE_VIEWER => r,
_ => {
return HttpResponse::BadRequest().json(serde_json::json!({"error": "Role must be 'admin' or 'viewer'"}));
}
};
// Check target user exists
match db.find_user_by_id(user_id) {
match db.find_user_by_id(user_id).await {
Ok(Some(_)) => {}
Ok(None) => {
return HttpResponse::NotFound().json(serde_json::json!({"error": "User not found"}));
@ -332,7 +219,7 @@ async fn update_role(
}
}
match db.update_user_role(user_id, role) {
match db.update_user_role(user_id, role).await {
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()})),
}
@ -361,8 +248,7 @@ async fn reset_password(
return HttpResponse::BadRequest().json(serde_json::json!({"error": msg}));
}
// Check target user exists
match db.find_user_by_id(user_id) {
match db.find_user_by_id(user_id).await {
Ok(Some(_)) => {}
Ok(None) => {
return HttpResponse::NotFound().json(serde_json::json!({"error": "User not found"}));
@ -379,37 +265,31 @@ async fn reset_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_or_error(db.reset_user_password(user_id, &hash).await)
}
// --- User Group Management (users:admin required) ---
async fn list_groups(_auth: AuthClaims, db: web::Data<Repo>) -> impl Responder {
match db.list_user_groups() {
match db.list_user_groups().await {
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,
})
})
.collect();
let mut result = Vec::with_capacity(groups.len());
for g in groups {
let perms: serde_json::Value = parse_permissions(&g.permissions);
let members: Vec<serde_json::Value> = db
.list_group_members(g.id)
.await
.unwrap_or_default()
.into_iter()
.map(|m| serde_json::json!({"id": m.id, "username": m.username}))
.collect();
result.push(serde_json::json!({
"id": g.id,
"name": g.name,
"description": g.description,
"permissions": perms,
"created_at": g.created_at,
"members": members,
}));
}
HttpResponse::Ok().json(result)
}
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
@ -430,12 +310,12 @@ async fn create_group(_auth: AuthClaims, body: web::Json<serde_json::Value>, db:
_ => "[]".to_string(),
};
match db.create_user_group(name, description, &permissions) {
match db.create_user_group(name, description, &permissions).await {
Ok(id) => HttpResponse::Created().json(serde_json::json!({
"id": id,
"name": name,
"description": description,
"permissions": serde_json::from_str::<serde_json::Value>(&permissions).unwrap_or(serde_json::json!([])),
"permissions": parse_permissions(&permissions),
})),
Err(e) => HttpResponse::Conflict().json(serde_json::json!({"error": e.to_string()})),
}
@ -444,16 +324,16 @@ async fn create_group(_auth: AuthClaims, body: web::Json<serde_json::Value>, db:
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) {
Ok(Some((id, name, description, permissions, created_at))) => {
let perms: serde_json::Value = serde_json::from_str(&permissions).unwrap_or(serde_json::json!([]));
let members = db.get_group_member_ids(group_id).unwrap_or_default();
match db.get_user_group(group_id).await {
Ok(Some(g)) => {
let perms: serde_json::Value = parse_permissions(&g.permissions);
let members = db.list_group_member_ids(group_id).await.unwrap_or_default();
HttpResponse::Ok().json(serde_json::json!({
"id": id,
"name": name,
"description": description,
"id": g.id,
"name": g.name,
"description": g.description,
"permissions": perms,
"created_at": created_at,
"created_at": g.created_at,
"members": members,
}))
}
@ -470,11 +350,9 @@ async fn update_group(
) -> impl Responder {
let group_id = path.into_inner();
// Check group exists
let existing = match db.get_user_group(group_id) {
let existing = match db.get_user_group(group_id).await {
Ok(Some(g)) => {
// Protect built-in groups
if g.1 == "Administrator" || g.1 == "Viewer" {
if g.name == GROUP_ADMIN || g.name == GROUP_VIEWER {
return HttpResponse::Forbidden().json(serde_json::json!({"error": "Cannot modify built-in groups"}));
}
g
@ -487,19 +365,22 @@ async fn update_group(
}
};
let name = body.get("name").and_then(|v| v.as_str()).unwrap_or(&existing.1);
let description = body.get("description").and_then(|v| v.as_str()).unwrap_or(&existing.2);
let name = body.get("name").and_then(|v| v.as_str()).unwrap_or(&existing.name);
let description = body
.get("description")
.and_then(|v| v.as_str())
.unwrap_or(&existing.description);
let permissions = match body.get("permissions") {
Some(p) if p.is_array() => p.to_string(),
_ => existing.3.clone(),
_ => existing.permissions.clone(),
};
match db.update_user_group(group_id, name, description, &permissions) {
match db.update_user_group(group_id, name, description, &permissions).await {
Ok(_) => HttpResponse::Ok().json(serde_json::json!({
"id": group_id,
"name": name,
"description": description,
"permissions": serde_json::from_str::<serde_json::Value>(&permissions).unwrap_or(serde_json::json!([])),
"permissions": parse_permissions(&permissions),
})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
@ -508,15 +389,14 @@ async fn update_group(
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" => {
match db.get_user_group(group_id).await {
Ok(Some(ref g)) if g.name == GROUP_ADMIN || g.name == GROUP_VIEWER => {
return HttpResponse::Forbidden().json(serde_json::json!({"error": "Cannot delete built-in groups"}));
}
_ => {}
}
match db.delete_user_group(group_id) {
match db.delete_user_group(group_id).await {
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()})),
@ -531,9 +411,8 @@ async fn set_user_groups(
) -> impl Responder {
let user_id = path.into_inner();
// Protect the default admin account
match db.find_user_by_id(user_id) {
Ok(Some((_, ref username, _, _, _))) if username == "admin" => {
match db.find_user_by_id(user_id).await {
Ok(Some(ref u)) if u.username == DEFAULT_ADMIN_USERNAME => {
return HttpResponse::Forbidden()
.json(serde_json::json!({"error": "Cannot modify groups for the built-in admin account"}));
}
@ -553,7 +432,7 @@ async fn set_user_groups(
}
};
match db.set_user_groups(user_id, &group_ids) {
match db.set_user_groups(user_id, &group_ids).await {
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()})),
@ -562,52 +441,13 @@ async fn set_user_groups(
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_username_valid() {
assert!(validate_username("admin").is_ok());
assert!(validate_username("user_123").is_ok());
assert!(validate_username("a").is_ok());
}
#[test]
fn test_validate_username_empty() {
assert!(validate_username("").is_err());
}
#[test]
fn test_validate_username_too_long() {
let long = "a".repeat(33);
assert!(validate_username(&long).is_err());
}
#[test]
fn test_validate_username_special_chars() {
assert!(validate_username("admin@host").is_err());
assert!(validate_username("user name").is_err());
assert!(validate_username("user-name").is_err());
assert!(validate_username("用戶").is_err());
}
#[test]
fn test_validate_password_valid() {
assert!(validate_password("12345678").is_ok());
assert!(validate_password("a very long password").is_ok());
}
#[test]
fn test_validate_password_too_short() {
assert!(validate_password("").is_err());
assert!(validate_password("1234567").is_err());
assert!(validate_password("a").is_err());
}
use crate::domain::identity::validation::{validate_password, validate_username};
#[test]
fn test_dummy_hash_is_valid_argon2() {
use argon2::password_hash::PasswordHash;
// DUMMY_HASH must be parseable as a valid Argon2 hash structure
// so that timing-based username enumeration is prevented
use crate::core::identity::auth_service::DUMMY_HASH;
let parsed = PasswordHash::new(DUMMY_HASH);
assert!(
parsed.is_ok(),
@ -615,4 +455,15 @@ mod tests {
parsed.err()
);
}
#[test]
fn test_validate_username_valid() {
assert!(validate_username("admin").is_ok());
assert!(validate_username("user_123").is_ok());
}
#[test]
fn test_validate_password_valid() {
assert!(validate_password("12345678").is_ok());
}
}

View File

@ -0,0 +1,2 @@
pub mod api_keys;
pub mod auth;

View File

@ -3,10 +3,11 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH};
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode, errors::ErrorKind};
use crate::interface::port::secret_store::SecretStorePort;
use crate::model::error::Error;
use crate::model::error::auth::AuthError;
use crate::model::identity::auth::Claims;
use crate::domain::common::error::Error;
use crate::domain::identity::auth::Claims;
use crate::domain::identity::error::AuthError;
use crate::interface::secret_store::SecretStorePort;
use crate::interface::token_minter::TokenMinter;
pub struct JwtService {
encoding_key: EncodingKey,
@ -63,21 +64,33 @@ impl JwtService {
}
}
impl TokenMinter for JwtService {
fn create_token(
&self,
user_id: i64,
username: &str,
role: &str,
permissions: Vec<String>,
) -> Result<String, Error> {
self.create_token(user_id, username, role, permissions)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::adapter::persistence::Database;
use crate::infrastructure::secret_store::SecretStore;
fn test_jwt_service() -> JwtService {
let db = Arc::new(Database::new(":memory:").unwrap());
async fn test_jwt_service() -> JwtService {
let db = Arc::new(Database::new(":memory:").await.unwrap());
let secrets: Arc<dyn SecretStorePort> = Arc::new(SecretStore::new(db));
JwtService::new(&secrets, 24).unwrap()
}
#[test]
fn test_create_and_validate_token() {
let jwt = test_jwt_service();
#[tokio::test]
async fn test_create_and_validate_token() {
let jwt = test_jwt_service().await;
let perms = vec!["dashboard:read".to_string()];
let token = jwt.create_token(1, "admin", "admin", perms.clone()).unwrap();
let claims = jwt.validate_token(&token).unwrap();
@ -87,16 +100,16 @@ mod tests {
assert_eq!(claims.permissions, perms);
}
#[test]
fn test_invalid_token() {
let jwt = test_jwt_service();
#[tokio::test]
async fn test_invalid_token() {
let jwt = test_jwt_service().await;
let result = jwt.validate_token("invalid.token.here");
assert!(result.is_err());
}
#[test]
fn test_expired_token() {
let db = Arc::new(Database::new(":memory:").unwrap());
#[tokio::test]
async fn test_expired_token() {
let db = Arc::new(Database::new(":memory:").await.unwrap());
let secrets: Arc<dyn SecretStorePort> = Arc::new(SecretStore::new(db));
let jwt = JwtService::new(&secrets, 0).unwrap(); // 0 hours = immediate expiry
@ -113,9 +126,9 @@ mod tests {
assert!(result.is_err());
}
#[test]
fn test_jwt_secret_changes_on_new_instance() {
let db = Arc::new(Database::new(":memory:").unwrap());
#[tokio::test]
async fn test_jwt_secret_changes_on_new_instance() {
let db = Arc::new(Database::new(":memory:").await.unwrap());
let secrets: Arc<dyn SecretStorePort> = Arc::new(SecretStore::new(db));
let jwt1 = JwtService::new(&secrets, 24).unwrap();
@ -127,10 +140,10 @@ mod tests {
assert!(result.is_err());
}
#[test]
fn test_different_secrets_reject() {
let jwt1 = test_jwt_service();
let jwt2 = test_jwt_service(); // different in-memory DB = different secret
#[tokio::test]
async fn test_different_secrets_reject() {
let jwt1 = test_jwt_service().await;
let jwt2 = test_jwt_service().await; // different in-memory DB = different secret
let token = jwt1.create_token(1, "admin", "admin", vec![]).unwrap();
let result = jwt2.validate_token(&token);

View File

@ -1,27 +1,14 @@
use std::fs;
use std::io::ErrorKind;
use std::path::Path;
use std::path::PathBuf;
use std::time::UNIX_EPOCH;
use actix_web::{HttpResponse, Scope, web};
use arc_swap::ArcSwap;
use serde::{Deserialize, Serialize};
use crate::core::observability::log_buffer::{self, LogEntry};
/// 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;
/// Default page size for `/live` when the client does not specify `limit`.
/// Chosen so a 2 s poll against a DEBUG-chatty deployment catches up in
/// one round-trip without being absurd payload-wise.
const LIVE_DEFAULT_LIMIT: usize = 500;
/// Hard cap on `/live?limit=` — prevents pathological clients from asking
/// for the entire buffer at once.
const LIVE_MAX_LIMIT: usize = 2_000;
use crate::domain::common::config::AppConfig;
use crate::infrastructure::log_buffer::{self, LogBuffer, LogEntry};
/// Validate log filename: only alphanumeric, dots, underscores, hyphens.
/// Prevents path traversal.
@ -58,16 +45,24 @@ struct LiveResponse {
dropped_oldest: bool,
}
async fn live_logs(query: web::Query<LiveQuery>) -> HttpResponse {
async fn live_logs(
query: web::Query<LiveQuery>,
app_config: web::Data<ArcSwap<AppConfig>>,
buf: web::Data<LogBuffer>,
) -> HttpResponse {
let since_id = query.since_id.unwrap_or(0);
let limit = query.limit.unwrap_or(LIVE_DEFAULT_LIMIT).clamp(1, LIVE_MAX_LIMIT);
let obs = app_config.load().observability.clone();
let limit = query
.limit
.unwrap_or(obs.log_live_default_limit)
.clamp(1, obs.log_live_max_limit.max(1));
let min_severity = query
.min_level
.as_deref()
.map(|s| log_buffer::level_severity(&s.to_ascii_uppercase()))
.unwrap_or(log_buffer::level_severity("TRACE"));
let snap = log_buffer::snapshot(since_id, min_severity, limit);
let snap = buf.snapshot(since_id, min_severity, limit);
// Signal to the UI that it lagged enough for the ring to evict rows
// between polls. Frontend can warn "older entries dropped" without
// silently skipping a gap.
@ -89,9 +84,9 @@ struct LogFileEntry {
modified: Option<u64>,
}
async fn list_logs() -> HttpResponse {
let log_dir = LOG_DIR;
let entries = match fs::read_dir(log_dir) {
async fn list_logs(app_config: web::Data<ArcSwap<AppConfig>>) -> HttpResponse {
let log_dir = app_config.load().system.log_dir.clone();
let entries = match fs::read_dir(&log_dir) {
Ok(dir) => dir
.filter_map(|e| e.ok())
.filter_map(|e| {
@ -118,7 +113,10 @@ async fn list_logs() -> HttpResponse {
HttpResponse::Ok().json(serde_json::json!({ "files": entries }))
}
async fn download_log(path: web::Path<String>) -> HttpResponse {
async fn download_log(path: web::Path<String>, app_config: web::Data<ArcSwap<AppConfig>>) -> HttpResponse {
let config = app_config.load();
let max_download_size = config.observability.log_max_download_size;
let log_dir = PathBuf::from(&config.system.log_dir);
let filename = path.into_inner();
if !is_valid_log_filename(&filename) {
@ -127,7 +125,7 @@ async fn download_log(path: web::Path<String>) -> HttpResponse {
}));
}
let file_path = Path::new(LOG_DIR).join(&filename);
let file_path = log_dir.join(&filename);
// Canonicalize to prevent symlink traversal
let canonical = match fs::canonicalize(&file_path) {
@ -138,7 +136,7 @@ async fn download_log(path: web::Path<String>) -> HttpResponse {
}));
}
};
if let Ok(log_dir_canonical) = fs::canonicalize(LOG_DIR)
if let Ok(log_dir_canonical) = fs::canonicalize(&log_dir)
&& !canonical.starts_with(&log_dir_canonical)
{
return HttpResponse::Forbidden().json(serde_json::json!({
@ -148,9 +146,9 @@ async fn download_log(path: web::Path<String>) -> HttpResponse {
// Check file size before reading to prevent OOM on large logs
match fs::metadata(&canonical) {
Ok(meta) if meta.len() > MAX_DOWNLOAD_SIZE => {
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)
"error": format!("Log file exceeds maximum download size ({}MB)", max_download_size / 1024 / 1024)
}));
}
Err(e) if e.kind() == ErrorKind::NotFound => {

View File

@ -0,0 +1,246 @@
use std::future::{Future, Ready, ready};
use std::pin::Pin;
use std::rc::Rc;
use std::task::{Context, Poll};
use actix_web::body::EitherBody;
use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform};
use actix_web::http::Method;
use actix_web::{Error as ActixError, HttpMessage, HttpResponse, web};
use macros::log;
use crate::adapter::http::jwt::JwtService;
use crate::domain::identity::error::AuthError;
use crate::interface::api_key::ApiKeyRepo;
use crate::interface::app_repo::AppRepo;
pub struct AuthMiddleware;
impl<S, B> Transform<S, ServiceRequest> for AuthMiddleware
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
B: 'static,
{
type Response = ServiceResponse<EitherBody<B>>;
type Error = ActixError;
type Transform = AuthMiddlewareService<S>;
type InitError = ();
type Future = Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future {
ready(Ok(AuthMiddlewareService {
service: Rc::new(service),
}))
}
}
pub struct AuthMiddlewareService<S> {
service: Rc<S>,
}
fn required_permission(path: &str, method: &Method) -> Option<String> {
let resource = if path == "/api/auth/login" || path == "/api/auth/me" || path == "/api/auth/change-password" {
return None;
} else if path.starts_with("/api/auth/") {
return Some("users:admin".to_string());
} else if path.starts_with("/api/health/") || path.starts_with("/api/stats/") {
"dashboard"
} else if path.starts_with("/api/ml/") || path.starts_with("/api/byo/") {
"ai_detection"
} else if path.starts_with("/api/fusion/") {
"fusion"
} else if path.starts_with("/api/flow-trace/") {
"flow_trace"
} else if path.starts_with("/api/acl/geo/") {
"geo_block"
} else if path.starts_with("/api/acl/") {
"access_control"
} else if path.starts_with("/api/filter/dns/") {
"dns_filter"
} else if path.starts_with("/api/filter/http/") || path.starts_with("/api/filter/ssh/") {
"protocol_filter"
} else if path.starts_with("/api/rate-limit/") {
"rate_limit"
} else if path.starts_with("/api/system/") {
"system"
} else if path == "/api/api-keys" || path.starts_with("/api/api-keys/") {
return Some("api_keys:admin".to_string());
} else if path.contains("/soar/blocks/") && path.ends_with("/unblock") {
return Some("access_control:write".to_string());
} else if path.starts_with("/api/soar/")
|| path.starts_with("/api/notifications/")
|| path.starts_with("/api/report/")
|| path.starts_with("/api/logs/")
|| path.starts_with("/api/audit/")
{
"system"
} else {
return None;
};
let action = match *method {
Method::GET => "read",
_ => "write",
};
Some(format!("{}:{}", resource, action))
}
impl<S, B> Service<ServiceRequest> for AuthMiddlewareService<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 Context<'_>) -> Poll<Result<(), Self::Error>> {
self.service.poll_ready(ctx)
}
fn call(&self, req: ServiceRequest) -> Self::Future {
let service = Rc::clone(&self.service);
Box::pin(async move {
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/") {
let res = service.call(req).await?.map_into_left_body();
return Ok(res);
}
// Extract JWT service from app data
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"}));
return Ok(req.into_response(resp).map_into_right_body());
}
};
// Try JWT first, then fall back to API key
let claims = if let Some(auth_header) = req.headers().get("Authorization") {
// JWT Bearer token auth
let val_str = auth_header.to_str().unwrap_or("");
let token = match val_str.strip_prefix("Bearer ") {
Some(t) => t,
None => {
let resp = HttpResponse::Unauthorized()
.json(serde_json::json!({"error": "Invalid authorization header"}));
return Ok(req.into_response(resp).map_into_right_body());
}
};
match jwt_service.validate_token(token) {
Ok(c) => c,
Err(_) => {
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") {
// API key auth with rate limiting
let api_key = api_key_header.to_str().unwrap_or("");
let api_key_port = match req.app_data::<web::Data<dyn ApiKeyRepo>>() {
Some(d) => d.clone(),
None => {
let resp = HttpResponse::InternalServerError()
.json(serde_json::json!({"error": "ApiKeyRepo not configured"}));
return Ok(req.into_response(resp).map_into_right_body());
}
};
let repo = match req.app_data::<web::Data<dyn AppRepo>>() {
Some(d) => d.clone(),
None => {
let resp = HttpResponse::InternalServerError()
.json(serde_json::json!({"error": "AppRepo 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)) = repo.check_login_locked(&rate_key).await {
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 api_key_port.validate_api_key(api_key).await {
Ok(Some(key_claims)) => {
if let Err(e) = repo.clear_login_failures(&rate_key).await {
log!(AuthError::LoginClearError(e));
}
key_claims
}
Ok(None) => {
if let Err(e) = repo.record_login_failure(&rate_key).await {
log!(AuthError::LoginFailureTrackingError(e));
}
let resp = HttpResponse::Unauthorized()
.json(serde_json::json!({"error": "Invalid or revoked API key"}));
return Ok(req.into_response(resp).map_into_right_body());
}
Err(_) => {
let resp = HttpResponse::InternalServerError()
.json(serde_json::json!({"error": "API key validation failed"}));
return Ok(req.into_response(resp).map_into_right_body());
}
}
} else {
let resp =
HttpResponse::Unauthorized().json(serde_json::json!({"error": "Missing authorization header"}));
return Ok(req.into_response(resp).map_into_right_body());
};
// Permission-based RBAC check
if let Some(required) = required_permission(&path, req.method())
&& !claims.permissions.contains(&required)
{
let resp = HttpResponse::Forbidden().json(serde_json::json!({"error": "Insufficient permissions"}));
return Ok(req.into_response(resp).map_into_right_body());
}
// Store claims in request extensions
req.extensions_mut().insert(claims);
let res = service.call(req).await?.map_into_left_body();
Ok(res)
})
}
}
#[cfg(test)]
mod tests {
use actix_web::http::Method;
use super::required_permission;
#[test]
fn api_key_collection_requires_admin_permission() {
assert_eq!(
required_permission("/api/api-keys", &Method::GET),
Some("api_keys:admin".to_string())
);
}
#[test]
fn api_key_subroutes_require_admin_permission() {
assert_eq!(
required_permission("/api/api-keys/generate", &Method::POST),
Some("api_keys:admin".to_string())
);
assert_eq!(
required_permission("/api/api-keys/1", &Method::DELETE),
Some("api_keys:admin".to_string())
);
}
}

View File

@ -5,7 +5,7 @@ use actix_web::dev::Payload;
use actix_web::error::ErrorUnauthorized;
use actix_web::{Error as ActixError, FromRequest, HttpMessage, HttpRequest};
use crate::model::identity::auth::Claims;
use crate::domain::identity::auth::Claims;
/// Actix-web extractor that pulls `Claims` from request extensions.
///

View File

@ -2,8 +2,7 @@ use std::future::{Future, Ready, ready};
use std::net::IpAddr;
use std::pin::Pin;
use std::rc::Rc;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::atomic::Ordering;
use std::task::{Context, Poll};
use actix_web::body::EitherBody;
@ -11,8 +10,7 @@ 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>;
use crate::infrastructure::http_server::ForceHttpsFlag;
/// 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
@ -100,7 +98,7 @@ where
// Check if force_https is enabled
let force = req
.app_data::<web::Data<ForceHttpsFlag>>()
.map(|flag| flag.load(Ordering::Relaxed))
.map(|flag| flag.0.load(Ordering::Relaxed))
.unwrap_or(false);
if !force {

View File

@ -7,13 +7,12 @@ use actix_web::body::EitherBody;
use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform};
use actix_web::http::Method;
use actix_web::{Error as ActixError, HttpMessage, HttpResponse, web};
use macros::log;
use crate::core::auth::jwt::JwtService;
use crate::adapter::http::middleware::jwt::JwtService;
use crate::domain::identity::error::AuthError;
use crate::interface::port::api_key::ApiKeyRepo;
use crate::interface::port::app_repo::AppRepo;
use crate::model::error::auth::AuthError;
pub struct AuthMiddleware;
@ -41,9 +40,8 @@ pub struct AuthMiddlewareService<S> {
fn required_permission(path: &str, method: &Method) -> Option<String> {
let resource = if path == "/api/auth/login" || path == "/api/auth/me" || path == "/api/auth/change-password" {
return None; // Public auth endpoints: login (no auth), me/change-password (auth-only, no RBAC)
return None;
} else if path.starts_with("/api/auth/") {
// User/group management requires users:admin
return Some("users:admin".to_string());
} else if path.starts_with("/api/health/") || path.starts_with("/api/stats/") {
"dashboard"
@ -66,7 +64,6 @@ fn required_permission(path: &str, method: &Method) -> Option<String> {
} else if path.starts_with("/api/system/") {
"system"
} else if path.contains("/soar/blocks/") && path.ends_with("/unblock") {
// manual_unblock needs access_control:write (always POST)
return Some("access_control:write".to_string());
} else if path.starts_with("/api/soar/")
|| path.starts_with("/api/notifications/")

View File

@ -1,7 +1,5 @@
pub mod auth;
pub mod csrf;
pub mod extractor;
pub mod https_redirect;
pub mod jwt;
pub mod middleware;
pub mod password;
pub mod setup_guard;

View File

@ -1,17 +1,14 @@
use std::future::{Future, Ready, ready};
use std::pin::Pin;
use std::rc::Rc;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::atomic::Ordering;
use std::task::{Context, Poll};
use actix_web::body::EitherBody;
use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform};
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.
pub type SetupCompleteFlag = Arc<AtomicBool>;
use crate::infrastructure::http_server::SetupCompleteFlag;
pub struct SetupGuard;
@ -59,8 +56,8 @@ where
// Check setup_complete flag from app data
let setup_complete = req
.app_data::<web::Data<SetupCompleteFlag>>()
.map(|flag| flag.load(Ordering::SeqCst))
.unwrap_or(true); // Default to true if flag not found
.map(|flag| flag.0.load(Ordering::SeqCst))
.unwrap_or(true);
if setup_complete {
// Normal mode: pass through, but block setup mutation endpoints.

View File

@ -1,20 +1,13 @@
pub mod acl;
pub mod api_keys;
pub mod audit;
pub mod auth;
pub mod byo;
pub mod data_plane;
pub mod default;
pub mod filter;
pub mod flow_trace;
pub mod fusion;
pub mod health;
pub mod detection;
pub mod helpers;
pub mod identity;
pub mod jwt;
pub mod logs;
pub mod ml;
pub mod model_upload;
pub mod notification;
pub mod rate_limit;
pub mod report;
pub mod middleware;
pub mod ready;
pub mod response;
pub mod setup;
pub mod soar;
pub mod stats;
pub mod system;

View File

@ -0,0 +1,23 @@
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering::SeqCst;
use actix_web::{HttpResponse, web};
use crate::infrastructure::readiness::ReadinessState;
pub async fn health_ready(ready: web::Data<Arc<AtomicBool>>, state: web::Data<ReadinessState>) -> HttpResponse {
let is_ready = ready.load(SeqCst);
let uptime_secs = state.started_at.elapsed().as_secs();
HttpResponse::Ok().json(serde_json::json!({
"ready": is_ready,
"subsystems": {
"db_connected": state.db_connected.load(SeqCst),
"ml_model_loaded": state.ml_model_loaded.load(SeqCst),
"soar_engine_running": state.soar_engine_running.load(SeqCst),
"ebpf_attached": state.ebpf_attached.load(SeqCst),
},
"uptime_secs": uptime_secs,
}))
}

View File

@ -0,0 +1,3 @@
pub mod notification;
pub mod report;
pub mod soar;

View File

@ -1,8 +1,9 @@
use actix_web::{HttpResponse, Scope, web};
use serde::Deserialize;
use crate::core::auth::extractor::AuthClaims;
use crate::core::notification_service::NotificationService;
use crate::adapter::http::helpers::{ok_json_or_error, ok_or_error};
use crate::adapter::http::middleware::extractor::AuthClaims;
use crate::core::common::notification_service::NotificationService;
pub fn initialize() -> Scope {
web::scope("/notifications")
@ -13,10 +14,7 @@ pub fn initialize() -> Scope {
}
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()})),
}
ok_json_or_error(svc.get_telegram_config().await)
}
#[derive(Deserialize)]
@ -30,10 +28,7 @@ async fn set_telegram_config(
svc: web::Data<NotificationService>,
body: web::Json<TelegramConfigRequest>,
) -> HttpResponse {
match svc.set_telegram_config(&body.bot_token, &body.chat_id) {
Ok(()) => HttpResponse::Ok().json(serde_json::json!({"saved": true})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
ok_or_error(svc.set_telegram_config(&body.bot_token, &body.chat_id).await)
}
async fn test_telegram(_auth: AuthClaims, svc: web::Data<NotificationService>) -> HttpResponse {
@ -44,7 +39,7 @@ async fn test_telegram(_auth: AuthClaims, svc: web::Data<NotificationService>) -
}
async fn test_smtp(_auth: AuthClaims, svc: web::Data<NotificationService>) -> HttpResponse {
match svc.test_smtp() {
match svc.test_smtp().await {
Ok(msg) => HttpResponse::Ok().json(serde_json::json!({"success": true, "message": msg})),
Err(e) => HttpResponse::BadRequest().json(serde_json::json!({"success": false, "error": e.to_string()})),
}

View File

@ -1,17 +1,22 @@
use std::fs;
use actix_web::{HttpResponse, Scope, web};
use arc_swap::ArcSwap;
use chrono::Local;
use tokio::task::spawn_blocking;
use crate::adapter::http::helpers::ok_json_or_error;
use crate::adapter::http::middleware::extractor::AuthClaims;
use crate::adapter::notification::smtp::SmtpClient;
use crate::adapter::persistence::Database;
use crate::core::auth::extractor::AuthClaims;
use crate::core::email::report::generate_weekly_report;
use crate::core::email::scheduler::SmtpClient;
use crate::core::report::engine;
use crate::core::reporting::email_report::generate_weekly_report;
use crate::core::reporting::report_engine;
use crate::domain::common::config::AppConfig;
use crate::domain::common::error::misc::MiscError;
use crate::infrastructure::secret_store::SecretStore;
use crate::interface::port::secret_store::SecretStorePort;
use crate::interface::port::setting::SettingRepo;
use crate::interface::report_snapshot::ReportSnapshotRepo;
use crate::interface::secret_store::SecretStorePort;
pub fn initialize() -> Scope {
web::scope("/report")
.route("/generate", web::post().to(generate_report))
@ -19,19 +24,19 @@ pub fn initialize() -> Scope {
.route("/send", web::post().to(send_report))
}
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());
async fn generate_report(
_auth: AuthClaims,
db: web::Data<Database>,
config: web::Data<ArcSwap<AppConfig>>,
) -> HttpResponse {
let report_dir = config.load().system.report_dir.clone();
if let Err(e) = fs::create_dir_all(&report_dir) {
return HttpResponse::InternalServerError().json(serde_json::json!({
"error": format!("Failed to create report directory: {}", e)
}));
}
let db_ref = db.get_ref();
match engine::generate_html_report(db_ref as &dyn SettingRepo, &report_dir) {
match report_engine::generate_html_report(db_ref as &dyn ReportSnapshotRepo, &report_dir).await {
Ok(path) => match fs::read(&path) {
Ok(content) => HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
@ -57,18 +62,21 @@ async fn generate_report(_auth: AuthClaims, db: web::Data<Database>) -> HttpResp
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 SettingRepo) {
Ok(data) => HttpResponse::Ok().json(data),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
ok_json_or_error(report_engine::generate_report_json(db_ref as &dyn ReportSnapshotRepo).await)
}
/// 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 SettingRepo;
async fn send_report(
_auth: AuthClaims,
db: web::Data<Database>,
config: web::Data<ArcSwap<AppConfig>>,
secrets: web::Data<SecretStore>,
) -> HttpResponse {
let db_ref = db.get_ref() as &dyn ReportSnapshotRepo;
let secrets_ref = secrets.get_ref() as &dyn SecretStorePort;
let smtp_cfg = config.load().notification.smtp.clone();
let smtp = match SmtpClient::from_database(db_ref, Some(secrets_ref)) {
let smtp = match SmtpClient::from_config(&smtp_cfg, Some(secrets_ref)).await {
Ok(Some(client)) => client,
Ok(None) => {
return HttpResponse::BadRequest().json(serde_json::json!({
@ -84,17 +92,15 @@ async fn send_report(_auth: AuthClaims, db: web::Data<Database>, secrets: web::D
}
};
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 recipient = smtp_cfg.recipient;
if recipient.is_empty() {
return HttpResponse::BadRequest().json(serde_json::json!({
"success": false,
"error": MiscError::ValidationError("No smtp_recipient configured.").to_string()
}));
}
let html = match generate_weekly_report(db_ref) {
let html = match generate_weekly_report(db_ref).await {
Ok(h) => h,
Err(e) => {
return HttpResponse::InternalServerError().json(serde_json::json!({

View File

@ -1,13 +1,16 @@
use std::str::FromStr;
use actix_web::{HttpResponse, Scope, web};
use arc_swap::ArcSwap;
use serde::Deserialize;
use crate::core::auth::extractor::AuthClaims;
use crate::core::playbook_service::PlaybookService;
use crate::core::soar::engine::SoarEngine;
use crate::model::event::{DetectionSource, ThreatDetectedEvent};
use crate::model::soar::playbook_data::{CreateConditionInput, CreatePlaybookInput};
use crate::adapter::http::helpers::{ok_json_or_error, ok_or_error};
use crate::adapter::http::middleware::extractor::AuthClaims;
use crate::core::response::engine::SoarEngine;
use crate::core::response::playbook_service::PlaybookService;
use crate::domain::common::config::AppConfig;
use crate::domain::common::event::{DetectionSource, ThreatDetectedEvent};
use crate::domain::response::playbook_data::{CreateConditionInput, CreatePlaybookInput};
#[derive(Deserialize)]
struct CreatePlaybookRequest {
@ -35,6 +38,47 @@ struct CreateConditionRequest {
value2: Option<String>,
}
fn map_request_to_input(body: &CreatePlaybookRequest, fallback_cooldown: i64) -> CreatePlaybookInput {
let actions = 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 = body
.conditions
.as_deref()
.unwrap_or_default()
.iter()
.map(|c| {
CreateConditionInput::new(
c.condition_type.clone(),
c.operator.clone(),
c.value.clone(),
c.value2.clone(),
)
})
.collect();
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(fallback_cooldown),
actions,
conditions,
}
}
pub fn initialize() -> Scope {
web::scope("/soar")
.route("/playbooks", web::get().to(list_playbooks))
@ -52,107 +96,17 @@ pub fn initialize() -> Scope {
}
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| {
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": 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();
HttpResponse::Ok().json(responses)
}
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
ok_json_or_error(svc.list_playbooks().await)
}
async fn create_playbook(
_auth: AuthClaims,
svc: web::Data<PlaybookService>,
app_config: web::Data<ArcSwap<AppConfig>>,
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 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.create_playbook(&input) {
let input = map_request_to_input(&body, app_config.load().soar.fallback_cooldown_secs);
match svc.create_playbook(&input).await {
Ok(id) => HttpResponse::Created().json(serde_json::json!({"id": id})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
@ -161,57 +115,13 @@ async fn create_playbook(
async fn update_playbook(
_auth: AuthClaims,
svc: web::Data<PlaybookService>,
app_config: web::Data<ArcSwap<AppConfig>>,
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) {
let input = map_request_to_input(&body, app_config.load().soar.fallback_cooldown_secs);
match svc.update_playbook(id, &input).await {
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()})),
@ -229,7 +139,7 @@ async fn toggle_playbook(
path: web::Path<i64>,
body: web::Json<TogglePlaybookRequest>,
) -> HttpResponse {
match svc.toggle_playbook(path.into_inner(), body.enabled) {
match svc.toggle_playbook(path.into_inner(), body.enabled).await {
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()})),
@ -237,7 +147,7 @@ async fn toggle_playbook(
}
async fn delete_playbook(_auth: AuthClaims, svc: web::Data<PlaybookService>, path: web::Path<i64>) -> HttpResponse {
match svc.delete_playbook(path.into_inner()) {
match svc.delete_playbook(path.into_inner()).await {
Ok(true) => HttpResponse::Ok().json(serde_json::json!({"deleted": 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()})),
@ -245,59 +155,23 @@ async fn delete_playbook(_auth: AuthClaims, svc: web::Data<PlaybookService>, pat
}
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,
})
})
.collect();
HttpResponse::Ok().json(responses)
}
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
ok_json_or_error(svc.list_active_blocks().await)
}
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()})),
}
ok_or_error(svc.manual_unblock(path.into_inner()).await)
}
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,
})
})
.collect();
HttpResponse::Ok().json(responses)
}
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
async fn list_executions(
_auth: AuthClaims,
svc: web::Data<PlaybookService>,
app_config: web::Data<ArcSwap<AppConfig>>,
) -> HttpResponse {
ok_json_or_error(svc.list_executions(app_config.load().soar.execution_list_limit).await)
}
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()})),
}
ok_json_or_error(svc.list_whitelist().await)
}
#[derive(Deserialize)]
@ -310,17 +184,14 @@ async fn add_whitelist(
svc: web::Data<PlaybookService>,
body: web::Json<WhitelistRequest>,
) -> HttpResponse {
match svc.add_whitelist(&body.ip) {
match svc.add_whitelist(&body.ip).await {
Ok(()) => HttpResponse::Created().json(serde_json::json!({"added": true})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
}
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()})),
}
ok_or_error(svc.remove_whitelist(&path.into_inner()).await)
}
/// Client shape for `POST /api/soar/dry-run`. Only the fields a SOAR

View File

@ -8,12 +8,14 @@ use serde::Deserialize;
use serde_json::Value;
use crate::adapter::persistence::Database;
use crate::core::auth::password;
use crate::core::auth::setup_guard::SetupCompleteFlag;
use crate::domain::common::error::Error;
use crate::domain::common::error::system::SystemError;
use crate::domain::identity::auth::DEFAULT_ADMIN_USERNAME;
use crate::domain::identity::password;
use crate::infrastructure::http_server::SetupCompleteFlag;
use crate::infrastructure::secret_store::SecretStore;
use crate::interface::port::secret_store::SecretStorePort;
use crate::model::error::Error;
use crate::model::error::system::SystemError;
use crate::interface::secret_store::SecretStorePort;
use crate::interface::system_state::SystemStateRepo;
pub fn initialize() -> Scope {
web::scope("/setup")
@ -23,7 +25,7 @@ pub fn initialize() -> Scope {
}
async fn setup_status(setup_flag: web::Data<SetupCompleteFlag>) -> HttpResponse {
let complete = setup_flag.load(Ordering::SeqCst);
let complete = setup_flag.0.load(Ordering::SeqCst);
HttpResponse::Ok().json(serde_json::json!({
"setup_complete": complete,
}))
@ -87,8 +89,7 @@ async fn complete_setup(
setup_flag: web::Data<SetupCompleteFlag>,
body: web::Json<SetupRequest>,
) -> HttpResponse {
// Check if already completed (concurrent access protection)
if setup_flag.load(Ordering::SeqCst) {
if setup_flag.0.load(Ordering::SeqCst) {
return HttpResponse::Conflict().json(serde_json::json!({
"error": "Setup already completed"
}));
@ -133,7 +134,7 @@ async fn complete_setup(
}
// Save configuration to database
if let Err(e) = save_config(&db, secret_store.as_ref(), &body) {
if let Err(e) = save_config(&db, secret_store.as_ref(), &body).await {
return HttpResponse::InternalServerError().json(serde_json::json!({
"error": format!("Failed to save configuration: {}", e)
}));
@ -143,14 +144,10 @@ async fn complete_setup(
match password::hash_password(&body.admin_password) {
Ok(hash) => {
// Find admin user and update password
if let Ok(Some(user)) = db.find_user("admin") {
if let Err(e) = db.update_user_password(user.0, &hash) {
log!(SystemError::SetupPasswordUpdateFailed(e));
}
// Clear force_password_change since setup wizard set the password
if let Err(e) = db.reset_user_password(user.0, &hash) {
log!(SystemError::SetupPasswordUpdateFailed(e));
}
if let Ok(Some(user)) = db.find_user(DEFAULT_ADMIN_USERNAME).await
&& let Err(e) = db.update_user_password(user.id, &hash).await
{
log!(SystemError::SetupPasswordUpdateFailed(e));
}
}
Err(e) => {
@ -161,10 +158,11 @@ async fn complete_setup(
}
// Mark setup as complete
if let Err(e) = db.set_setting("setup_complete", "true") {
let state_repo = db.get_ref() as &dyn SystemStateRepo;
if let Err(e) = state_repo.set_system_state("setup_complete", "true").await {
log!(SystemError::SetupCompleteFlagFailed(e));
}
setup_flag.store(true, Ordering::SeqCst);
setup_flag.0.store(true, Ordering::SeqCst);
// System::run() polls the setup_complete flag and will automatically
// start eBPF, ML, and SOAR services once this flag becomes true.
@ -176,43 +174,43 @@ async fn complete_setup(
}))
}
fn save_config(db: &Database, secrets: &dyn SecretStorePort, req: &SetupRequest) -> Result<(), Error> {
async fn save_config(db: &Database, secrets: &dyn SecretStorePort, req: &SetupRequest) -> Result<(), Error> {
// Save network config
db.set_setting("ingress_interface", &req.ingress_interface)?;
db.set_setting("egress_interface", &req.egress_interface)?;
db.set_config_value("ingress_interface", &req.ingress_interface).await?;
db.set_config_value("egress_interface", &req.egress_interface).await?;
if let Some(port) = req.http_port {
db.set_setting("http_port", &port.to_string())?;
db.set_config_value("http_port", &port.to_string()).await?;
}
// Save SMTP config (non-secret fields go to settings)
if let Some(host) = &req.smtp_host {
db.set_setting("smtp_host", host)?;
db.set_config_value("smtp_host", host).await?;
}
if let Some(port) = req.smtp_port {
db.set_setting("smtp_port", &port.to_string())?;
db.set_config_value("smtp_port", &port.to_string()).await?;
}
if let Some(user) = &req.smtp_username {
db.set_setting("smtp_username", user)?;
db.set_config_value("smtp_username", user).await?;
}
if let Some(pass) = &req.smtp_password {
// Store password through secret store (encrypted)
secrets.set_secret("smtp_password", pass)?;
db.set_setting("smtp_password", "__encrypted__")?;
secrets.set_secret("smtp_password", pass).await?;
db.set_config_value("smtp_password", "__encrypted__").await?;
}
if let Some(recipient) = &req.smtp_recipient {
db.set_setting("smtp_recipient", recipient)?;
db.set_config_value("smtp_recipient", recipient).await?;
}
// 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)?;
secrets.set_secret("telegram_bot_token", token).await?;
let config_json = serde_json::json!({
"bot_token": "__encrypted__",
"chat_id": chat_id,
})
.to_string();
db.set_notification_config("telegram", &config_json)?;
db.set_notification_config("telegram", &config_json).await?;
}
Ok(())

View File

@ -1,17 +1,16 @@
use actix_web::{HttpResponse, Responder, Scope, web};
use serde::Deserialize;
use crate::core::auth::extractor::AuthClaims;
use crate::core::config_service::ConfigService;
use crate::infrastructure::communication_manager::CommunicationManager;
use crate::adapter::http::middleware::extractor::AuthClaims;
use crate::core::common::config_service::ConfigService;
use crate::core::common::enforce_mode_handler::EnforceModeHandler;
use crate::domain::common::config::constants::{
ENFORCE_MODE_ENFORCE, ENFORCE_MODE_ML_ONLY, ENFORCE_MODE_MONITOR, PERMISSION_SYSTEM_ADMIN,
};
use crate::infrastructure::logger::Logger;
use crate::infrastructure::runtime_state::RuntimeState;
use crate::infrastructure::system::{ShutdownHandle, ShutdownMode};
use crate::interface::communication::command_types::ChangeEnforceModeCommand;
use crate::interface::communication::query_types::GetEnforceModeQuery;
use crate::interface::port::app_repo::AppRepo;
use crate::utils::boot_time;
use crate::utils::logging::Logging;
type Repo = dyn AppRepo;
#[derive(Deserialize)]
struct EnforceModeRequest {
@ -36,54 +35,42 @@ async fn get_boot_time() -> impl Responder {
HttpResponse::Ok().json(boot_time::boot_time())
}
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()})),
}
async fn get_enforce_mode(handler: web::Data<EnforceModeHandler>) -> impl Responder {
HttpResponse::Ok().json(serde_json::json!({"mode": handler.get_mode()}))
}
async fn set_enforce_mode(
body: web::Json<EnforceModeRequest>,
comm: web::Data<CommunicationManager>,
handler: web::Data<EnforceModeHandler>,
) -> impl Responder {
let mode = &body.mode;
if mode != "monitor" && mode != "ml_only" && mode != "enforce" {
if mode != ENFORCE_MODE_MONITOR && mode != ENFORCE_MODE_ML_ONLY && mode != ENFORCE_MODE_ENFORCE {
return HttpResponse::BadRequest()
.json(serde_json::json!({"error": "Mode must be 'monitor', 'ml_only', or 'enforce'"}));
}
match comm.send_command(ChangeEnforceModeCommand { mode: mode.clone() }).await {
match handler.change_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()})),
}
}
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());
async fn get_xdp_mode(runtime_state: web::Data<arc_swap::ArcSwap<RuntimeState>>) -> impl Responder {
let xdp = runtime_state.load().xdp.clone();
HttpResponse::Ok().json(serde_json::json!({
"ingress_mode": ingress,
"egress_mode": egress,
"ingress_mode": xdp.ingress_mode,
"egress_mode": xdp.egress_mode,
}))
}
async fn get_config(svc: web::Data<ConfigService>) -> impl Responder {
HttpResponse::Ok().json(svc.get_config())
HttpResponse::Ok().json(svc.get_config().await)
}
async fn get_log_level() -> impl Responder {
async fn get_log_level(logging: web::Data<Logger>) -> impl Responder {
HttpResponse::Ok().json(serde_json::json!({
"level": Logging::current_level(),
"level": logging.current_level(),
}))
}
@ -92,8 +79,8 @@ struct LogLevelRequest {
level: String,
}
async fn set_log_level(body: web::Json<LogLevelRequest>) -> impl Responder {
match Logging::set_level(&body.level) {
async fn set_log_level(body: web::Json<LogLevelRequest>, logging: web::Data<Logger>) -> impl Responder {
match logging.set_level(&body.level) {
Ok(new_level) => HttpResponse::Ok().json(serde_json::json!({
"level": new_level,
"message": "Log level updated",
@ -110,7 +97,7 @@ async fn update_config(
svc: web::Data<ConfigService>,
handle: web::Data<ShutdownHandle>,
) -> impl Responder {
match svc.update_config(&body) {
match svc.update_config(&body).await {
Ok(updated) => {
let needs_restart = updated.iter().any(|k| HTTP_RELOAD_KEYS.contains(&k.as_str()));
if needs_restart {
@ -137,7 +124,7 @@ async fn update_config(
}
async fn shutdown(auth: AuthClaims, handle: web::Data<ShutdownHandle>) -> impl Responder {
if !auth.permissions.iter().any(|p| p == "system:admin") {
if !auth.permissions.iter().any(|p| p == PERMISSION_SYSTEM_ADMIN) {
return HttpResponse::Forbidden().json(serde_json::json!({"error": "Requires system:admin permission"}));
}
if handle.trigger(ShutdownMode::Shutdown) {
@ -148,7 +135,7 @@ async fn shutdown(auth: AuthClaims, handle: web::Data<ShutdownHandle>) -> impl R
}
async fn restart(auth: AuthClaims, handle: web::Data<ShutdownHandle>) -> impl Responder {
if !auth.permissions.iter().any(|p| p == "system:admin") {
if !auth.permissions.iter().any(|p| p == PERMISSION_SYSTEM_ADMIN) {
return HttpResponse::Forbidden().json(serde_json::json!({"error": "Requires system:admin permission"}));
}
if handle.trigger(ShutdownMode::Restart) {

View File

@ -1,6 +1,8 @@
pub mod access_control_adapter;
pub mod access_control;
pub mod ebpf;
pub mod http;
pub mod model_loading;
pub mod notification;
pub mod persistence;
pub mod telegram;
pub mod websocket;

View File

@ -2,9 +2,9 @@ use std::collections::{BTreeMap, HashMap};
use std::fs;
use std::path::{Path, PathBuf};
use super::manifest::{AdapterKind, LabelSpec, ModelManifest};
use crate::model::error::ml::MLError;
use crate::model::system::config::MLInferenceConfig;
use crate::domain::detection::error::MLError;
use crate::domain::detection::manifest::{AdapterKind, LabelSpec, ModelManifest};
use crate::domain::detection::ml_inference_config::MLInferenceConfig;
impl MLInferenceConfig {
pub fn load_file(file: &str) -> Result<Self, MLError> {
@ -145,7 +145,7 @@ mod tests {
use std::io::Write;
use super::*;
use crate::model::detection::ml_detection::ClipParams;
use crate::domain::detection::ml_detection::ClipParams;
/// Integration test: the shipped `models/manifest.yaml` must successfully pair
/// with its scaler sidecar to yield a valid `MLInferenceConfig`. Skipped silently

View File

@ -0,0 +1,17 @@
use std::fs;
use std::path::Path;
use crate::domain::detection::error::MLError;
use crate::domain::detection::manifest::ModelManifest;
impl ModelManifest {
pub fn load(path: impl AsRef<Path>) -> Result<Self, MLError> {
let path = path.as_ref();
let content = fs::read_to_string(path)
.map_err(|e| MLError::ManifestInvalid(path.to_path_buf(), format!("read failed: {e}")))?;
let manifest: ModelManifest = serde_yaml_ng::from_str(&content)
.map_err(|e| MLError::ManifestInvalid(path.to_path_buf(), format!("YAML parse: {e}")))?;
manifest.validate(path)?;
Ok(manifest)
}
}

View File

@ -0,0 +1,2 @@
pub mod config_loader;
pub mod manifest;

View File

@ -0,0 +1 @@
pub mod smtp;

View File

@ -0,0 +1,112 @@
use lettre::message::header::ContentType;
use lettre::transport::smtp::authentication::Credentials;
use lettre::{Message, SmtpTransport, Transport};
use crate::domain::common::config::notification::SmtpConfig;
use crate::domain::common::error::Error;
use crate::domain::common::error::notification::NotificationError;
use crate::interface::email_sender::{EmailSender, EmailSenderFactory};
use crate::interface::secret_store::SecretStorePort;
pub struct SmtpClient {
host: String,
port: u16,
username: String,
password: String,
sender: String,
}
impl SmtpClient {
pub async fn from_config(cfg: &SmtpConfig, secrets: Option<&dyn SecretStorePort>) -> Result<Option<Self>, Error> {
if cfg.host.is_empty() || cfg.username.is_empty() {
return Ok(None);
}
let secret = match secrets {
Some(ss) => ss.get_secret("smtp_password").await?,
None => None,
};
let password = match secret {
Some(pw) if !pw.is_empty() => pw,
_ => return Ok(None),
};
let sender = if cfg.sender.is_empty() {
cfg.username.clone()
} else {
cfg.sender.clone()
};
if !sender.contains('@') {
return Ok(None);
}
Ok(Some(Self {
host: cfg.host.clone(),
port: cfg.port,
username: cfg.username.clone(),
password,
sender,
}))
}
pub fn send(&self, to: &str, subject: &str, html_body: &str) -> Result<(), Error> {
let from_addr = self
.sender
.parse()
.map_err(|e| NotificationError::InvalidAddress("from", e))?;
let to_addr = to.parse().map_err(|e| NotificationError::InvalidAddress("to", e))?;
let email = Message::builder()
.from(from_addr)
.to(to_addr)
.subject(subject)
.header(ContentType::TEXT_HTML)
.body(html_body.to_string())
.map_err(NotificationError::MessageBuildFailed)?;
let creds = Credentials::new(self.username.clone(), self.password.clone());
let mailer = match self.port {
465 => SmtpTransport::relay(&self.host)
.map_err(NotificationError::SmtpConnectionFailed)?
.port(self.port)
.credentials(creds)
.build(),
25 | 587 => SmtpTransport::starttls_relay(&self.host)
.map_err(NotificationError::SmtpConnectionFailed)?
.port(self.port)
.credentials(creds)
.build(),
_ => SmtpTransport::builder_dangerous(&self.host)
.port(self.port)
.credentials(creds)
.build(),
};
mailer.send(&email).map_err(NotificationError::SmtpSendFailed)?;
Ok(())
}
}
impl EmailSender for SmtpClient {
fn send(&self, to: &str, subject: &str, html_body: &str) -> Result<(), Error> {
self.send(to, subject, html_body)
}
}
pub struct SmtpClientFactory;
#[async_trait::async_trait]
impl EmailSenderFactory for SmtpClientFactory {
async fn build_smtp_sender(
&self,
cfg: &SmtpConfig,
secrets: Option<&dyn SecretStorePort>,
) -> Result<Option<Box<dyn EmailSender>>, Error> {
SmtpClient::from_config(cfg, secrets)
.await
.map(|opt| opt.map(|c| Box::new(c) as Box<dyn EmailSender>))
}
}

View File

@ -0,0 +1,185 @@
use async_trait::async_trait;
use rusqlite::params;
use super::Database;
use crate::domain::common::error::Error;
use crate::domain::data_plane::acl_rule::AclRuleView;
use crate::interface::acl::AclRepo;
impl Database {
pub async fn insert_acl_rule(
&self,
ip_version: u8,
direction: &str,
list_type: &str,
ip_address: &str,
port: u16,
) -> Result<(), Error> {
let direction = direction.to_string();
let list_type = list_type.to_string();
let ip_address = ip_address.to_string();
self.pool
.conn_and_then(move |conn| {
conn.execute(
"INSERT OR IGNORE INTO acl_rules (ip_version, direction, list_type, ip_address, port) VALUES (?1, ?2, ?3, ?4, ?5)",
params![ip_version, direction, list_type, ip_address, port as i64],
)?;
if direction == "source" && list_type == "blacklist" {
conn.execute(
"UPDATE soar_block_rules
SET preserve_acl_on_unblock = 1
WHERE source_ip = ?1 AND unblocked_at IS NULL",
params![ip_address],
)?;
}
Ok(())
})
.await
}
pub async fn delete_acl_rule(
&self,
ip_version: u8,
direction: &str,
list_type: &str,
ip_address: &str,
port: u16,
) -> Result<(), Error> {
let direction = direction.to_string();
let list_type = list_type.to_string();
let ip_address = ip_address.to_string();
self.pool
.conn_and_then(move |conn| {
conn.execute(
"DELETE FROM acl_rules WHERE ip_version = ?1 AND direction = ?2 AND list_type = ?3 AND ip_address = ?4 AND port = ?5",
params![ip_version, direction, list_type, ip_address, port as i64],
)?;
Ok(())
})
.await
}
pub async fn list_acl_rules(&self) -> Result<Vec<AclRuleView>, Error> {
self.pool
.conn_and_then(move |conn| {
let mut stmt =
conn.prepare("SELECT ip_version, direction, list_type, ip_address, port FROM acl_rules")?;
let rows = stmt.query_map([], |row| {
Ok(AclRuleView {
ip_version: row.get(0)?,
direction: row.get(1)?,
list_type: row.get(2)?,
ip_address: row.get(3)?,
port: row.get::<_, i64>(4)? as u16,
})
})?;
let mut results = Vec::new();
for row in rows {
results.push(row?);
}
Ok(results)
})
.await
}
pub async fn has_manual_acl_rule(&self, ip_address: &str) -> Result<bool, Error> {
let ip_address = ip_address.to_string();
self.pool
.conn_and_then(move |conn| {
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM acl_rules
WHERE ip_address = ?1 AND list_type = 'blacklist'
AND EXISTS (
SELECT 1 FROM soar_block_rules
WHERE soar_block_rules.source_ip = acl_rules.ip_address
AND soar_block_rules.unblocked_at IS NULL
AND (
soar_block_rules.preserve_acl_on_unblock = 1
OR soar_block_rules.created_acl_rule = 0
)
)",
params![ip_address],
|row| row.get(0),
)?;
Ok(count > 0)
})
.await
}
pub async fn list_admin_whitelist(&self) -> Result<Vec<String>, Error> {
self.pool
.conn_and_then(move |conn| {
let mut stmt = conn.prepare("SELECT ip FROM admin_whitelist")?;
let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
let mut result = Vec::new();
for row in rows {
result.push(row?);
}
Ok(result)
})
.await
}
pub async fn insert_admin_whitelist(&self, ip: &str) -> Result<(), Error> {
let ip = ip.to_string();
self.pool
.conn_and_then(move |conn| {
conn.execute("INSERT OR IGNORE INTO admin_whitelist (ip) VALUES (?1)", params![ip])?;
Ok(())
})
.await
}
pub async fn delete_admin_whitelist(&self, ip: &str) -> Result<(), Error> {
let ip = ip.to_string();
self.pool
.conn_and_then(move |conn| {
conn.execute("DELETE FROM admin_whitelist WHERE ip = ?1", params![ip])?;
Ok(())
})
.await
}
}
#[async_trait]
impl AclRepo for Database {
async fn insert_acl_rule(
&self,
ip_version: u8,
direction: &str,
list_type: &str,
ip_address: &str,
port: u16,
) -> Result<(), Error> {
self.insert_acl_rule(ip_version, direction, list_type, ip_address, port)
.await
}
async fn delete_acl_rule(
&self,
ip_version: u8,
direction: &str,
list_type: &str,
ip_address: &str,
port: u16,
) -> Result<(), Error> {
self.delete_acl_rule(ip_version, direction, list_type, ip_address, port)
.await
}
async fn has_manual_acl_rule(&self, ip_address: &str) -> Result<bool, Error> {
self.has_manual_acl_rule(ip_address).await
}
async fn list_admin_whitelist(&self) -> Result<Vec<String>, Error> {
self.list_admin_whitelist().await
}
async fn insert_admin_whitelist(&self, ip: &str) -> Result<(), Error> {
self.insert_admin_whitelist(ip).await
}
async fn delete_admin_whitelist(&self, ip: &str) -> Result<(), Error> {
self.delete_admin_whitelist(ip).await
}
}

View File

@ -0,0 +1,174 @@
use std::fmt::Write;
use async_trait::async_trait;
use hmac::{Hmac, Mac};
use rusqlite::{Error as RusqliteError, params};
use sha2::Sha256;
use super::Database;
use crate::domain::common::error::Error;
use crate::domain::identity::auth::{Claims, PermissionLevel};
use crate::domain::identity::user::ApiKeyView;
use crate::interface::api_key::ApiKeyRepo;
type HmacSha256 = Hmac<Sha256>;
impl Database {
/// Compute HMAC-SHA256 of an API key using the derived secret.
pub fn hmac_api_key(&self, raw_key: &str) -> String {
let mut mac = HmacSha256::new_from_slice(&self.api_key_hmac).unwrap_or_else(|_| unreachable!());
mac.update(raw_key.as_bytes());
let result = mac.finalize().into_bytes();
let mut hex = String::with_capacity(64);
for byte in result {
let _ = write!(&mut hex, "{:02x}", byte);
}
hex
}
pub async fn validate_api_key(&self, api_key: &str) -> Result<Option<Claims>, Error> {
let digest = self.hmac_api_key(api_key);
self.pool
.conn_and_then(move |conn| {
let result = conn.query_row(
"SELECT id, name, permission_level FROM api_keys WHERE key_hash = ?1",
params![digest],
|row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
))
},
);
match result {
Ok((id, name, level)) => {
let _ = conn.execute(
"UPDATE api_keys SET last_used_at = datetime('now') WHERE id = ?1",
params![id],
);
let perm_level = PermissionLevel::from_str(&level).unwrap_or(PermissionLevel::ReadOnly);
let permissions: Vec<String> =
perm_level.permissions().iter().map(|s| (*s).to_string()).collect();
Ok(Some(Claims {
sub: -id,
username: format!("api:{}", name),
role: level,
permissions,
exp: usize::MAX,
}))
}
Err(RusqliteError::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e.into()),
}
})
.await
}
pub async fn insert_api_key(&self, key_hash: &str, name: &str, permission_level: &str) -> Result<i64, Error> {
let key_hash = key_hash.to_string();
let name = name.to_string();
let permission_level = permission_level.to_string();
self.pool
.conn_and_then(move |conn| {
conn.execute(
"INSERT INTO api_keys (key_hash, name, permission_level) VALUES (?1, ?2, ?3)",
params![key_hash, name, permission_level],
)?;
Ok(conn.last_insert_rowid())
})
.await
}
pub async fn list_api_keys(&self) -> Result<Vec<ApiKeyView>, Error> {
self.pool
.conn_and_then(move |conn| {
let mut stmt =
conn.prepare("SELECT id, name, permission_level, created_at, last_used_at FROM api_keys")?;
let rows = stmt.query_map([], |row| {
Ok(ApiKeyView {
id: row.get(0)?,
name: row.get(1)?,
permission_level: row.get(2)?,
created_at: row.get(3)?,
last_used_at: row.get(4)?,
})
})?;
let mut result = Vec::new();
for row in rows {
result.push(row?);
}
Ok(result)
})
.await
}
pub async fn delete_api_key(&self, id: i64) -> Result<bool, Error> {
self.pool
.conn_and_then(move |conn| {
let affected = conn.execute("DELETE FROM api_keys WHERE id = ?1", params![id])?;
Ok(affected > 0)
})
.await
}
}
#[async_trait]
impl ApiKeyRepo for Database {
async fn validate_api_key(&self, api_key: &str) -> Result<Option<Claims>, Error> {
self.validate_api_key(api_key).await
}
fn hmac_api_key(&self, raw_key: &str) -> String {
self.hmac_api_key(raw_key)
}
async fn insert_api_key(&self, key_hash: &str, name: &str, permission_level: &str) -> Result<i64, Error> {
self.insert_api_key(key_hash, name, permission_level).await
}
async fn list_api_keys(&self) -> Result<Vec<ApiKeyView>, Error> {
self.list_api_keys().await
}
async fn delete_api_key(&self, id: i64) -> Result<bool, Error> {
self.delete_api_key(id).await
}
}
#[cfg(test)]
mod tests {
use super::Database;
#[tokio::test]
async fn validate_full_access_api_key_grants_admin_permissions() {
let db = Database::new(":memory:").await.expect("test db");
let raw_key = "ng-test-full-access";
let digest = db.hmac_api_key(raw_key);
db.insert_api_key(&digest, "automation", "full_access").await.unwrap();
let claims = db.validate_api_key(raw_key).await.unwrap().expect("claims");
assert!(claims.permissions.contains(&"api_keys:admin".to_string()));
assert!(claims.permissions.contains(&"system:admin".to_string()));
assert!(claims.permissions.contains(&"users:admin".to_string()));
}
#[tokio::test]
async fn validate_read_write_api_key_does_not_grant_admin_permissions() {
let db = Database::new(":memory:").await.expect("test db");
let raw_key = "ng-test-read-write";
let digest = db.hmac_api_key(raw_key);
db.insert_api_key(&digest, "automation", "read_write").await.unwrap();
let claims = db.validate_api_key(raw_key).await.unwrap().expect("claims");
assert!(!claims.permissions.contains(&"api_keys:admin".to_string()));
assert!(!claims.permissions.contains(&"system:admin".to_string()));
assert!(!claims.permissions.contains(&"users:admin".to_string()));
}
}

View File

@ -0,0 +1,160 @@
use std::fmt::Write;
use async_trait::async_trait;
use chrono::Utc;
use rusqlite::params;
use sha2::{Digest, Sha256};
use super::Database;
use crate::domain::common::audit::AuditLogEntry;
use crate::domain::common::error::Error;
use crate::domain::common::error::database::DatabaseError;
use crate::interface::audit::AuditRepo;
fn audit_row_hash(ts: &str, actor: &str, action: &str, detail: &str, prev_hash: &str) -> String {
let mut h = Sha256::new();
for part in [ts, actor, action, detail, prev_hash] {
h.update(part.as_bytes());
h.update([0u8]);
}
let out = h.finalize();
let mut hex = String::with_capacity(64);
for byte in out {
let _ = write!(&mut hex, "{:02x}", byte);
}
hex
}
impl Database {
pub async fn insert_audit_log(&self, actor: &str, action: &str, detail: &str) -> Result<(), Error> {
let actor = actor.to_string();
let action = action.to_string();
let detail = detail.to_string();
self.pool
.conn_mut_and_then(move |conn| {
let ts = Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
let tx = conn.transaction()?;
let prev_hash: String = tx
.query_row("SELECT row_hash FROM audit_log ORDER BY id DESC LIMIT 1", [], |row| {
row.get(0)
})
.unwrap_or_default();
let row_hash = audit_row_hash(&ts, &actor, &action, &detail, &prev_hash);
tx.execute(
"INSERT INTO audit_log (ts, actor, action, detail, prev_hash, row_hash) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![ts, actor, action, detail, prev_hash, row_hash],
)?;
tx.commit()?;
Ok(())
})
.await
}
pub async fn list_audit_logs(&self) -> Result<Vec<AuditLogEntry>, Error> {
self.pool
.conn_and_then(move |conn| {
let mut stmt =
conn.prepare("SELECT id, actor, action, detail, ts FROM audit_log ORDER BY id DESC LIMIT 200")?;
let rows = stmt
.query_map([], |row| {
Ok(AuditLogEntry {
id: row.get(0)?,
actor: row.get(1)?,
action: row.get(2)?,
detail: row.get(3)?,
created_at: row.get(4)?,
})
})?
.filter_map(|r| r.ok())
.collect();
Ok(rows)
})
.await
}
pub async fn list_audit_logs_by_action(&self, action: &str, limit: i64) -> Result<Vec<AuditLogEntry>, Error> {
let action = action.to_string();
self.pool
.conn_and_then(move |conn| {
let mut stmt = conn.prepare(
"SELECT id, actor, action, detail, ts FROM audit_log WHERE action = ?1 ORDER BY id DESC LIMIT ?2",
)?;
let rows = stmt
.query_map(params![action, limit], |row| {
Ok(AuditLogEntry {
id: row.get(0)?,
actor: row.get(1)?,
action: row.get(2)?,
detail: row.get(3)?,
created_at: row.get(4)?,
})
})?
.filter_map(|r| r.ok())
.collect();
Ok(rows)
})
.await
}
pub async fn verify_audit_log_chain(&self, after_id: i64) -> Result<(usize, i64), Error> {
self.pool
.conn_and_then(move |conn| {
let mut expected_prev = if after_id > 0 {
conn.query_row(
"SELECT row_hash FROM audit_log WHERE id = ?1",
params![after_id],
|row| row.get::<_, String>(0),
)
.unwrap_or_default()
} else {
String::new()
};
let mut stmt = conn.prepare(
"SELECT id, ts, actor, action, detail, prev_hash, row_hash \
FROM audit_log WHERE id > ?1 ORDER BY id ASC",
)?;
let mut rows = stmt.query(params![after_id])?;
let mut count = 0usize;
let mut last_id = after_id;
while let Some(row) = rows.next()? {
let id: i64 = row.get(0)?;
let ts: String = row.get(1)?;
let actor: String = row.get(2)?;
let action: String = row.get(3)?;
let detail: String = row.get(4)?;
let prev_hash: String = row.get(5)?;
let row_hash: String = row.get(6)?;
if prev_hash != expected_prev {
return Err(DatabaseError::AuditPrevHashMismatch(id, expected_prev, prev_hash).into());
}
let computed = audit_row_hash(&ts, &actor, &action, &detail, &prev_hash);
if computed != row_hash {
return Err(DatabaseError::AuditRowHashMismatch(id, computed, row_hash).into());
}
expected_prev = row_hash;
last_id = id;
count += 1;
}
Ok((count, last_id))
})
.await
}
}
#[async_trait]
impl AuditRepo for Database {
async fn insert_audit_log(&self, actor: &str, action: &str, detail: &str) -> Result<(), Error> {
self.insert_audit_log(actor, action, detail).await
}
async fn list_audit_logs_by_action(&self, action: &str, limit: i64) -> Result<Vec<AuditLogEntry>, Error> {
self.list_audit_logs_by_action(action, limit).await
}
async fn verify_audit_log_chain(&self, after_id: i64) -> Result<(usize, i64), Error> {
self.verify_audit_log_chain(after_id).await
}
}

View File

@ -0,0 +1,153 @@
use async_trait::async_trait;
use rusqlite::{Error as RusqliteError, params};
use super::Database;
use crate::domain::common::error::Error;
use crate::interface::config_repo::ConfigRepo;
impl Database {
pub async fn get_config_value(&self, key: &str) -> Result<Option<String>, Error> {
let key = key.to_string();
self.pool
.conn_and_then(move |conn| {
let result = conn.query_row("SELECT value FROM settings WHERE key = ?1", params![key], |row| {
row.get(0)
});
match result {
Ok(val) => Ok(Some(val)),
Err(RusqliteError::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e.into()),
}
})
.await
}
pub async fn set_config_value(&self, key: &str, value: &str) -> Result<(), Error> {
let key = key.to_string();
let value = value.to_string();
self.pool
.conn_and_then(move |conn| {
conn.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
params![key, value],
)?;
Ok(())
})
.await
}
async fn get_app_secret(&self, key: &str) -> Result<Option<String>, Error> {
let key = key.to_string();
self.pool
.conn_and_then(move |conn| {
let result = conn.query_row("SELECT value FROM app_secrets WHERE key = ?1", params![key], |row| {
row.get(0)
});
match result {
Ok(val) => Ok(Some(val)),
Err(RusqliteError::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e.into()),
}
})
.await
}
async fn set_app_secret(&self, key: &str, value: &str) -> Result<(), Error> {
let key = key.to_string();
let value = value.to_string();
self.pool
.conn_and_then(move |conn| {
conn.execute(
"INSERT OR REPLACE INTO app_secrets (key, value) VALUES (?1, ?2)",
params![key, value],
)?;
Ok(())
})
.await
}
pub async fn get_notification_config(&self, channel: &str) -> Result<Option<String>, Error> {
let channel = channel.to_string();
self.pool
.conn_and_then(move |conn| {
match conn.query_row(
"SELECT config_json FROM notification_config WHERE channel = ?1 AND enabled = 1",
params![channel],
|row| row.get::<_, String>(0),
) {
Ok(json) => Ok(Some(json)),
Err(RusqliteError::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e.into()),
}
})
.await
}
pub async fn set_notification_config(&self, channel: &str, config_json: &str) -> Result<(), Error> {
let channel = channel.to_string();
let config_json = config_json.to_string();
self.pool
.conn_and_then(move |conn| {
conn.execute(
"INSERT INTO notification_config (channel, config_json) VALUES (?1, ?2) \
ON CONFLICT(channel) DO UPDATE SET config_json = ?2, updated_at = datetime('now')",
params![channel, config_json],
)?;
Ok(())
})
.await
}
}
#[async_trait]
impl ConfigRepo for Database {
async fn get_config_value(&self, key: &str) -> Result<Option<String>, Error> {
self.get_config_value(key).await
}
async fn set_config_value(&self, key: &str, value: &str) -> Result<(), Error> {
self.set_config_value(key, value).await
}
async fn get_app_secret(&self, key: &str) -> Result<Option<String>, Error> {
self.get_app_secret(key).await
}
async fn set_app_secret(&self, key: &str, plaintext: &str) -> Result<(), Error> {
self.set_app_secret(key, plaintext).await
}
async fn get_notification_config(&self, channel: &str) -> Result<Option<String>, Error> {
self.get_notification_config(channel).await
}
async fn set_notification_config(&self, channel: &str, config_json: &str) -> Result<(), Error> {
self.set_notification_config(channel, config_json).await
}
async fn update_config_values_atomically(
&self,
config_values: Vec<(String, String)>,
secrets: Vec<(String, String)>,
) -> Result<(), Error> {
self.pool
.conn_mut_and_then(move |conn| {
let tx = conn.transaction()?;
for (key, value) in config_values {
tx.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
params![key, value],
)?;
}
for (key, value) in secrets {
tx.execute(
"INSERT OR REPLACE INTO app_secrets (key, value) VALUES (?1, ?2)",
params![key, value],
)?;
}
tx.commit()?;
Ok(())
})
.await
}
}

View File

@ -0,0 +1,143 @@
use async_trait::async_trait;
use rusqlite::params;
use super::Database;
use crate::domain::common::error::Error;
use crate::interface::enforcement::EnforcementRepo;
impl Database {
pub async fn set_rate_limit(&self, key: &str, value: u64) -> Result<(), Error> {
let key = key.to_string();
self.pool
.conn_and_then(move |conn| {
conn.execute(
"INSERT OR REPLACE INTO rate_limit_config (key, value) VALUES (?1, ?2)",
params![key, value as i64],
)?;
Ok(())
})
.await
}
pub async fn load_rate_limit_config(&self) -> Result<Vec<(String, u64)>, Error> {
self.pool
.conn_and_then(move |conn| {
let mut stmt = conn.prepare("SELECT key, value FROM rate_limit_config")?;
let rows = stmt.query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as u64)))?;
let mut results = Vec::new();
for row in rows {
results.push(row?);
}
Ok(results)
})
.await
}
pub async fn insert_dns_domains(&self, domains: &[String]) -> Result<(), Error> {
let domains = domains.to_vec();
self.pool
.conn_mut_and_then(move |conn| {
let tx = conn.transaction()?;
for domain in domains {
tx.execute(
"INSERT OR IGNORE INTO dns_blacklist (domain) VALUES (?1)",
params![domain],
)?;
}
tx.commit()?;
Ok(())
})
.await
}
pub async fn delete_dns_domains(&self, domains: &[String]) -> Result<(), Error> {
let domains = domains.to_vec();
self.pool
.conn_mut_and_then(move |conn| {
let tx = conn.transaction()?;
for domain in domains {
tx.execute("DELETE FROM dns_blacklist WHERE domain = ?1", params![domain])?;
}
tx.commit()?;
Ok(())
})
.await
}
pub async fn load_dns_domains(&self) -> Result<Vec<String>, Error> {
self.pool
.conn_and_then(move |conn| {
let mut stmt = conn.prepare("SELECT domain FROM dns_blacklist")?;
let rows = stmt.query_map([], |row| row.get(0))?;
let mut results = Vec::new();
for row in rows {
results.push(row?);
}
Ok(results)
})
.await
}
pub async fn insert_geo_country(&self, code: &str) -> Result<(), Error> {
let code = code.to_string();
self.pool
.conn_and_then(move |conn| {
conn.execute(
"INSERT OR IGNORE INTO geo_blocked_countries (country_code) VALUES (?1)",
params![code],
)?;
Ok(())
})
.await
}
pub async fn delete_geo_country(&self, code: &str) -> Result<(), Error> {
let code = code.to_string();
self.pool
.conn_and_then(move |conn| {
conn.execute(
"DELETE FROM geo_blocked_countries WHERE country_code = ?1",
params![code],
)?;
Ok(())
})
.await
}
pub async fn load_geo_countries(&self) -> Result<Vec<String>, Error> {
self.pool
.conn_and_then(move |conn| {
let mut stmt = conn.prepare("SELECT country_code FROM geo_blocked_countries")?;
let rows = stmt.query_map([], |row| row.get(0))?;
let mut results = Vec::new();
for row in rows {
results.push(row?);
}
Ok(results)
})
.await
}
}
#[async_trait]
impl EnforcementRepo for Database {
async fn set_rate_limit(&self, key: &str, value: u64) -> Result<(), Error> {
self.set_rate_limit(key, value).await
}
async fn insert_dns_domains(&self, domains: &[String]) -> Result<(), Error> {
self.insert_dns_domains(domains).await
}
async fn delete_dns_domains(&self, domains: &[String]) -> Result<(), Error> {
self.delete_dns_domains(domains).await
}
async fn insert_geo_country(&self, code: &str) -> Result<(), Error> {
self.insert_geo_country(code).await
}
async fn delete_geo_country(&self, code: &str) -> Result<(), Error> {
self.delete_geo_country(code).await
}
}

View File

@ -1,3 +1,603 @@
pub mod repository;
mod acl;
mod api_key;
mod audit;
mod config;
mod enforcement;
mod report_snapshot;
mod soar;
mod soar_block;
mod stats;
mod system_state;
mod user;
pub use repository::Database;
use std::env;
use async_sqlite::{Client, ClientBuilder};
use macros::log;
use rusqlite::{self, Connection, params};
use crate::domain::common::error::Error;
use crate::domain::common::error::database::DatabaseError;
use crate::domain::common::log::misc::MiscLog;
impl From<rusqlite::Error> for DatabaseError {
fn from(e: rusqlite::Error) -> Self {
DatabaseError::QueryFailed(e)
}
}
impl From<rusqlite::Error> for Error {
fn from(e: rusqlite::Error) -> Self {
Self::Database(DatabaseError::from(e))
}
}
impl From<async_sqlite::Error> for Error {
fn from(e: async_sqlite::Error) -> Self {
Self::Database(DatabaseError::QueryFailed(e))
}
}
use crate::domain::identity::auth::{ADMIN_PERMISSIONS, GROUP_ADMIN, GROUP_VIEWER, VIEWER_PERMISSIONS};
/// Reads the SQLCipher encryption key from the environment variable `NETGUARDIA_DB_KEY`.
/// Returns `Some(key)` if set and non-empty, `None` otherwise (dev / unencrypted mode).
fn db_encryption_key() -> Option<String> {
match env::var("NETGUARDIA_DB_KEY") {
Ok(k) if !k.is_empty() => Some(k),
_ => None,
}
}
pub struct Database {
pool: Client,
/// HMAC-SHA256 key for API key hashing, derived from NETGUARDIA_SECRETS_KEY.
api_key_hmac: [u8; 32],
}
impl Database {
pub async fn new(path: &str) -> Result<Self, Error> {
let encryption_key = db_encryption_key();
if path != ":memory:" && encryption_key.is_none() {
log!(MiscLog::DbEncryptionDisabled);
}
let builder = if path == ":memory:" {
ClientBuilder::new()
} else {
ClientBuilder::new().path(path)
};
let pool = builder.open().await.map_err(DatabaseError::QueryFailed)?;
let key_for_pragmas = encryption_key.clone();
pool.conn(move |conn| {
if let Some(ref key) = key_for_pragmas {
conn.pragma_update(None, "key", key)?;
}
conn.execute_batch(
"PRAGMA journal_mode=WAL; \
PRAGMA synchronous=NORMAL; \
PRAGMA busy_timeout=5000; \
PRAGMA foreign_keys=ON;",
)?;
Ok(())
})
.await
.map_err(DatabaseError::QueryFailed)?;
// Verify the pool is actually usable (catches wrong key / corrupt DB early).
pool.conn(|conn| conn.query_row("SELECT count(*) FROM sqlite_master", [], |_| Ok(())))
.await
.map_err(|_| DatabaseError::EncryptionKeyInvalid)?;
let api_key_hmac = Self::derive_api_key_hmac();
let db = Self { pool, api_key_hmac };
db.create_tables().await?;
Ok(db)
}
/// Derive HMAC-SHA256 key for API key hashing from NETGUARDIA_SECRETS_KEY.
/// Falls back to a static dev key if the env var is unset.
fn derive_api_key_hmac() -> [u8; 32] {
use hkdf::Hkdf;
use sha2::Sha256;
let root_key = env::var("NETGUARDIA_SECRETS_KEY")
.ok()
.filter(|k| !k.is_empty())
.or_else(|| env::var("NETGUARDIA_DB_KEY").ok().filter(|k| !k.is_empty()))
.unwrap_or_else(|| "netguardia-dev-api-key-secret".to_string());
let hk = Hkdf::<Sha256>::new(Some(b"netguardia-v1-salt"), root_key.as_bytes());
let mut okm = [0u8; 32];
// SAFETY: 32 bytes is a valid output length for HKDF-SHA256
hk.expand(b"netguardia-apikey-hmac-v1", &mut okm).unwrap();
okm
}
/// Export an encrypted database to a plaintext copy.
/// The original file is NOT modified.
pub fn decrypt_to_file(src_path: &str, key: &str, dest_path: &str) -> Result<(), Error> {
let conn = Connection::open(src_path).map_err(DatabaseError::QueryFailed)?;
conn.pragma_update(None, "key", key)
.map_err(DatabaseError::QueryFailed)?;
// Verify we can read the encrypted DB
conn.query_row("SELECT count(*) FROM sqlite_master", [], |_| Ok(()))
.map_err(|_| DatabaseError::DatabaseNotReadable)?;
// Attach a plaintext destination (empty key = no encryption)
conn.execute("ATTACH DATABASE ?1 AS plaintext KEY '';", params![dest_path])
.map_err(DatabaseError::QueryFailed)?;
conn.query_row("SELECT sqlcipher_export('plaintext')", [], |_| Ok(()))
.map_err(DatabaseError::QueryFailed)?;
conn.execute_batch("DETACH DATABASE plaintext;")
.map_err(DatabaseError::QueryFailed)?;
Ok(())
}
/// Encrypt a plaintext database to a new encrypted copy.
/// The original file is NOT modified.
pub fn encrypt_to_file(src_path: &str, key: &str, dest_path: &str) -> Result<(), Error> {
let conn = Connection::open(src_path).map_err(DatabaseError::QueryFailed)?;
// Verify it's readable as plaintext
conn.query_row("SELECT count(*) FROM sqlite_master", [], |_| Ok(()))
.map_err(|_| DatabaseError::SourceDatabaseNotReadable)?;
conn.execute("ATTACH DATABASE ?1 AS encrypted KEY ?2;", params![dest_path, key])
.map_err(DatabaseError::QueryFailed)?;
conn.query_row("SELECT sqlcipher_export('encrypted')", [], |_| Ok(()))
.map_err(DatabaseError::QueryFailed)?;
conn.execute_batch("DETACH DATABASE encrypted;")
.map_err(DatabaseError::QueryFailed)?;
Ok(())
}
async fn create_tables(&self) -> Result<(), Error> {
self.pool
.conn_mut_and_then(|conn| {
conn.execute_batch(
"
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'viewer',
force_password_change INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS acl_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip_version INTEGER NOT NULL,
direction TEXT NOT NULL,
list_type TEXT NOT NULL,
ip_address TEXT NOT NULL,
port INTEGER NOT NULL,
UNIQUE(ip_version, direction, list_type, ip_address, port)
);
CREATE TABLE IF NOT EXISTS rate_limit_config (
key TEXT PRIMARY KEY,
value INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS dns_blacklist (
domain TEXT PRIMARY KEY
);
CREATE TABLE IF NOT EXISTS geo_blocked_countries (
country_code TEXT PRIMARY KEY
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS system_state (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS report_snapshots (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS login_attempts (
username TEXT PRIMARY KEY,
failure_count INTEGER NOT NULL DEFAULT 0,
locked_until INTEGER
);
CREATE TABLE IF NOT EXISTS user_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
description TEXT NOT NULL DEFAULT '',
permissions TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS user_group_members (
user_id INTEGER NOT NULL,
group_id INTEGER NOT NULL,
PRIMARY KEY (user_id, group_id),
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (group_id) REFERENCES user_groups(id)
);
-- SOAR tables
CREATE TABLE IF NOT EXISTS playbooks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
enabled INTEGER DEFAULT 1,
trigger_event TEXT NOT NULL,
condition_threshold REAL,
condition_count INTEGER,
condition_window_secs INTEGER,
cooldown_secs INTEGER DEFAULT 300,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS playbook_actions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
playbook_id INTEGER NOT NULL REFERENCES playbooks(id) ON DELETE CASCADE,
action_order INTEGER NOT NULL,
action_type TEXT NOT NULL,
params TEXT NOT NULL DEFAULT '{}',
UNIQUE(playbook_id, action_order)
);
CREATE TABLE IF NOT EXISTS playbook_conditions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
playbook_id INTEGER NOT NULL REFERENCES playbooks(id) ON DELETE CASCADE,
condition_type TEXT NOT NULL,
operator TEXT NOT NULL DEFAULT '>=',
value TEXT NOT NULL,
value2 TEXT,
UNIQUE(playbook_id, condition_type)
);
CREATE TABLE IF NOT EXISTS soar_block_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_ip TEXT NOT NULL,
playbook_id INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT NOT NULL,
unblocked_at TEXT,
created_acl_rule INTEGER NOT NULL DEFAULT 1,
preserve_acl_on_unblock INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS soar_executions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
playbook_id INTEGER NOT NULL,
source_ip TEXT,
trigger_event TEXT NOT NULL,
actions_executed TEXT NOT NULL DEFAULT '[]',
executed_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS admin_whitelist (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- MCP API keys
CREATE TABLE IF NOT EXISTS api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key_hash TEXT NOT NULL,
name TEXT NOT NULL,
permission_level TEXT NOT NULL DEFAULT 'read_only',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_used_at TEXT
);
-- Notification config (Telegram bot token, etc.)
CREATE TABLE IF NOT EXISTS notification_config (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channel TEXT NOT NULL UNIQUE,
config_json TEXT NOT NULL,
enabled INTEGER DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- App secrets (encryption keys for sensitive data)
CREATE TABLE IF NOT EXISTS app_secrets (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Pending unblock queue for orphan eBPF block recovery
CREATE TABLE IF NOT EXISTS pending_unblock (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_ip TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
retry_count INTEGER NOT NULL DEFAULT 0
);
-- Audit trail (WORM: hash-chained, triggers block UPDATE/DELETE)
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL,
actor TEXT NOT NULL,
action TEXT NOT NULL,
detail TEXT NOT NULL DEFAULT '{}',
prev_hash TEXT NOT NULL DEFAULT '',
row_hash TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_audit_log_action
ON audit_log(action, id DESC);
CREATE INDEX IF NOT EXISTS idx_soar_block_active_expires
ON soar_block_rules(expires_at)
WHERE unblocked_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_soar_block_created_at
ON soar_block_rules(created_at);
CREATE INDEX IF NOT EXISTS idx_soar_block_unblocked_at
ON soar_block_rules(unblocked_at)
WHERE unblocked_at IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_soar_block_source_created
ON soar_block_rules(source_ip, created_at);
CREATE INDEX IF NOT EXISTS idx_soar_executions_executed_at
ON soar_executions(executed_at DESC);
CREATE INDEX IF NOT EXISTS idx_soar_executions_trigger_executed
ON soar_executions(trigger_event, executed_at);
CREATE INDEX IF NOT EXISTS idx_user_group_members_group_id
ON user_group_members(group_id, user_id);
CREATE INDEX IF NOT EXISTS idx_api_keys_key_hash
ON api_keys(key_hash);
CREATE TRIGGER IF NOT EXISTS audit_log_no_update
BEFORE UPDATE ON audit_log BEGIN
SELECT RAISE(ABORT, 'audit_log is append-only (WORM)');
END;
CREATE TRIGGER IF NOT EXISTS audit_log_no_delete
BEFORE DELETE ON audit_log BEGIN
SELECT RAISE(ABORT, 'audit_log is append-only (WORM)');
END;
",
)?;
if !Self::column_exists(conn, "soar_block_rules", "created_acl_rule")? {
conn.execute(
"ALTER TABLE soar_block_rules ADD COLUMN created_acl_rule INTEGER NOT NULL DEFAULT 1",
[],
)?;
}
if !Self::column_exists(conn, "soar_block_rules", "preserve_acl_on_unblock")? {
conn.execute(
"ALTER TABLE soar_block_rules ADD COLUMN preserve_acl_on_unblock INTEGER NOT NULL DEFAULT 0",
[],
)?;
}
Self::migrate_legacy_settings_state(conn)?;
let group_count: i64 = conn.query_row("SELECT COUNT(*) FROM user_groups", [], |row| row.get(0))?;
if group_count == 0 {
let all_permissions =
serde_json::to_string(&ADMIN_PERMISSIONS).unwrap_or_else(|_| "[]".to_string());
let viewer_permissions =
serde_json::to_string(&VIEWER_PERMISSIONS).unwrap_or_else(|_| "[]".to_string());
conn.execute(
"INSERT INTO user_groups (name, description, permissions) VALUES (?1, ?2, ?3)",
params![GROUP_ADMIN, "Full system access with all permissions", &all_permissions],
)?;
conn.execute(
"INSERT INTO user_groups (name, description, permissions) VALUES (?1, ?2, ?3)",
params![GROUP_VIEWER, "Read-only access to all modules", &viewer_permissions],
)?;
}
Ok::<(), Error>(())
})
.await
}
fn column_exists(conn: &Connection, table: &str, column: &str) -> Result<bool, Error> {
let mut stmt = conn.prepare(&format!("PRAGMA table_info({})", table))?;
let rows = stmt.query_map([], |row| row.get::<_, String>(1))?;
for row in rows {
if row? == column {
return Ok(true);
}
}
Ok(false)
}
fn migrate_legacy_settings_state(conn: &Connection) -> Result<(), Error> {
conn.execute_batch(
"
INSERT OR IGNORE INTO system_state (key, value)
SELECT key, value FROM settings WHERE key = 'setup_complete';
INSERT OR IGNORE INTO report_snapshots (key, value)
SELECT key, value FROM settings
WHERE key IN (
'weekly_threats_count',
'weekly_top_ips',
'weekly_threat_breakdown',
'weekly_system_health',
'system_uptime_percent',
'active_rules_count',
'weekly_geo_distribution',
'weekly_soar_blocks',
'weekly_soar_triggers',
'weekly_soar_unblocks',
'weekly_blocked_count'
);
INSERT OR IGNORE INTO login_attempts (username, failure_count)
SELECT substr(key, length('login_failures:') + 1), CAST(value AS INTEGER)
FROM settings
WHERE key LIKE 'login_failures:%';
INSERT INTO login_attempts (username, locked_until)
SELECT substr(key, length('login_locked_until:') + 1), CAST(value AS INTEGER)
FROM settings
WHERE key LIKE 'login_locked_until:%'
ON CONFLICT(username) DO UPDATE SET
locked_until = COALESCE(login_attempts.locked_until, excluded.locked_until);
DELETE FROM settings
WHERE key = 'setup_complete'
OR key IN (
'weekly_threats_count',
'weekly_top_ips',
'weekly_threat_breakdown',
'weekly_system_health',
'system_uptime_percent',
'active_rules_count',
'weekly_geo_distribution',
'weekly_soar_blocks',
'weekly_soar_triggers',
'weekly_soar_unblocks',
'weekly_blocked_count'
)
OR key LIKE 'login_failures:%'
OR key LIKE 'login_locked_until:%';
",
)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::interface::acl::AclRepo;
use crate::interface::config_repo::ConfigRepo;
use crate::interface::identity::UserRepo;
pub(super) async fn test_db() -> Database {
Database::new(":memory:").await.expect("Failed to create test database")
}
#[tokio::test]
async fn test_create_tables() {
let _db = test_db().await;
}
/// Verify that Database satisfies each aggregate Repo trait contract
/// (AclRepo / ConfigRepo / UserRepo). Exercises the trait-object
/// path so callers that take `Arc<dyn XxxRepo>` compile end-to-end.
#[tokio::test]
async fn test_aggregate_repo_trait_objects() {
let db = test_db().await;
let setting: &dyn ConfigRepo = &db;
setting.set_config_value("test_key", "test_value").await.unwrap();
assert_eq!(
setting.get_config_value("test_key").await.unwrap(),
Some("test_value".to_string())
);
let acl: &dyn AclRepo = &db;
acl.insert_acl_rule(4, "source", "blacklist", "10.0.0.1", 443)
.await
.unwrap();
// load_acl_rules is an inherent Database method (not on AclRepo),
// so go through `&db` directly for this read-back assertion.
let rules = db.list_acl_rules().await.unwrap();
assert_eq!(rules.len(), 1);
let identity: &dyn UserRepo = &db;
// user_count is inherent — inserts still go through the trait so
// the vtable has something to exercise.
assert_eq!(db.user_count().await.unwrap(), 0);
identity.insert_user("test", "hash", "viewer", false).await.unwrap();
assert_eq!(db.user_count().await.unwrap(), 1);
}
#[tokio::test]
async fn legacy_non_config_settings_are_backfilled() {
let unique = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
let path = std::env::temp_dir().join(format!("netguardia-legacy-settings-{}-{unique}.db", std::process::id()));
let locked_until = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() + 3600;
{
let conn = Connection::open(&path).unwrap();
conn.execute_batch(
"
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
",
)
.unwrap();
conn.execute(
"INSERT INTO settings (key, value) VALUES (?1, ?2)",
params!["setup_complete", "true"],
)
.unwrap();
conn.execute(
"INSERT INTO settings (key, value) VALUES (?1, ?2)",
params!["weekly_threats_count", "7"],
)
.unwrap();
conn.execute(
"INSERT INTO settings (key, value) VALUES (?1, ?2)",
params!["login_failures:alice", "4"],
)
.unwrap();
conn.execute(
"INSERT INTO settings (key, value) VALUES (?1, ?2)",
params!["login_locked_until:alice", locked_until.to_string()],
)
.unwrap();
}
let db = Database::new(path.to_str().unwrap()).await.unwrap();
assert_eq!(
db.get_system_state("setup_complete").await.unwrap(),
Some("true".to_string())
);
assert_eq!(
db.get_report_snapshot("weekly_threats_count").await.unwrap(),
Some("7".to_string())
);
assert_eq!(db.get_config_value("setup_complete").await.unwrap(), None);
assert_eq!(db.get_config_value("weekly_threats_count").await.unwrap(), None);
let remaining_lock = db.check_login_locked("alice").await.unwrap();
assert!(remaining_lock.is_some_and(|remaining| remaining > 0));
assert_eq!(db.get_config_value("login_failures:alice").await.unwrap(), None);
drop(db);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(path.with_extension("db-wal"));
let _ = std::fs::remove_file(path.with_extension("db-shm"));
}
#[tokio::test]
async fn create_tables_installs_query_shape_indexes() {
let db = test_db().await;
let expected = [
("soar_block_rules", "idx_soar_block_active_expires"),
("soar_block_rules", "idx_soar_block_created_at"),
("soar_block_rules", "idx_soar_block_unblocked_at"),
("soar_block_rules", "idx_soar_block_source_created"),
("soar_executions", "idx_soar_executions_executed_at"),
("soar_executions", "idx_soar_executions_trigger_executed"),
("user_group_members", "idx_user_group_members_group_id"),
("api_keys", "idx_api_keys_key_hash"),
];
db.pool
.conn_and_then(move |conn| {
for (table, index) in expected {
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND tbl_name = ?1 AND name = ?2",
params![table, index],
|row| row.get(0),
)?;
assert_eq!(count, 1, "missing index {index} on {table}");
}
Ok::<(), Error>(())
})
.await
.unwrap();
}
}

View File

@ -0,0 +1,51 @@
use async_trait::async_trait;
use rusqlite::{Error as RusqliteError, params};
use super::Database;
use crate::domain::common::error::Error;
use crate::interface::report_snapshot::ReportSnapshotRepo;
impl Database {
pub async fn get_report_snapshot(&self, key: &str) -> Result<Option<String>, Error> {
let key = key.to_string();
self.pool
.conn_and_then(move |conn| {
let result = conn.query_row(
"SELECT value FROM report_snapshots WHERE key = ?1",
params![key],
|row| row.get(0),
);
match result {
Ok(value) => Ok(Some(value)),
Err(RusqliteError::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e.into()),
}
})
.await
}
pub async fn set_report_snapshot(&self, key: &str, value: &str) -> Result<(), Error> {
let key = key.to_string();
let value = value.to_string();
self.pool
.conn_and_then(move |conn| {
conn.execute(
"INSERT OR REPLACE INTO report_snapshots (key, value) VALUES (?1, ?2)",
params![key, value],
)?;
Ok(())
})
.await
}
}
#[async_trait]
impl ReportSnapshotRepo for Database {
async fn get_report_snapshot(&self, key: &str) -> Result<Option<String>, Error> {
self.get_report_snapshot(key).await
}
async fn set_report_snapshot(&self, key: &str, value: &str) -> Result<(), Error> {
self.set_report_snapshot(key, value).await
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,459 @@
use std::collections::HashMap;
use async_trait::async_trait;
use rusqlite::params;
use serde_json::Value;
use super::Database;
use crate::domain::common::error::Error;
use crate::domain::response::defaults::DEFAULT_PLAYBOOKS;
use crate::domain::response::playbook_data::{
ActionInput, ActionView, ActiveBlockView, ConditionView, CreateConditionInput, CreatePlaybookInput, ExecutionView,
PendingUnblock, PlaybookView, UpdatePlaybookInput,
};
use crate::interface::soar::SoarRepo;
struct PlaybookActionRow {
pb_id: i64,
name: String,
enabled: bool,
trigger_event: String,
condition_threshold: Option<f64>,
condition_count: Option<i64>,
condition_window_secs: Option<i64>,
cooldown_secs: i64,
action_id: Option<i64>,
action_order: Option<i64>,
action_type: Option<String>,
action_params: Option<String>,
}
impl Database {
pub async fn insert_playbook(
&self,
name: &str,
trigger_event: &str,
threshold: Option<f64>,
count: Option<i64>,
window: Option<i64>,
cooldown: i64,
) -> Result<i64, Error> {
let name = name.to_string();
let trigger_event = trigger_event.to_string();
self.pool
.conn_and_then(move |conn| {
conn.execute(
"INSERT INTO playbooks (name, trigger_event, condition_threshold, condition_count, condition_window_secs, cooldown_secs) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![name, trigger_event, threshold, count, window, cooldown],
)?;
Ok(conn.last_insert_rowid())
})
.await
}
pub async fn insert_playbook_action(
&self,
playbook_id: i64,
action_order: i64,
action_type: &str,
params_json: &str,
) -> Result<i64, Error> {
let action_type = action_type.to_string();
let params_json = params_json.to_string();
self.pool
.conn_and_then(move |conn| {
conn.execute(
"INSERT INTO playbook_actions (playbook_id, action_order, action_type, params) VALUES (?1, ?2, ?3, ?4)",
params![playbook_id, action_order, action_type, params_json],
)?;
Ok(conn.last_insert_rowid())
})
.await
}
async fn list_playbooks_with_actions(&self) -> Result<Vec<PlaybookActionRow>, Error> {
self.pool
.conn_and_then(move |conn| {
let mut stmt = conn.prepare(
"SELECT p.id, p.name, p.enabled, p.trigger_event, p.condition_threshold, \
p.condition_count, p.condition_window_secs, p.cooldown_secs, \
a.id, a.action_order, a.action_type, a.params \
FROM playbooks p \
LEFT JOIN playbook_actions a ON a.playbook_id = p.id \
ORDER BY p.id, a.action_order",
)?;
let rows = stmt.query_map([], |row| {
Ok(PlaybookActionRow {
pb_id: row.get::<_, i64>(0)?,
name: row.get::<_, String>(1)?,
enabled: row.get::<_, bool>(2)?,
trigger_event: row.get::<_, String>(3)?,
condition_threshold: row.get::<_, Option<f64>>(4)?,
condition_count: row.get::<_, Option<i64>>(5)?,
condition_window_secs: row.get::<_, Option<i64>>(6)?,
cooldown_secs: row.get::<_, i64>(7)?,
action_id: row.get::<_, Option<i64>>(8)?,
action_order: row.get::<_, Option<i64>>(9)?,
action_type: row.get::<_, Option<String>>(10)?,
action_params: row.get::<_, Option<String>>(11)?,
})
})?;
let mut result = Vec::new();
for row in rows {
result.push(row?);
}
Ok(result)
})
.await
}
pub async fn update_playbook_enabled(&self, id: i64, enabled: bool) -> Result<bool, Error> {
self.pool
.conn_and_then(move |conn| {
let rows = conn.execute(
"UPDATE playbooks SET enabled = ?2, updated_at = datetime('now') WHERE id = ?1",
params![id, enabled as i32],
)?;
Ok(rows > 0)
})
.await
}
pub async fn delete_playbook(&self, id: i64) -> Result<bool, Error> {
self.pool
.conn_and_then(move |conn| {
let rows = conn.execute("DELETE FROM playbooks WHERE id = ?1", params![id])?;
Ok(rows > 0)
})
.await
}
pub async fn insert_playbook_condition(
&self,
playbook_id: i64,
condition_type: &str,
operator: &str,
value: &str,
value2: Option<&str>,
) -> Result<i64, Error> {
let condition_type = condition_type.to_string();
let operator = operator.to_string();
let value = value.to_string();
let value2 = value2.map(str::to_string);
self.pool
.conn_and_then(move |conn| {
conn.execute(
"INSERT INTO playbook_conditions (playbook_id, condition_type, operator, value, value2) \
VALUES (?1, ?2, ?3, ?4, ?5)",
params![playbook_id, condition_type, operator, value, value2],
)?;
Ok(conn.last_insert_rowid())
})
.await
}
async fn list_all_playbook_conditions(&self) -> Result<Vec<(i64, ConditionView)>, Error> {
self.pool
.conn_and_then(move |conn| {
let mut stmt = conn.prepare(
"SELECT id, playbook_id, condition_type, operator, value, value2 \
FROM playbook_conditions ORDER BY playbook_id, id",
)?;
let rows = stmt.query_map([], |row| {
Ok((
row.get::<_, i64>(1)?,
ConditionView {
id: row.get::<_, i64>(0)?,
condition_type: row.get::<_, String>(2)?,
operator: row.get::<_, String>(3)?,
value: row.get::<_, String>(4)?,
value2: row.get::<_, Option<String>>(5)?,
},
))
})?;
let mut result = Vec::new();
for row in rows {
result.push(row?);
}
Ok(result)
})
.await
}
pub async fn insert_soar_execution(
&self,
playbook_id: i64,
source_ip: Option<&str>,
trigger_event: &str,
actions_json: &str,
) -> Result<i64, Error> {
let source_ip = source_ip.map(str::to_string);
let trigger_event = trigger_event.to_string();
let actions_json = actions_json.to_string();
self.pool
.conn_and_then(move |conn| {
conn.execute(
"INSERT INTO soar_executions (playbook_id, source_ip, trigger_event, actions_executed) VALUES (?1, ?2, ?3, ?4)",
params![playbook_id, source_ip, trigger_event, actions_json],
)?;
Ok(conn.last_insert_rowid())
})
.await
}
pub async fn list_soar_executions(&self, limit: i64) -> Result<Vec<ExecutionView>, Error> {
self.pool
.conn_and_then(move |conn| {
let mut stmt = conn.prepare(
"SELECT id, playbook_id, source_ip, trigger_event, actions_executed, executed_at FROM soar_executions ORDER BY executed_at DESC LIMIT ?1"
)?;
let rows = stmt.query_map(params![limit], |row| {
let actions_str: String = row.get(4)?;
Ok(ExecutionView {
id: row.get::<_, i64>(0)?,
playbook_id: row.get::<_, i64>(1)?,
source_ip: row.get::<_, Option<String>>(2)?,
trigger_event: row.get::<_, String>(3)?,
actions_executed: serde_json::from_str(&actions_str).unwrap_or(Value::Null),
created_at: row.get::<_, String>(5)?,
})
})?;
let mut result = Vec::new();
for row in rows {
result.push(row?);
}
Ok(result)
})
.await
}
pub async fn seed_default_playbooks(&self) -> Result<(), Error> {
let count: i64 = self
.pool
.conn_and_then(move |conn| {
Ok::<i64, Error>(conn.query_row("SELECT COUNT(*) FROM playbooks", [], |row| row.get(0))?)
})
.await?;
if count > 0 {
return Ok(());
}
for def in DEFAULT_PLAYBOOKS {
let pb_id = self
.insert_playbook(
def.name,
def.trigger_event,
def.threshold,
def.count,
def.window,
def.cooldown,
)
.await?;
for action in def.actions {
self.insert_playbook_action(pb_id, action.order, action.action_type, action.params)
.await?;
}
for cond in def.conditions {
self.insert_playbook_condition(pb_id, cond.condition_type, cond.operator, cond.value, cond.value2)
.await?;
}
}
Ok(())
}
}
#[async_trait]
impl SoarRepo for Database {
async fn list_playbooks(&self) -> Result<Vec<PlaybookView>, Error> {
let rows = self.list_playbooks_with_actions().await?;
let mut result: Vec<PlaybookView> = Vec::new();
for row in rows {
let pb = if let Some(position) = result.iter().position(|p| p.id == row.pb_id) {
&mut result[position]
} else {
result.push(PlaybookView {
id: row.pb_id,
name: row.name,
enabled: row.enabled,
trigger_event: row.trigger_event,
condition_threshold: row.condition_threshold,
condition_count: row.condition_count,
condition_window_secs: row.condition_window_secs,
cooldown_secs: row.cooldown_secs,
actions: Vec::new(),
conditions: Vec::new(),
});
result.last_mut().unwrap_or_else(|| unreachable!())
};
if let (Some(aid), Some(order), Some(atype), Some(params_str)) =
(row.action_id, row.action_order, row.action_type, row.action_params)
{
pb.actions.push(ActionView {
id: aid,
action_order: order,
action_type: atype,
params: serde_json::from_str(&params_str).unwrap_or(Value::Null),
});
}
}
let cond_rows = self.list_all_playbook_conditions().await?;
let mut cond_map: HashMap<i64, Vec<ConditionView>> = HashMap::new();
for (pb_id, cond) in cond_rows {
cond_map.entry(pb_id).or_default().push(cond);
}
for pb in &mut result {
if let Some(conds) = cond_map.remove(&pb.id) {
pb.conditions = conds;
}
}
Ok(result)
}
async fn update_playbook_enabled(&self, id: i64, enabled: bool) -> Result<bool, Error> {
self.update_playbook_enabled(id, enabled).await
}
async fn delete_playbook(&self, id: i64) -> Result<bool, Error> {
self.delete_playbook(id).await
}
async fn seed_default_playbooks(&self) -> Result<(), Error> {
self.seed_default_playbooks().await
}
async fn count_active_soar_blocks(&self) -> Result<u32, Error> {
self.count_active_soar_blocks().await
}
async fn list_active_soar_blocks(&self) -> Result<Vec<ActiveBlockView>, Error> {
self.list_active_soar_blocks().await
}
async fn find_soar_block_by_id(&self, id: i64) -> Result<Option<ActiveBlockView>, Error> {
self.find_soar_block_by_id(id).await
}
async fn list_expired_soar_blocks(&self) -> Result<Vec<ActiveBlockView>, Error> {
self.list_expired_soar_blocks().await
}
async fn mark_soar_block_unblocked(&self, id: i64) -> Result<(), Error> {
self.mark_soar_block_unblocked(id).await
}
async fn insert_pending_unblock(&self, source_ip: &str) -> Result<i64, Error> {
self.insert_pending_unblock(source_ip).await
}
async fn list_pending_unblocks(&self) -> Result<Vec<PendingUnblock>, Error> {
self.list_pending_unblocks().await
}
async fn delete_pending_unblock(&self, id: i64) -> Result<(), Error> {
self.delete_pending_unblock(id).await
}
async fn increment_pending_unblock_retry(&self, id: i64) -> Result<(), Error> {
self.increment_pending_unblock_retry(id).await
}
async fn insert_soar_execution(
&self,
playbook_id: i64,
source_ip: Option<&str>,
trigger_event: &str,
actions_json: &str,
) -> Result<i64, Error> {
self.insert_soar_execution(playbook_id, source_ip, trigger_event, actions_json)
.await
}
async fn list_soar_executions(&self, limit: i64) -> Result<Vec<ExecutionView>, Error> {
self.list_soar_executions(limit).await
}
async fn insert_playbook_atomic(
&self,
input: &CreatePlaybookInput,
actions: &[ActionInput],
conditions: &[CreateConditionInput],
) -> Result<i64, Error> {
let input = input.clone();
let actions = actions.to_vec();
let conditions = conditions.to_vec();
self.pool
.conn_mut_and_then(move |conn| {
let tx = conn.transaction()?;
tx.execute(
"INSERT INTO playbooks (name, trigger_event, condition_threshold, condition_count, condition_window_secs, cooldown_secs) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![input.name, input.trigger_event, input.condition_threshold, input.condition_count, input.condition_window_secs, input.cooldown_secs],
)?;
let playbook_id = tx.last_insert_rowid();
for a in actions {
tx.execute(
"INSERT INTO playbook_actions (playbook_id, action_order, action_type, params) VALUES (?1, ?2, ?3, ?4)",
params![playbook_id, a.action_order, a.action_type, a.params_json],
)?;
}
for c in conditions {
tx.execute(
"INSERT INTO playbook_conditions (playbook_id, condition_type, operator, value, value2) VALUES (?1, ?2, ?3, ?4, ?5)",
params![playbook_id, c.condition_type, c.operator, c.value, c.value2.as_deref()],
)?;
}
tx.commit()?;
Ok(playbook_id)
})
.await
}
async fn update_playbook_atomic(
&self,
id: i64,
row: &UpdatePlaybookInput,
actions: &[ActionInput],
conditions: &[CreateConditionInput],
) -> Result<bool, Error> {
let row = row.clone();
let actions = actions.to_vec();
let conditions = conditions.to_vec();
self.pool
.conn_mut_and_then(move |conn| {
let tx = conn.transaction()?;
let rows_updated = tx.execute(
"UPDATE playbooks SET name = ?2, trigger_event = ?3, condition_threshold = ?4, \
condition_count = ?5, condition_window_secs = ?6, cooldown_secs = ?7, \
updated_at = datetime('now') WHERE id = ?1",
params![
id,
row.name,
row.trigger_event,
row.condition_threshold,
row.condition_count,
row.condition_window_secs,
row.cooldown_secs
],
)?;
if rows_updated == 0 {
return Ok(false);
}
tx.execute("DELETE FROM playbook_actions WHERE playbook_id = ?1", params![id])?;
tx.execute("DELETE FROM playbook_conditions WHERE playbook_id = ?1", params![id])?;
for a in actions {
tx.execute(
"INSERT INTO playbook_actions (playbook_id, action_order, action_type, params) VALUES (?1, ?2, ?3, ?4)",
params![id, a.action_order, a.action_type, a.params_json],
)?;
}
for c in conditions {
tx.execute(
"INSERT INTO playbook_conditions (playbook_id, condition_type, operator, value, value2) VALUES (?1, ?2, ?3, ?4, ?5)",
params![id, c.condition_type, c.operator, c.value, c.value2.as_deref()],
)?;
}
tx.commit()?;
Ok(true)
})
.await
}
}

View File

@ -0,0 +1,322 @@
use async_trait::async_trait;
use rusqlite::params;
use super::Database;
use crate::domain::common::error::Error;
use crate::domain::response::playbook_data::{ActiveBlockView, PendingUnblock};
use crate::interface::db_admin::DbAdminRepo;
fn active_block_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<ActiveBlockView> {
Ok(ActiveBlockView {
id: row.get::<_, i64>(0)?,
source_ip: row.get::<_, String>(1)?,
playbook_id: row.get::<_, i64>(2)?,
expires_at: row.get::<_, String>(3)?,
})
}
impl Database {
pub async fn count_active_soar_blocks(&self) -> Result<u32, Error> {
self.pool
.conn_and_then(move |conn| {
let count: u32 = conn.query_row(
"SELECT COUNT(*) FROM soar_block_rules WHERE unblocked_at IS NULL AND expires_at > datetime('now')",
[],
|row| row.get(0),
)?;
Ok(count)
})
.await
}
pub async fn list_expired_soar_blocks(&self) -> Result<Vec<ActiveBlockView>, Error> {
self.pool
.conn_and_then(move |conn| {
let mut stmt = conn.prepare(
"SELECT id, source_ip, playbook_id, expires_at FROM soar_block_rules WHERE expires_at <= datetime('now') AND unblocked_at IS NULL",
)?;
let rows = stmt.query_map([], active_block_from_row)?;
let mut result = Vec::new();
for row in rows {
result.push(row?);
}
Ok(result)
})
.await
}
pub async fn find_soar_block_by_id(&self, id: i64) -> Result<Option<ActiveBlockView>, Error> {
self.pool
.conn_and_then(move |conn| {
let result = conn.query_row(
"SELECT id, source_ip, playbook_id, expires_at FROM soar_block_rules WHERE id = ?1",
params![id],
active_block_from_row,
);
match result {
Ok(block) => Ok(Some(block)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e.into()),
}
})
.await
}
pub async fn mark_soar_block_unblocked(&self, id: i64) -> Result<(), Error> {
self.pool
.conn_and_then(move |conn| {
conn.execute(
"UPDATE soar_block_rules SET unblocked_at = datetime('now') WHERE id = ?1",
params![id],
)?;
Ok(())
})
.await
}
pub async fn list_active_soar_blocks(&self) -> Result<Vec<ActiveBlockView>, Error> {
self.pool
.conn_and_then(move |conn| {
let mut stmt = conn.prepare(
"SELECT id, source_ip, playbook_id, expires_at FROM soar_block_rules WHERE unblocked_at IS NULL AND expires_at > datetime('now')"
)?;
let rows = stmt.query_map([], active_block_from_row)?;
let mut result = Vec::new();
for row in rows {
result.push(row?);
}
Ok(result)
})
.await
}
pub async fn insert_pending_unblock(&self, source_ip: &str) -> Result<i64, Error> {
let source_ip = source_ip.to_string();
self.pool
.conn_and_then(move |conn| {
conn.execute(
"INSERT INTO pending_unblock (source_ip) VALUES (?1)",
params![source_ip],
)?;
Ok(conn.last_insert_rowid())
})
.await
}
pub async fn list_pending_unblocks(&self) -> Result<Vec<PendingUnblock>, Error> {
self.pool
.conn_and_then(move |conn| {
let mut stmt = conn.prepare("SELECT id, source_ip, retry_count FROM pending_unblock ORDER BY id")?;
let rows = stmt.query_map([], |row| {
Ok(PendingUnblock {
id: row.get::<_, i64>(0)?,
source_ip: row.get::<_, String>(1)?,
retry_count: row.get::<_, i64>(2)?,
})
})?;
let mut result = Vec::new();
for row in rows {
result.push(row?);
}
Ok(result)
})
.await
}
pub async fn delete_pending_unblock(&self, id: i64) -> Result<(), Error> {
self.pool
.conn_and_then(move |conn| {
conn.execute("DELETE FROM pending_unblock WHERE id = ?1", params![id])?;
Ok(())
})
.await
}
pub async fn increment_pending_unblock_retry(&self, id: i64) -> Result<(), Error> {
self.pool
.conn_and_then(move |conn| {
conn.execute(
"UPDATE pending_unblock SET retry_count = retry_count + 1 WHERE id = ?1",
params![id],
)?;
Ok(())
})
.await
}
pub async fn commit_soar_block_to_db(
&self,
source_ip: &str,
ip_version: u8,
playbook_id: i64,
expires_at: &str,
) -> Result<i64, Error> {
let source_ip = source_ip.to_string();
let expires_at = expires_at.to_string();
self.pool
.conn_mut_and_then(move |conn| {
let tx = conn.transaction()?;
let existing_acl_count: i64 = tx.query_row(
"SELECT COUNT(*) FROM acl_rules
WHERE ip_version = ?1 AND direction = ?2 AND list_type = ?3 AND ip_address = ?4 AND port = ?5",
params![ip_version, "source", "blacklist", source_ip.as_str(), 0i64],
|row| row.get(0),
)?;
let active_soar_owned_count: i64 = tx.query_row(
"SELECT COUNT(*) FROM soar_block_rules
WHERE source_ip = ?1
AND unblocked_at IS NULL
AND expires_at > datetime('now')
AND created_acl_rule = 1
AND preserve_acl_on_unblock = 0",
params![source_ip.as_str()],
|row| row.get(0),
)?;
let created_acl_rule = existing_acl_count == 0 || active_soar_owned_count > 0;
tx.execute(
"INSERT INTO soar_block_rules (source_ip, playbook_id, expires_at, created_acl_rule)
VALUES (?1, ?2, ?3, ?4)",
params![source_ip.as_str(), playbook_id, expires_at, created_acl_rule as i64],
)?;
let soar_block_id = tx.last_insert_rowid();
tx.execute(
"INSERT OR IGNORE INTO acl_rules (ip_version, direction, list_type, ip_address, port) VALUES (?1, ?2, ?3, ?4, ?5)",
params![ip_version, "source", "blacklist", source_ip.as_str(), 0i64],
)?;
tx.commit()?;
Ok(soar_block_id)
})
.await
}
pub async fn commit_soar_unblock_to_db(
&self,
soar_block_id: i64,
ip_version: u8,
source_ip: &str,
) -> Result<(), Error> {
let source_ip = source_ip.to_string();
self.pool
.conn_mut_and_then(move |conn| {
let tx = conn.transaction()?;
let should_delete_acl: bool = tx.query_row(
"SELECT created_acl_rule = 1
AND preserve_acl_on_unblock = 0
AND NOT EXISTS (
SELECT 1 FROM soar_block_rules
WHERE id != ?1
AND source_ip = ?2
AND unblocked_at IS NULL
AND expires_at > datetime('now')
)
FROM soar_block_rules WHERE id = ?1",
params![soar_block_id, source_ip.as_str()],
|row| row.get(0),
)?;
if should_delete_acl {
tx.execute(
"DELETE FROM acl_rules
WHERE ip_version = ?1 AND direction = ?2 AND list_type = ?3 AND ip_address = ?4 AND port = ?5",
params![ip_version, "source", "blacklist", source_ip.as_str(), 0i64],
)?;
}
tx.execute(
"UPDATE soar_block_rules SET unblocked_at = datetime('now') WHERE id = ?1",
params![soar_block_id],
)?;
tx.commit()?;
Ok(())
})
.await
}
}
#[async_trait]
impl DbAdminRepo for Database {
async fn commit_soar_block_to_db(
&self,
source_ip: &str,
ip_version: u8,
playbook_id: i64,
expires_at: &str,
) -> Result<i64, Error> {
self.commit_soar_block_to_db(source_ip, ip_version, playbook_id, expires_at)
.await
}
async fn commit_soar_unblock_to_db(
&self,
soar_block_id: i64,
ip_version: u8,
source_ip: &str,
) -> Result<(), Error> {
self.commit_soar_unblock_to_db(soar_block_id, ip_version, source_ip)
.await
}
}
#[cfg(test)]
mod tests {
use super::*;
const SOURCE_IP: &str = "198.51.100.42";
const ACTIVE_UNTIL: &str = "2999-01-01 00:00:00";
fn acl_contains(rules: &[AclRuleViewForTest], ip: &str) -> bool {
rules
.iter()
.any(|rule| rule.ip_address == ip && rule.direction == "source" && rule.list_type == "blacklist")
}
type AclRuleViewForTest = crate::domain::data_plane::acl_rule::AclRuleView;
#[tokio::test]
async fn overlapping_soar_blocks_keep_acl_until_last_block_unblocks() {
let db = Database::new(":memory:").await.expect("database");
let first = db
.commit_soar_block_to_db(SOURCE_IP, 4, 1, ACTIVE_UNTIL)
.await
.expect("first block");
let second = db
.commit_soar_block_to_db(SOURCE_IP, 4, 2, ACTIVE_UNTIL)
.await
.expect("second block");
db.commit_soar_unblock_to_db(first, 4, SOURCE_IP)
.await
.expect("first unblock");
assert!(
acl_contains(&db.list_acl_rules().await.expect("acl after first unblock"), SOURCE_IP),
"ACL row must stay while an overlapping SOAR block is active"
);
db.commit_soar_unblock_to_db(second, 4, SOURCE_IP)
.await
.expect("second unblock");
assert!(
!acl_contains(&db.list_acl_rules().await.expect("acl after second unblock"), SOURCE_IP),
"last SOAR-owned block should remove the SOAR-owned ACL row"
);
}
#[tokio::test]
async fn manual_acl_existing_before_soar_block_is_preserved_on_unblock() {
let db = Database::new(":memory:").await.expect("database");
db.insert_acl_rule(4, "source", "blacklist", SOURCE_IP, 0)
.await
.expect("manual acl");
let block = db
.commit_soar_block_to_db(SOURCE_IP, 4, 1, ACTIVE_UNTIL)
.await
.expect("soar block");
db.commit_soar_unblock_to_db(block, 4, SOURCE_IP)
.await
.expect("unblock");
assert!(
acl_contains(&db.list_acl_rules().await.expect("acl rules"), SOURCE_IP),
"pre-existing manual ACL row must remain after SOAR unblock"
);
}
}

View File

@ -0,0 +1,126 @@
use async_trait::async_trait;
use rusqlite::params;
use super::Database;
use crate::domain::common::error::Error;
use crate::domain::report::data::{ThreatBreakdownEntry, TopIpEntry};
use crate::interface::stats::StatsRepo;
impl Database {
pub async fn count_weekly_executions(&self, days: i64) -> Result<u64, Error> {
self.pool
.conn_and_then(move |conn| {
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM soar_executions WHERE executed_at >= datetime('now', ?1)",
params![format!("-{} days", days)],
|row| row.get(0),
)?;
Ok(count as u64)
})
.await
}
pub async fn count_weekly_blocks(&self, days: i64) -> Result<u64, Error> {
self.pool
.conn_and_then(move |conn| {
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM soar_block_rules WHERE created_at >= datetime('now', ?1)",
params![format!("-{} days", days)],
|row| row.get(0),
)?;
Ok(count as u64)
})
.await
}
pub async fn count_weekly_unblocks(&self, days: i64) -> Result<u64, Error> {
self.pool
.conn_and_then(move |conn| {
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM soar_block_rules WHERE unblocked_at IS NOT NULL AND unblocked_at >= datetime('now', ?1)",
params![format!("-{} days", days)],
|row| row.get(0),
)?;
Ok(count as u64)
})
.await
}
pub async fn weekly_threat_breakdown(&self, days: i64) -> Result<Vec<ThreatBreakdownEntry>, Error> {
self.pool
.conn_and_then(move |conn| {
let mut stmt = conn.prepare(
"SELECT trigger_event, COUNT(*) FROM soar_executions WHERE executed_at >= datetime('now', ?1) GROUP BY trigger_event ORDER BY COUNT(*) DESC"
)?;
let rows = stmt.query_map(params![format!("-{} days", days)], |row| {
Ok(ThreatBreakdownEntry {
threat_type: row.get(0)?,
count: row.get::<_, i64>(1)? as u64,
})
})?;
let mut result = Vec::new();
for row in rows {
result.push(row?);
}
Ok(result)
})
.await
}
pub async fn weekly_top_ips(&self, days: i64, limit: i64) -> Result<Vec<TopIpEntry>, Error> {
self.pool
.conn_and_then(move |conn| {
let mut stmt = conn.prepare(
"SELECT source_ip, COUNT(*) as cnt FROM soar_block_rules WHERE created_at >= datetime('now', ?1) GROUP BY source_ip ORDER BY cnt DESC LIMIT ?2"
)?;
let rows = stmt.query_map(params![format!("-{} days", days), limit], |row| {
Ok(TopIpEntry {
ip: row.get(0)?,
count: row.get::<_, i64>(1)? as u64,
})
})?;
let mut result = Vec::new();
for row in rows {
result.push(row?);
}
Ok(result)
})
.await
}
pub async fn count_acl_rules(&self) -> Result<u64, Error> {
self.pool
.conn_and_then(move |conn| {
let count: i64 = conn.query_row("SELECT COUNT(*) FROM acl_rules", [], |row| row.get(0))?;
Ok(count as u64)
})
.await
}
}
#[async_trait]
impl StatsRepo for Database {
async fn count_weekly_executions(&self, days: i64) -> Result<u64, Error> {
self.count_weekly_executions(days).await
}
async fn count_weekly_blocks(&self, days: i64) -> Result<u64, Error> {
self.count_weekly_blocks(days).await
}
async fn count_weekly_unblocks(&self, days: i64) -> Result<u64, Error> {
self.count_weekly_unblocks(days).await
}
async fn weekly_threat_breakdown(&self, days: i64) -> Result<Vec<ThreatBreakdownEntry>, Error> {
self.weekly_threat_breakdown(days).await
}
async fn weekly_top_ips(&self, days: i64, limit: i64) -> Result<Vec<TopIpEntry>, Error> {
self.weekly_top_ips(days, limit).await
}
async fn count_acl_rules(&self) -> Result<u64, Error> {
self.count_acl_rules().await
}
}

View File

@ -0,0 +1,49 @@
use async_trait::async_trait;
use rusqlite::{Error as RusqliteError, params};
use super::Database;
use crate::domain::common::error::Error;
use crate::interface::system_state::SystemStateRepo;
impl Database {
pub async fn get_system_state(&self, key: &str) -> Result<Option<String>, Error> {
let key = key.to_string();
self.pool
.conn_and_then(move |conn| {
let result = conn.query_row("SELECT value FROM system_state WHERE key = ?1", params![key], |row| {
row.get(0)
});
match result {
Ok(value) => Ok(Some(value)),
Err(RusqliteError::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e.into()),
}
})
.await
}
pub async fn set_system_state(&self, key: &str, value: &str) -> Result<(), Error> {
let key = key.to_string();
let value = value.to_string();
self.pool
.conn_and_then(move |conn| {
conn.execute(
"INSERT OR REPLACE INTO system_state (key, value) VALUES (?1, ?2)",
params![key, value],
)?;
Ok(())
})
.await
}
}
#[async_trait]
impl SystemStateRepo for Database {
async fn get_system_state(&self, key: &str) -> Result<Option<String>, Error> {
self.get_system_state(key).await
}
async fn set_system_state(&self, key: &str, value: &str) -> Result<(), Error> {
self.set_system_state(key, value).await
}
}

View File

@ -0,0 +1,579 @@
use std::collections::{HashMap, HashSet};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use async_trait::async_trait;
use rusqlite::{Error as RusqliteError, params};
use super::Database;
use crate::domain::common::error::Error;
use crate::domain::common::error::database::DatabaseError;
use crate::domain::identity::auth::{GROUP_ADMIN, GROUP_VIEWER, LOGIN_LOCKOUT_SECS, LOGIN_MAX_FAILURES, ROLE_ADMIN};
use crate::domain::identity::user::{
GroupMemberView, UserGroupMembership, UserGroupView, UserView, UserWithGroupsView,
};
use crate::interface::identity::{LoginAttemptRepo, UserGroupRepo, UserRepo};
fn user_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<UserView> {
Ok(UserView {
id: row.get(0)?,
username: row.get(1)?,
password_hash: row.get(2)?,
force_password_change: row.get::<_, i64>(3)? != 0,
})
}
fn group_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<UserGroupView> {
Ok(UserGroupView {
id: row.get(0)?,
name: row.get(1)?,
description: row.get(2)?,
permissions: row.get(3)?,
created_at: row.get(4)?,
})
}
impl Database {
pub async fn find_user(&self, username: &str) -> Result<Option<UserView>, Error> {
let username = username.to_string();
self.pool
.conn_and_then(move |conn| {
match conn.query_row(
"SELECT id, username, password_hash, force_password_change FROM users WHERE username = ?1",
params![username],
user_from_row,
) {
Ok(user) => Ok(Some(user)),
Err(RusqliteError::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e.into()),
}
})
.await
}
pub async fn insert_user(
&self,
username: &str,
password_hash: &str,
role: &str,
force_password_change: bool,
) -> Result<i64, Error> {
let username = username.to_string();
let password_hash = password_hash.to_string();
let role = role.to_string();
self.pool
.conn_and_then(move |conn| {
conn.execute(
"INSERT INTO users (username, password_hash, role, force_password_change) VALUES (?1, ?2, ?3, ?4)",
params![username, password_hash, role, force_password_change as i64],
)
.map_err(|e| -> Error {
if e.to_string().contains("UNIQUE constraint") {
DatabaseError::UserAlreadyExists(username.clone()).into()
} else {
e.into()
}
})?;
Ok(conn.last_insert_rowid())
})
.await
}
pub async fn update_user_password(&self, user_id: i64, password_hash: &str) -> Result<(), Error> {
let password_hash = password_hash.to_string();
self.pool
.conn_and_then(move |conn| {
conn.execute(
"UPDATE users SET password_hash = ?1, force_password_change = 0 WHERE id = ?2",
params![password_hash, user_id],
)?;
Ok(())
})
.await
}
pub async fn user_count(&self) -> Result<i64, Error> {
self.pool
.conn_and_then(move |conn| Ok(conn.query_row("SELECT COUNT(*) FROM users", [], |row| row.get(0))?))
.await
}
pub async fn list_users_with_groups(&self) -> Result<Vec<UserWithGroupsView>, Error> {
self.pool
.conn_and_then(move |conn| {
let mut stmt = conn.prepare(
"SELECT u.id, u.username, u.force_password_change, u.created_at, \
g.id, g.name \
FROM users u \
LEFT JOIN user_group_members m ON u.id = m.user_id \
LEFT JOIN user_groups g ON g.id = m.group_id \
ORDER BY u.id, g.id",
)?;
let rows = stmt.query_map([], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, i64>(2)? != 0,
row.get::<_, String>(3)?,
row.get::<_, Option<i64>>(4)?,
row.get::<_, Option<String>>(5)?,
))
})?;
let mut user_map: HashMap<i64, UserWithGroupsView> = HashMap::new();
let mut order: Vec<i64> = Vec::new();
for row in rows {
let (id, username, force_pw, created_at, group_id, group_name) = row?;
let entry = user_map.entry(id).or_insert_with(|| {
order.push(id);
UserWithGroupsView {
id,
username,
force_password_change: force_pw,
created_at,
groups: Vec::new(),
}
});
if let (Some(gid), Some(gname)) = (group_id, group_name) {
entry.groups.push(UserGroupMembership {
group_id: gid,
group_name: gname,
});
}
}
Ok(order.into_iter().filter_map(|id| user_map.remove(&id)).collect())
})
.await
}
pub async fn delete_user(&self, user_id: i64) -> Result<bool, Error> {
self.pool
.conn_mut_and_then(move |conn| {
let tx = conn.transaction()?;
tx.execute("DELETE FROM user_group_members WHERE user_id = ?1", params![user_id])?;
let affected = tx.execute("DELETE FROM users WHERE id = ?1", params![user_id])?;
tx.commit()?;
Ok(affected > 0)
})
.await
}
pub async fn update_user_role(&self, user_id: i64, role: &str) -> Result<(), Error> {
let role = role.to_string();
self.pool
.conn_mut_and_then(move |conn| {
let tx = conn.transaction()?;
tx.execute("UPDATE users SET role = ?1 WHERE id = ?2", params![role, user_id])?;
let group_name = if role == ROLE_ADMIN { GROUP_ADMIN } else { GROUP_VIEWER };
let group_id: i64 = tx.query_row(
"SELECT id FROM user_groups WHERE name = ?1",
params![group_name],
|row| row.get(0),
)?;
tx.execute("DELETE FROM user_group_members WHERE user_id = ?1", params![user_id])?;
tx.execute(
"INSERT INTO user_group_members (user_id, group_id) VALUES (?1, ?2)",
params![user_id, group_id],
)?;
tx.commit()?;
Ok(())
})
.await
}
pub async fn reset_user_password(&self, user_id: i64, password_hash: &str) -> Result<(), Error> {
let password_hash = password_hash.to_string();
self.pool
.conn_and_then(move |conn| {
conn.execute(
"UPDATE users SET password_hash = ?1, force_password_change = 1 WHERE id = ?2",
params![password_hash, user_id],
)?;
Ok(())
})
.await
}
pub async fn find_user_by_id(&self, user_id: i64) -> Result<Option<UserView>, Error> {
self.pool
.conn_and_then(move |conn| {
match conn.query_row(
"SELECT id, username, password_hash, force_password_change FROM users WHERE id = ?1",
params![user_id],
user_from_row,
) {
Ok(user) => Ok(Some(user)),
Err(RusqliteError::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e.into()),
}
})
.await
}
pub async fn list_user_groups(&self) -> Result<Vec<UserGroupView>, Error> {
self.pool
.conn_and_then(move |conn| {
let mut stmt =
conn.prepare("SELECT id, name, description, permissions, created_at FROM user_groups ORDER BY id")?;
let rows = stmt.query_map([], group_from_row)?;
let mut results = Vec::new();
for row in rows {
results.push(row?);
}
Ok(results)
})
.await
}
pub async fn create_user_group(&self, name: &str, description: &str, permissions: &str) -> Result<i64, Error> {
let name = name.to_string();
let description = description.to_string();
let permissions = permissions.to_string();
self.pool
.conn_and_then(move |conn| {
conn.execute(
"INSERT INTO user_groups (name, description, permissions) VALUES (?1, ?2, ?3)",
params![name, description, permissions],
)
.map_err(|e| -> Error {
if e.to_string().contains("UNIQUE constraint") {
DatabaseError::GroupAlreadyExists(name.clone()).into()
} else {
e.into()
}
})?;
Ok(conn.last_insert_rowid())
})
.await
}
pub async fn update_user_group(
&self,
id: i64,
name: &str,
description: &str,
permissions: &str,
) -> Result<(), Error> {
let name = name.to_string();
let description = description.to_string();
let permissions = permissions.to_string();
self.pool
.conn_and_then(move |conn| {
conn.execute(
"UPDATE user_groups SET name = ?1, description = ?2, permissions = ?3 WHERE id = ?4",
params![name, description, permissions, id],
)?;
Ok(())
})
.await
}
pub async fn delete_user_group(&self, id: i64) -> Result<bool, Error> {
self.pool
.conn_mut_and_then(move |conn| {
let tx = conn.transaction()?;
tx.execute("DELETE FROM user_group_members WHERE group_id = ?1", params![id])?;
let affected = tx.execute("DELETE FROM user_groups WHERE id = ?1", params![id])?;
tx.commit()?;
Ok(affected > 0)
})
.await
}
pub async fn get_user_group(&self, id: i64) -> Result<Option<UserGroupView>, Error> {
self.pool
.conn_and_then(move |conn| {
match conn.query_row(
"SELECT id, name, description, permissions, created_at FROM user_groups WHERE id = ?1",
params![id],
group_from_row,
) {
Ok(group) => Ok(Some(group)),
Err(RusqliteError::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e.into()),
}
})
.await
}
pub async fn list_groups_for_user(&self, user_id: i64) -> Result<Vec<UserGroupView>, Error> {
self.pool
.conn_and_then(move |conn| {
let mut stmt = conn.prepare(
"SELECT g.id, g.name, g.description, g.permissions, g.created_at FROM user_groups g \
INNER JOIN user_group_members m ON g.id = m.group_id \
WHERE m.user_id = ?1 ORDER BY g.id",
)?;
let rows = stmt.query_map(params![user_id], group_from_row)?;
let mut results = Vec::new();
for row in rows {
results.push(row?);
}
Ok(results)
})
.await
}
pub async fn set_user_groups(&self, user_id: i64, group_ids: &[i64]) -> Result<(), Error> {
let group_ids = group_ids.to_vec();
self.pool
.conn_mut_and_then(move |conn| {
let tx = conn.transaction()?;
for &gid in &group_ids {
tx.query_row("SELECT id FROM user_groups WHERE id = ?1", params![gid], |row| {
row.get::<_, i64>(0)
})?;
}
tx.execute("DELETE FROM user_group_members WHERE user_id = ?1", params![user_id])?;
for &gid in &group_ids {
tx.execute(
"INSERT INTO user_group_members (user_id, group_id) VALUES (?1, ?2)",
params![user_id, gid],
)?;
}
tx.commit()?;
Ok(())
})
.await
}
pub async fn list_user_permissions(&self, user_id: i64) -> Result<Vec<String>, Error> {
let groups = self.list_groups_for_user(user_id).await?;
let mut all_perms = HashSet::new();
for g in groups {
if let Ok(perms) = serde_json::from_str::<Vec<String>>(&g.permissions) {
for p in perms {
all_perms.insert(p);
}
}
}
let mut result: Vec<String> = all_perms.into_iter().collect();
result.sort();
Ok(result)
}
pub async fn list_group_member_ids(&self, group_id: i64) -> Result<Vec<i64>, Error> {
self.pool
.conn_and_then(move |conn| {
let mut stmt = conn.prepare("SELECT user_id FROM user_group_members WHERE group_id = ?1")?;
let rows = stmt.query_map(params![group_id], |row| row.get::<_, i64>(0))?;
let mut results = Vec::new();
for row in rows {
results.push(row?);
}
Ok(results)
})
.await
}
pub async fn list_group_members(&self, group_id: i64) -> Result<Vec<GroupMemberView>, Error> {
self.pool
.conn_and_then(move |conn| {
let mut stmt = conn.prepare(
"SELECT u.id, u.username FROM users u \
INNER JOIN user_group_members m ON u.id = m.user_id \
WHERE m.group_id = ?1 ORDER BY u.username",
)?;
let rows = stmt.query_map(params![group_id], |row| {
Ok(GroupMemberView {
id: row.get(0)?,
username: row.get(1)?,
})
})?;
let mut results = Vec::new();
for row in rows {
results.push(row?);
}
Ok(results)
})
.await
}
pub async fn record_login_failure(&self, username: &str) -> Result<(u32, Option<u64>), Error> {
let username = username.to_string();
let count: u32 = self
.pool
.conn_and_then({
let username = username.clone();
move |conn| {
let count = conn
.query_row(
"SELECT failure_count FROM login_attempts WHERE username = ?1",
params![username],
|row| row.get::<_, u32>(0),
)
.unwrap_or(0)
+ 1;
conn.execute(
"INSERT INTO login_attempts (username, failure_count) VALUES (?1, ?2) \
ON CONFLICT(username) DO UPDATE SET failure_count = ?2",
params![username, count],
)?;
Ok::<u32, Error>(count)
}
})
.await?;
if count >= LOGIN_MAX_FAILURES {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or(Duration::ZERO)
.as_secs();
let locked_until = now + LOGIN_LOCKOUT_SECS;
let locked_until_db = locked_until as i64;
self.pool
.conn_and_then(move |conn| {
conn.execute(
"UPDATE login_attempts SET locked_until = ?2 WHERE username = ?1",
params![username, locked_until_db],
)?;
Ok::<(), Error>(())
})
.await?;
Ok((count, Some(locked_until)))
} else {
Ok((count, None))
}
}
pub async fn check_login_locked(&self, username: &str) -> Result<Option<u64>, Error> {
let username = username.to_string();
if let Some(locked_until) = self
.pool
.conn_and_then({
let username = username.clone();
move |conn| {
let result = conn.query_row(
"SELECT locked_until FROM login_attempts WHERE username = ?1",
params![username],
|row| row.get::<_, Option<i64>>(0),
);
match result {
Ok(value) => Ok::<Option<u64>, Error>(value.and_then(|v| u64::try_from(v).ok())),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok::<Option<u64>, Error>(None),
Err(e) => Err(Error::from(e)),
}
}
})
.await?
{
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or(Duration::ZERO)
.as_secs();
if now < locked_until {
return Ok(Some(locked_until - now));
}
self.clear_login_failures(&username).await?;
}
Ok(None)
}
pub async fn clear_login_failures(&self, username: &str) -> Result<(), Error> {
let username = username.to_string();
self.pool
.conn_and_then(move |conn| {
conn.execute("DELETE FROM login_attempts WHERE username = ?1", params![username])?;
Ok(())
})
.await
}
}
#[async_trait]
impl UserRepo for Database {
async fn find_user(&self, username: &str) -> Result<Option<UserView>, Error> {
self.find_user(username).await
}
async fn find_user_by_id(&self, user_id: i64) -> Result<Option<UserView>, Error> {
self.find_user_by_id(user_id).await
}
async fn insert_user(
&self,
username: &str,
password_hash: &str,
role: &str,
force_password_change: bool,
) -> Result<i64, Error> {
self.insert_user(username, password_hash, role, force_password_change)
.await
}
async fn update_user_password(&self, user_id: i64, password_hash: &str) -> Result<(), Error> {
self.update_user_password(user_id, password_hash).await
}
async fn list_users_with_groups(&self) -> Result<Vec<UserWithGroupsView>, Error> {
self.list_users_with_groups().await
}
async fn delete_user(&self, user_id: i64) -> Result<bool, Error> {
self.delete_user(user_id).await
}
async fn update_user_role(&self, user_id: i64, role: &str) -> Result<(), Error> {
self.update_user_role(user_id, role).await
}
async fn reset_user_password(&self, user_id: i64, password_hash: &str) -> Result<(), Error> {
self.reset_user_password(user_id, password_hash).await
}
}
#[async_trait]
impl UserGroupRepo for Database {
async fn list_user_groups(&self) -> Result<Vec<UserGroupView>, Error> {
self.list_user_groups().await
}
async fn create_user_group(&self, name: &str, description: &str, permissions: &str) -> Result<i64, Error> {
self.create_user_group(name, description, permissions).await
}
async fn update_user_group(&self, id: i64, name: &str, description: &str, permissions: &str) -> Result<(), Error> {
self.update_user_group(id, name, description, permissions).await
}
async fn delete_user_group(&self, id: i64) -> Result<bool, Error> {
self.delete_user_group(id).await
}
async fn get_user_group(&self, id: i64) -> Result<Option<UserGroupView>, Error> {
self.get_user_group(id).await
}
async fn list_groups_for_user(&self, user_id: i64) -> Result<Vec<UserGroupView>, Error> {
self.list_groups_for_user(user_id).await
}
async fn set_user_groups(&self, user_id: i64, group_ids: &[i64]) -> Result<(), Error> {
self.set_user_groups(user_id, group_ids).await
}
async fn list_user_permissions(&self, user_id: i64) -> Result<Vec<String>, Error> {
self.list_user_permissions(user_id).await
}
async fn list_group_member_ids(&self, group_id: i64) -> Result<Vec<i64>, Error> {
self.list_group_member_ids(group_id).await
}
async fn list_group_members(&self, group_id: i64) -> Result<Vec<GroupMemberView>, Error> {
self.list_group_members(group_id).await
}
}
#[async_trait]
impl LoginAttemptRepo for Database {
async fn record_login_failure(&self, username: &str) -> Result<(u32, Option<u64>), Error> {
self.record_login_failure(username).await
}
async fn check_login_locked(&self, username: &str) -> Result<Option<u64>, Error> {
self.check_login_locked(username).await
}
async fn clear_login_failures(&self, username: &str) -> Result<(), Error> {
self.clear_login_failures(username).await
}
}

View File

@ -2,25 +2,26 @@ use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use arc_swap::ArcSwap;
use async_trait::async_trait;
use macros::log;
use reqwest::Client;
use tokio::time::sleep;
use crate::interface::port::app_repo::AppRepo;
use crate::interface::port::notification::{AlertNotifier, AlertNotifierFactory, AlertPayload};
use crate::interface::port::secret_store::SecretStorePort;
use crate::interface::port::setting::SettingRepo;
use crate::model::config::constants::TELEGRAM_MAX_RETRIES;
use crate::model::error::Error;
use crate::model::error::notification::NotificationError;
use crate::model::log::system::SystemLog;
use crate::domain::common::config::AppConfig;
use crate::domain::common::error::Error;
use crate::domain::common::error::notification::NotificationError;
use crate::domain::common::log::system::SystemLog;
use crate::domain::common::notification::AlertPayload;
use crate::interface::config_repo::ConfigRepo;
use crate::interface::notification::{AlertNotifier, AlertNotifierFactory};
use crate::interface::secret_store::SecretStorePort;
/// Telegram Bot API adapter implementing AlertNotifier.
pub struct TelegramAdapter {
client: Client,
notif: Arc<dyn SettingRepo>,
repo: Arc<dyn AppRepo>,
notif: Arc<dyn ConfigRepo + Send + Sync>,
config: Arc<ArcSwap<AppConfig>>,
secrets: Option<Arc<dyn SecretStorePort>>,
/// Packed rate-limit state: high 32 bits = window-start unix seconds,
/// low 32 bits = count consumed in this window. Updated via CAS so the
@ -30,8 +31,8 @@ pub struct TelegramAdapter {
impl TelegramAdapter {
pub fn new(
notif: Arc<dyn SettingRepo>,
repo: Arc<dyn AppRepo>,
notif: Arc<dyn ConfigRepo + Send + Sync>,
config: Arc<ArcSwap<AppConfig>>,
secrets: Option<Arc<dyn SecretStorePort>>,
) -> Result<Self, Error> {
let client = Client::builder()
@ -42,7 +43,7 @@ impl TelegramAdapter {
Ok(Self {
client,
notif,
repo,
config,
secrets,
rate_state: AtomicU64::new(0),
})
@ -50,8 +51,8 @@ impl TelegramAdapter {
/// 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.notif.get_notification_config("telegram")? {
async fn get_config(&self) -> Result<Option<(String, String)>, Error> {
match self.notif.get_notification_config("telegram").await? {
Some(json_str) => {
let config: serde_json::Value =
serde_json::from_str(&json_str).map_err(NotificationError::TelegramRequestFailed)?;
@ -59,11 +60,10 @@ impl TelegramAdapter {
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());
if token.as_deref() == Some("__encrypted__")
&& let Some(ss) = self.secrets.as_ref()
{
token = ss.get_secret("telegram_bot_token").await?;
}
match (token, chat_id) {
@ -75,24 +75,13 @@ impl TelegramAdapter {
}
}
/// Read the configured per-window message cap from settings.
fn rate_limit_max_messages(&self) -> u32 {
self.repo
.get_setting("telegram_rate_limit_max_messages")
.ok()
.flatten()
.and_then(|v| v.parse().ok())
.unwrap_or(20)
self.config.load().notification.telegram.rate_limit_max_messages
}
/// Read the configured window length (seconds) from settings.
/// Read the configured window length (seconds) from the live config.
fn rate_limit_window_secs(&self) -> u32 {
self.repo
.get_setting("telegram_rate_limit_window_secs")
.ok()
.flatten()
.and_then(|v| v.parse().ok())
.unwrap_or(60)
self.config.load().notification.telegram.rate_limit_window_secs
}
/// Check rate limit. Returns true if send is allowed.
@ -134,8 +123,9 @@ impl TelegramAdapter {
/// Send a message via Telegram Bot API with retry on 429.
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);
let max_retries = self.config.load().notification.telegram.max_retries;
for attempt in 0..=TELEGRAM_MAX_RETRIES {
for attempt in 0..=max_retries {
let resp = self
.client
.post(&url)
@ -181,11 +171,11 @@ impl TelegramAdapter {
.and_then(|r| r.as_u64())
.unwrap_or(5);
if attempt < TELEGRAM_MAX_RETRIES {
if attempt < max_retries {
log!(SystemLog::TelegramRateLimitedRetry(
retry_after,
attempt + 1,
TELEGRAM_MAX_RETRIES,
max_retries,
));
sleep(Duration::from_secs(retry_after)).await;
continue;
@ -227,7 +217,7 @@ impl TelegramAdapter {
#[async_trait]
impl AlertNotifier for TelegramAdapter {
async fn send_alert(&self, payload: &AlertPayload) -> Result<(), Error> {
let (bot_token, chat_id) = match self.get_config()? {
let (bot_token, chat_id) = match self.get_config().await? {
Some(config) => config,
None => {
log!(SystemLog::TelegramNotConfiguredSkipped);
@ -249,7 +239,7 @@ impl AlertNotifier for TelegramAdapter {
}
async fn send_test_message(&self) -> Result<(), Error> {
let (bot_token, chat_id) = match self.get_config()? {
let (bot_token, chat_id) = match self.get_config().await? {
Some(config) => config,
None => Err(NotificationError::NotConfigured("telegram"))?,
};
@ -268,20 +258,24 @@ impl AlertNotifier for TelegramAdapter {
/// call instantiates a fresh `TelegramAdapter` so the test path observes
/// whatever config the user just saved.
pub struct TelegramAdapterFactory {
notif: Arc<dyn SettingRepo>,
repo: Arc<dyn AppRepo>,
notif: Arc<dyn ConfigRepo + Send + Sync>,
config: Arc<ArcSwap<AppConfig>>,
secrets: Option<Arc<dyn SecretStorePort>>,
}
impl TelegramAdapterFactory {
pub fn new(notif: Arc<dyn SettingRepo>, repo: Arc<dyn AppRepo>, secrets: Option<Arc<dyn SecretStorePort>>) -> Self {
Self { notif, repo, secrets }
pub fn new(
notif: Arc<dyn ConfigRepo + Send + Sync>,
config: Arc<ArcSwap<AppConfig>>,
secrets: Option<Arc<dyn SecretStorePort>>,
) -> Self {
Self { notif, config, secrets }
}
}
impl AlertNotifierFactory for TelegramAdapterFactory {
fn create(&self) -> Result<Arc<dyn AlertNotifier>, Error> {
let adapter = TelegramAdapter::new(self.notif.clone(), self.repo.clone(), self.secrets.clone())?;
let adapter = TelegramAdapter::new(self.notif.clone(), self.config.clone(), self.secrets.clone())?;
Ok(Arc::new(adapter))
}
}

View File

@ -1,89 +1,13 @@
use actix_web::rt::spawn;
use actix_web::{HttpRequest, HttpResponse, Result, web};
use actix_ws::{Message, MessageStream, Session, handle};
use futures_util::StreamExt;
use macros::log;
use tokio::sync::broadcast;
use tokio::sync::broadcast::error::RecvError;
use actix_ws::handle;
use crate::core::ml::alert::MLAlert;
use crate::model::detection::ml_detection::AlertMessage;
use crate::model::error::http::HttpError;
use crate::model::error::misc::MiscError;
use crate::model::log::http::HttpLog;
use crate::adapter::websocket::ws_bridge;
use crate::core::inference::alert::MLAlert;
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();
spawn(async move {
handle_alert_connection(session, msg_stream, broadcast_rx).await;
});
let rx = ai.subscribe_to_alerts();
spawn(ws_bridge::broadcast_json(session, msg_stream, rx));
Ok(response)
}
async fn handle_alert_connection(
mut session: Session,
mut msg_stream: MessageStream,
mut broadcast_rx: broadcast::Receiver<AlertMessage>,
) {
loop {
tokio::select! {
msg_result = msg_stream.next() => {
if !handle_client_message(&mut session, msg_result).await {
break;
}
},
broadcast_result = broadcast_rx.recv() => {
match broadcast_result {
Ok(alert) => {
if !send_alert(&mut session, &alert).await {
break;
}
}
Err(RecvError::Lagged(skipped)) => {
log!(HttpLog::WebSocketLagged(skipped));
continue;
}
Err(RecvError::Closed) => {
break;
}
}
},
}
}
let _ = session.close(None).await;
}
async fn handle_client_message(
session: &mut Session,
msg_result: Option<Result<Message, actix_ws::ProtocolError>>,
) -> bool {
match msg_result {
Some(Ok(Message::Text(_))) => true,
Some(Ok(Message::Ping(bytes))) => session.pong(&bytes).await.is_ok(),
Some(Ok(Message::Close(reason))) => {
let _ = (session.clone()).close(reason).await;
false
}
Some(Err(err)) => {
log!(HttpError::WebSocketError(err));
false
}
None => false,
_ => true,
}
}
async fn send_alert(session: &mut Session, alert: &AlertMessage) -> bool {
match serde_json::to_string(alert) {
Ok(json) => session.text(json).await.is_ok(),
Err(err) => {
log!(MiscError::SerializeError(err));
false
}
}
}

View File

@ -1,16 +1,9 @@
use actix_web::rt::spawn;
use actix_web::{HttpRequest, HttpResponse, Result, web};
use actix_ws::{Message, MessageStream, Session, handle};
use futures_util::StreamExt;
use macros::log;
use tokio::sync::broadcast;
use tokio::sync::broadcast::error::RecvError;
use actix_ws::handle;
use super::ws_bridge;
use crate::adapter::ebpf::drop_monitor::DropMonitor;
use crate::model::error::http::HttpError;
use crate::model::error::misc::MiscError;
use crate::model::log::http::HttpLog;
use crate::model::monitoring::drop_event::DropEventMessage;
pub async fn websocket_drops(
req: HttpRequest,
@ -18,76 +11,7 @@ pub async fn websocket_drops(
monitor: web::Data<DropMonitor>,
) -> Result<HttpResponse> {
let (response, session, msg_stream) = handle(&req, body)?;
let broadcast_rx = monitor.subscribe();
spawn(async move {
handle_drop_connection(session, msg_stream, broadcast_rx).await;
});
let rx = monitor.subscribe();
spawn(ws_bridge::broadcast_json(session, msg_stream, rx));
Ok(response)
}
async fn handle_drop_connection(
mut session: Session,
mut msg_stream: MessageStream,
mut broadcast_rx: broadcast::Receiver<DropEventMessage>,
) {
loop {
tokio::select! {
msg_result = msg_stream.next() => {
if !handle_client_message(&mut session, msg_result).await {
break;
}
},
broadcast_result = broadcast_rx.recv() => {
match broadcast_result {
Ok(event) => {
if !send_drop_event(&mut session, &event).await {
break;
}
}
Err(RecvError::Lagged(skipped)) => {
log!(HttpLog::WebSocketLagged(skipped));
continue;
}
Err(RecvError::Closed) => {
break;
}
}
},
}
}
let _ = session.close(None).await;
}
async fn handle_client_message(
session: &mut Session,
msg_result: Option<Result<Message, actix_ws::ProtocolError>>,
) -> bool {
match msg_result {
Some(Ok(Message::Text(_))) => true,
Some(Ok(Message::Ping(bytes))) => session.pong(&bytes).await.is_ok(),
Some(Ok(Message::Close(reason))) => {
let _ = (session.clone()).close(reason).await;
false
}
Some(Err(err)) => {
log!(HttpError::WebSocketError(err));
false
}
None => false,
_ => true,
}
}
async fn send_drop_event(session: &mut Session, event: &DropEventMessage) -> bool {
match serde_json::to_string(event) {
Ok(json) => session.text(json).await.is_ok(),
Err(err) => {
log!(MiscError::SerializeError(err));
false
}
}
}

View File

@ -6,8 +6,8 @@ use actix_ws::Message;
use futures_util::StreamExt;
use tokio::time::interval;
use crate::infrastructure::statistics::FlowStatistics;
use crate::model::monitoring::flow_stats::FlowSubscription;
use crate::core::common::statistics::FlowStatistics;
use crate::domain::data_plane::flow_stats::FlowSubscription;
/// Default subscription: all flows, no filter, 5 second interval
fn default_subscription() -> FlowSubscription {

View File

@ -1,8 +1,8 @@
//! WebSocket bridge for post-fusion threat events.
//!
//! `/ws/fusion` subscribes to the `ThreatDetectedEvent` broadcast that the
//! `DetectionOrchestrator` already publishes through `CommunicationManager`
//! (the same stream SOAR consumes). Each event is wrapped with a server-side
//! `/ws/fusion` subscribes to the `ThreatDetectedEvent` broadcast channel
//! that the `DetectionOrchestrator` publishes to (the same stream SOAR
//! consumes). Each event is wrapped with a server-side
//! `ts` (unix seconds) so the dashboard can render relative timestamps
//! without doing the conversion itself.
//!
@ -16,102 +16,26 @@ use std::time::{SystemTime, UNIX_EPOCH};
use actix_web::rt::spawn;
use actix_web::{HttpRequest, HttpResponse, Result, web};
use actix_ws::{Message, MessageStream, Session, handle};
use futures_util::StreamExt;
use actix_ws::handle;
use macros::log;
use tokio::sync::broadcast;
use tokio::sync::broadcast::error::RecvError;
use crate::infrastructure::communication_manager::CommunicationManager;
use crate::model::error::http::HttpError;
use crate::model::error::misc::MiscError;
use crate::model::event::ThreatDetectedEvent;
use crate::model::log::http::HttpLog;
use super::ws_bridge;
use crate::domain::common::error::misc::MiscError;
use crate::domain::common::event::ThreatDetectedEvent;
pub async fn websocket_fusion(
req: HttpRequest,
body: web::Payload,
comm: web::Data<CommunicationManager>,
threat_tx: web::Data<broadcast::Sender<ThreatDetectedEvent>>,
) -> Result<HttpResponse> {
let (response, session, msg_stream) = handle(&req, body)?;
let broadcast_rx = match comm.subscribe_event::<ThreatDetectedEvent>() {
Ok(rx) => rx,
Err(e) => {
log!(HttpLog::FusionSubscribeFailed(e.to_string()));
return Ok(HttpResponse::InternalServerError().json(serde_json::json!({
"error": "fusion event channel not registered",
})));
}
};
spawn(async move {
handle_fusion_connection(session, msg_stream, broadcast_rx).await;
});
let rx = threat_tx.subscribe();
spawn(ws_bridge::broadcast_loop(session, msg_stream, rx, envelope_with_ts));
Ok(response)
}
async fn handle_fusion_connection(
mut session: Session,
mut msg_stream: MessageStream,
mut broadcast_rx: broadcast::Receiver<ThreatDetectedEvent>,
) {
loop {
tokio::select! {
msg_result = msg_stream.next() => {
if !handle_client_message(&mut session, msg_result).await {
break;
}
},
broadcast_result = broadcast_rx.recv() => {
match broadcast_result {
Ok(event) => {
if !send_event(&mut session, &event).await {
break;
}
}
Err(RecvError::Lagged(skipped)) => {
log!(HttpLog::WebSocketLagged(skipped));
continue;
}
Err(RecvError::Closed) => {
break;
}
}
},
}
}
let _ = session.close(None).await;
}
async fn handle_client_message(
session: &mut Session,
msg_result: Option<Result<Message, actix_ws::ProtocolError>>,
) -> bool {
match msg_result {
Some(Ok(Message::Text(_))) => true,
Some(Ok(Message::Ping(bytes))) => session.pong(&bytes).await.is_ok(),
Some(Ok(Message::Close(reason))) => {
let _ = (session.clone()).close(reason).await;
false
}
Some(Err(err)) => {
log!(HttpError::WebSocketError(err));
false
}
None => false,
_ => true,
}
}
/// Wrap each event in `{ts, ...event_fields}`. The `ts` is a server-stamped
/// unix-seconds value so the client can render "5s ago" without inferring
/// the time from the audit chain. All declared fields of
/// `ThreatDetectedEvent` flow through verbatim via the event's own
/// `Serialize` derive — no field whitelist to drift out of date.
async fn send_event(session: &mut Session, event: &ThreatDetectedEvent) -> bool {
fn envelope_with_ts(event: &ThreatDetectedEvent) -> Option<String> {
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
@ -122,21 +46,18 @@ async fn send_event(session: &mut Session, event: &ThreatDetectedEvent) -> bool
map.insert("ts".to_string(), serde_json::Value::from(ts));
serde_json::Value::Object(map)
}
// The derived Serialize on a struct always produces an Object —
// this branch only fires if the type changes shape in a future
// refactor. Falling back to the raw value keeps the stream alive.
Ok(other) => other,
Err(err) => {
log!(MiscError::SerializeError(err));
return false;
return None;
}
};
match serde_json::to_string(&payload) {
Ok(json) => session.text(json).await.is_ok(),
Ok(json) => Some(json),
Err(err) => {
log!(MiscError::SerializeError(err));
false
None
}
}
}
@ -144,7 +65,7 @@ async fn send_event(session: &mut Session, event: &ThreatDetectedEvent) -> bool
#[cfg(test)]
mod tests {
use super::*;
use crate::model::event::DetectionSource;
use crate::domain::common::event::DetectionSource;
fn sample_event() -> ThreatDetectedEvent {
ThreatDetectedEvent {
@ -179,14 +100,9 @@ mod tests {
#[test]
fn envelope_adds_ts_field_to_event_object() {
// The `send_event` wire path inserts `ts` into the event's own
// serde object; mirror that here without an actix session so the
// wrapping logic stays covered when the orchestrator schema evolves.
let event = sample_event();
let mut value = serde_json::to_value(&event).expect("serialize event");
let object = value.as_object_mut().expect("expected object shape");
object.insert("ts".to_string(), serde_json::Value::from(1_700_000_000_u64));
assert_eq!(value["ts"], 1_700_000_000_u64);
let json = envelope_with_ts(&sample_event()).expect("should serialize");
let value: serde_json::Value = serde_json::from_str(&json).expect("valid json");
assert!(value["ts"].is_u64());
assert_eq!(value["attack_type"], "brute_force");
}
}

View File

@ -1,16 +1,9 @@
use actix_web::rt::spawn;
use actix_web::{HttpRequest, HttpResponse, Result, web};
use actix_ws::{Message, MessageStream, Session, handle};
use futures_util::StreamExt;
use macros::log;
use tokio::sync::broadcast;
use tokio::sync::broadcast::error::RecvError;
use actix_ws::handle;
use super::ws_bridge;
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::system::health::SystemHealthMetrics;
pub async fn websocket_system_health(
req: HttpRequest,
@ -18,76 +11,7 @@ pub async fn websocket_system_health(
health: web::Data<SystemHealth>,
) -> Result<HttpResponse> {
let (response, session, msg_stream) = handle(&req, body)?;
let broadcast_rx = health.subscribe_to_metrics();
spawn(async move {
handle_health_connection(session, msg_stream, broadcast_rx).await;
});
let rx = health.subscribe_to_metrics();
spawn(ws_bridge::broadcast_json(session, msg_stream, rx));
Ok(response)
}
async fn handle_health_connection(
mut session: Session,
mut msg_stream: MessageStream,
mut broadcast_rx: broadcast::Receiver<SystemHealthMetrics>,
) {
loop {
tokio::select! {
msg_result = msg_stream.next() => {
if !handle_client_message(&mut session, msg_result).await {
break;
}
},
broadcast_result = broadcast_rx.recv() => {
match broadcast_result {
Ok(metrics) => {
if !send_metrics(&mut session, &metrics).await {
break;
}
}
Err(RecvError::Lagged(skipped)) => {
log!(HttpLog::WebSocketLagged(skipped));
continue;
}
Err(RecvError::Closed) => {
break;
}
}
},
}
}
let _ = session.close(None).await;
}
async fn handle_client_message(
session: &mut Session,
msg_result: Option<Result<Message, actix_ws::ProtocolError>>,
) -> bool {
match msg_result {
Some(Ok(Message::Text(_))) => true,
Some(Ok(Message::Ping(bytes))) => session.pong(&bytes).await.is_ok(),
Some(Ok(Message::Close(reason))) => {
let _ = (session.clone()).close(reason).await;
false
}
Some(Err(err)) => {
log!(HttpError::WebSocketError(err));
false
}
None => false,
_ => true,
}
}
async fn send_metrics(session: &mut Session, metrics: &SystemHealthMetrics) -> bool {
match serde_json::to_string(metrics) {
Ok(json) => session.text(json).await.is_ok(),
Err(err) => {
log!(MiscError::SerializeError(err));
false
}
}
}

View File

@ -4,3 +4,4 @@ pub mod flow_websocket;
pub mod fusion_websocket;
pub mod health_websocket;
pub mod routes;
pub mod ws_bridge;

View File

@ -1,13 +1,14 @@
use actix_web::{HttpRequest, HttpResponse, Responder, Scope, web};
use serde::Deserialize;
use tokio::sync::broadcast;
use super::{alert_websocket, drop_websocket, flow_websocket, fusion_websocket, health_websocket};
use crate::adapter::ebpf::drop_monitor::DropMonitor;
use crate::core::auth::jwt::JwtService;
use crate::core::ml::alert::MLAlert;
use crate::infrastructure::communication_manager::CommunicationManager;
use crate::adapter::http::jwt::JwtService;
use crate::core::common::statistics::FlowStatistics;
use crate::core::inference::alert::MLAlert;
use crate::domain::common::event::ThreatDetectedEvent;
use crate::infrastructure::health::SystemHealth;
use crate::infrastructure::statistics::FlowStatistics;
#[derive(Deserialize)]
struct WsQuery {
@ -41,10 +42,7 @@ fn validate_ws_token(
Some(ref t) => jwt
.validate_token(t)
.map(|_| ())
.map_err(|_| {
HttpResponse::Unauthorized()
.json(serde_json::json!({"error": "Invalid or expired token"}))
}),
.map_err(|_| HttpResponse::Unauthorized().json(serde_json::json!({"error": "Invalid or expired token"}))),
None => Err(HttpResponse::Unauthorized()
.json(serde_json::json!({"error": "Missing authentication: provide Authorization header or token query parameter"}))),
}
@ -89,14 +87,14 @@ async fn alerts_ws(
async fn fusion_ws(
req: HttpRequest,
stream: web::Payload,
comm: web::Data<CommunicationManager>,
threat_tx: web::Data<broadcast::Sender<ThreatDetectedEvent>>,
query: web::Query<WsQuery>,
jwt: web::Data<JwtService>,
) -> impl Responder {
if let Err(resp) = validate_ws_token(&req, &query, &jwt) {
return resp;
}
match fusion_websocket::websocket_fusion(req, stream, comm).await {
match fusion_websocket::websocket_fusion(req, stream, threat_tx).await {
Ok(response) => response,
Err(err) => {
HttpResponse::InternalServerError().json(serde_json::json!({"error": format!("WebSocket error: {}", err)}))

View File

@ -0,0 +1,86 @@
use actix_ws::{Message, MessageStream, Session};
use futures_util::StreamExt;
use macros::log;
use serde::Serialize;
use tokio::sync::broadcast;
use tokio::sync::broadcast::error::RecvError;
use crate::domain::common::error::http::HttpError;
use crate::domain::common::error::misc::MiscError;
use crate::domain::common::log::http::HttpLog;
async fn handle_client_message(
session: &mut Session,
msg_result: Option<Result<Message, actix_ws::ProtocolError>>,
) -> bool {
match msg_result {
Some(Ok(Message::Text(_))) => true,
Some(Ok(Message::Ping(bytes))) => session.pong(&bytes).await.is_ok(),
Some(Ok(Message::Close(reason))) => {
let _ = (session.clone()).close(reason).await;
false
}
Some(Err(err)) => {
log!(HttpError::WebSocketError(err));
false
}
None => false,
_ => true,
}
}
fn serialize_json<T: Serialize>(value: &T) -> Option<String> {
match serde_json::to_string(value) {
Ok(json) => Some(json),
Err(err) => {
log!(MiscError::SerializeError(err));
None
}
}
}
pub async fn broadcast_json<T: Serialize + Clone + Send + 'static>(
session: Session,
msg_stream: MessageStream,
rx: broadcast::Receiver<T>,
) {
broadcast_loop(session, msg_stream, rx, |event| serialize_json(event)).await;
}
pub async fn broadcast_loop<T: Clone + Send + 'static>(
mut session: Session,
mut msg_stream: MessageStream,
mut rx: broadcast::Receiver<T>,
to_json: impl Fn(&T) -> Option<String>,
) {
loop {
tokio::select! {
msg_result = msg_stream.next() => {
if !handle_client_message(&mut session, msg_result).await {
break;
}
},
broadcast_result = rx.recv() => {
match broadcast_result {
Ok(event) => {
let Some(json) = to_json(&event) else {
break;
};
if session.text(json).await.is_err() {
break;
}
}
Err(RecvError::Lagged(skipped)) => {
log!(HttpLog::WebSocketLagged(skipped));
continue;
}
Err(RecvError::Closed) => {
break;
}
}
},
}
}
let _ = session.close(None).await;
}

View File

@ -0,0 +1,204 @@
use std::collections::HashMap;
use std::sync::Arc;
use arc_swap::ArcSwap;
use async_trait::async_trait;
use crate::domain::common::config::AppConfig;
use crate::domain::common::config::section::ConfigSection;
use crate::domain::common::error::Error;
use crate::domain::common::error::misc::MiscError;
use crate::interface::app_repo::AppRepo;
use crate::interface::config_repo::ConfigRepo;
use crate::interface::secret_store::SecretStorePort;
const SECRET_KEYS: &[&str] = &["smtp_password"];
const VALID_PIPELINE_STAGES: &[&str] = &["access_control", "rate_limit", "service"];
pub struct ConfigService {
db: Arc<dyn AppRepo>,
secrets: Option<Arc<dyn SecretStorePort>>,
app_config: Arc<ArcSwap<AppConfig>>,
}
struct CandidateConfigRepo {
base: Arc<dyn AppRepo>,
values: HashMap<String, String>,
}
#[async_trait]
impl ConfigRepo for CandidateConfigRepo {
async fn get_config_value(&self, key: &str) -> Result<Option<String>, Error> {
if let Some(value) = self.values.get(key) {
return Ok(Some(value.clone()));
}
self.base.get_config_value(key).await
}
async fn set_config_value(&self, key: &str, value: &str) -> Result<(), Error> {
self.base.set_config_value(key, value).await
}
async fn get_app_secret(&self, key: &str) -> Result<Option<String>, Error> {
self.base.get_app_secret(key).await
}
async fn set_app_secret(&self, key: &str, plaintext: &str) -> Result<(), Error> {
self.base.set_app_secret(key, plaintext).await
}
async fn get_notification_config(&self, channel: &str) -> Result<Option<String>, Error> {
self.base.get_notification_config(channel).await
}
async fn set_notification_config(&self, channel: &str, config_json: &str) -> Result<(), Error> {
self.base.set_notification_config(channel, config_json).await
}
async fn update_config_values_atomically(
&self,
config_values: Vec<(String, String)>,
secrets: Vec<(String, String)>,
) -> Result<(), Error> {
self.base.update_config_values_atomically(config_values, secrets).await
}
}
impl ConfigService {
pub fn new(db: Arc<dyn AppRepo>, app_config: Arc<ArcSwap<AppConfig>>) -> Self {
Self {
db,
secrets: None,
app_config,
}
}
pub fn with_secret_store(mut self, secrets: Arc<dyn SecretStorePort>) -> Self {
self.secrets = Some(secrets);
self
}
pub async fn get_config(&self) -> serde_json::Value {
let cfg = self.app_config.load();
let values = cfg.api_setting_values();
let mut root = serde_json::Map::new();
for section in ConfigSection::ALL {
let mut section_obj = serde_json::Map::new();
for key in section.keys() {
let value = values.get(key).cloned().unwrap_or_default();
section_obj.insert(key.to_string(), serde_json::Value::String(value));
}
root.insert(section.name().to_string(), serde_json::Value::Object(section_obj));
}
root.insert(
"pipeline".to_string(),
serde_json::json!({
"ingress": cfg.pipeline.ingress.join(","),
"egress": cfg.pipeline.egress.join(","),
}),
);
serde_json::Value::Object(root)
}
pub async fn update_config(&self, body: &serde_json::Value) -> Result<Vec<String>, Error> {
let mut updated: Vec<String> = Vec::new();
let mut config_values = Vec::new();
let mut secrets_to_save = Vec::new();
for section in ConfigSection::ALL {
if let Some(section_obj) = body.get(section.name()).and_then(|v| v.as_object()) {
for key in section.keys() {
if let Some(val) = section_obj.get(*key).and_then(json_value_as_string) {
config_values.push((key.to_string(), val));
updated.push(key.to_string());
}
}
}
}
if let Some(ref secrets) = self.secrets {
for key in SECRET_KEYS {
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)
{
let envelope = secrets.encrypt_envelope(&val)?;
secrets_to_save.push((key.to_string(), envelope));
config_values.push((key.to_string(), String::new()));
updated.push(key.to_string());
}
}
}
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")] {
if let Some(val) = pipeline_obj.get(field).and_then(|v| v.as_str()) {
if !val.is_empty() {
let stages: Vec<&str> = val.split(',').map(|s| s.trim()).collect();
for stage in &stages {
if !stage.is_empty() && !VALID_PIPELINE_STAGES.contains(stage) {
Err(MiscError::ValidationError(format!(
"Invalid pipeline stage '{}'. Valid stages: {}",
stage,
VALID_PIPELINE_STAGES.join(", ")
)))?;
}
}
}
config_values.push((db_key.to_string(), val.to_string()));
updated.push(db_key.to_string());
}
}
}
let candidate_repo = CandidateConfigRepo {
base: self.db.clone(),
values: config_values.iter().cloned().collect(),
};
let new_cfg = AppConfig::from_config_repo(&candidate_repo).await?;
self.db
.update_config_values_atomically(config_values, secrets_to_save)
.await?;
self.app_config.store(Arc::new(new_cfg));
Ok(updated)
}
}
fn json_value_as_string(v: &serde_json::Value) -> Option<String> {
match v {
serde_json::Value::String(s) if !s.is_empty() => Some(s.clone()),
serde_json::Value::Bool(b) => Some(b.to_string()),
serde_json::Value::Number(n) => Some(n.to_string()),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::adapter::persistence::Database;
#[tokio::test]
async fn invalid_config_update_is_not_persisted() {
let db = Arc::new(Database::new(":memory:").await.expect("test db"));
let app_config = Arc::new(ArcSwap::from_pointee(
AppConfig::from_config_repo(db.as_ref()).await.expect("initial config"),
));
let service = ConfigService::new(db.clone() as Arc<dyn AppRepo>, app_config);
let body = serde_json::json!({
"http": {
"http_port": 0
}
});
assert!(service.update_config(&body).await.is_err());
assert_eq!(db.get_config_value("http_port").await.unwrap(), None);
}
}

View File

@ -0,0 +1,119 @@
use std::sync::Arc;
use std::sync::atomic::{AtomicU8, Ordering};
use arc_swap::ArcSwap;
use macros::log;
use tokio::sync::broadcast;
use crate::domain::common::config::AppConfig;
use crate::domain::common::config::constants::enforce_mode_to_u8;
use crate::domain::common::error::Error;
use crate::domain::common::event::AuditEvent;
use crate::domain::common::log::system::SystemLog;
use crate::interface::app_repo::AppRepo;
/// Handles enforce-mode commands and queries by delegating to the repository.
pub struct EnforceModeHandler {
db: Arc<dyn AppRepo>,
app_config: Arc<ArcSwap<AppConfig>>,
audit_tx: broadcast::Sender<AuditEvent>,
/// Shared AtomicU8 cache: Monitor=0, MlOnly=1, Enforce=2.
enforce_cache: Arc<AtomicU8>,
}
impl EnforceModeHandler {
pub fn new(
db: Arc<dyn AppRepo>,
app_config: Arc<ArcSwap<AppConfig>>,
audit_tx: broadcast::Sender<AuditEvent>,
enforce_cache: Arc<AtomicU8>,
) -> Self {
Self {
db,
app_config,
audit_tx,
enforce_cache,
}
}
pub async fn change_mode(&self, mode: String) -> Result<(), Error> {
self.db.set_config_value("enforce_mode", &mode).await?;
self.enforce_cache.store(enforce_mode_to_u8(&mode), Ordering::SeqCst);
let mut cfg = (**self.app_config.load()).clone();
cfg.system.enforce_mode = mode.clone();
self.app_config.store(Arc::new(cfg));
log!(SystemLog::EnforceModeChanged(mode.clone()));
// Publish audit event for the mode change
let _ = self.audit_tx.send(AuditEvent {
actor: "admin".to_string(),
action: "enforce_mode_changed".to_string(),
detail: serde_json::json!({ "new_mode": mode }).to_string(),
});
Ok(())
}
pub fn get_mode(&self) -> String {
self.app_config.load().system.enforce_mode.clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::adapter::persistence::Database;
use crate::domain::common::config::constants::EVENT_CHANNEL_CAPACITY;
async fn test_handler() -> EnforceModeHandler {
let db = Arc::new(Database::new(":memory:").await.unwrap());
let app_config = Arc::new(ArcSwap::from_pointee(
AppConfig::from_config_repo(db.as_ref()).await.unwrap(),
));
let cache = Arc::new(AtomicU8::new(0));
let (audit_tx, _rx) = broadcast::channel::<AuditEvent>(EVENT_CHANNEL_CAPACITY);
EnforceModeHandler::new(db as Arc<dyn AppRepo>, app_config, audit_tx, cache)
}
#[tokio::test]
async fn test_default_mode_is_monitor() {
let handler = test_handler().await;
let mode = handler.get_mode();
assert_eq!(mode, "monitor");
}
#[tokio::test]
async fn test_change_to_enforce() {
let handler = test_handler().await;
handler.change_mode("enforce".into()).await.unwrap();
let mode = handler.get_mode();
assert_eq!(mode, "enforce");
}
#[tokio::test]
async fn test_change_back_to_monitor() {
let handler = test_handler().await;
handler.change_mode("enforce".into()).await.unwrap();
handler.change_mode("monitor".into()).await.unwrap();
let mode = handler.get_mode();
assert_eq!(mode, "monitor");
}
#[tokio::test]
async fn test_change_to_ml_only() {
let handler = test_handler().await;
handler.change_mode("ml_only".into()).await.unwrap();
let mode = handler.get_mode();
assert_eq!(mode, "ml_only");
}
#[tokio::test]
async fn test_cycle_all_modes() {
let handler = test_handler().await;
for mode_str in ["enforce", "ml_only", "monitor"] {
handler.change_mode(mode_str.into()).await.unwrap();
let mode = handler.get_mode();
assert_eq!(mode, mode_str);
}
}
}

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