feat: GeoIP replacement, Vue 3 frontend, setup wizard, DDD architecture, UI/UX fixes, i18n, security hardening

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
DaLaw2 2026-03-28 10:59:06 +08:00
parent e71abca1b7
commit c14583d985
115 changed files with 7486 additions and 1649 deletions

1
.gitattributes vendored
View File

@ -1 +1,2 @@
* text=auto eol=lf
*.mmdb filter=lfs diff=lfs merge=lfs -text

View File

@ -79,7 +79,7 @@ jobs:
run: cargo test --package net-guardia
- name: cargo clippy
run: cargo clippy --package net-guardia -- -D warnings -A dead_code
run: cargo clippy --package net-guardia -- -D warnings
integration-test:
name: Integration Test (placeholder)

837
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
[workspace]
resolver = "2"
members = ["net-guardia", "common", "macros", "ingress-ebpf", "egress-ebpf"]
default-members = ["net-guardia", "common"]
members = ["net-guardia", "common", "macros", "ingress-ebpf", "egress-ebpf", "mcp-server", "cli"]
default-members = ["net-guardia", "common", "mcp-server", "cli"]
[workspace.dependencies]
# eBPF - kernel side (pinned: aya-ebpf 0.1.2 was yanked, see aya-rs/aya#1400)

16
cli/Cargo.toml Normal file
View File

@ -0,0 +1,16 @@
[package]
name = "ng-cli"
version = "0.1.0"
edition = "2024"
[dependencies]
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
clap = { workspace = true }
rand = { workspace = true }
[[bin]]
name = "ng"
path = "src/main.rs"

376
cli/src/main.rs Normal file
View File

@ -0,0 +1,376 @@
use std::path::PathBuf;
use std::time::Duration;
use clap::{Parser, Subcommand};
use reqwest::Client;
use serde_json::Value;
/// NetGuardia CLI management tool.
#[derive(Parser)]
#[command(name = "ng", about = "NetGuardia CLI", version)]
struct Cli {
/// API base URL
#[arg(long, default_value = "http://127.0.0.1:8080", global = true)]
url: String,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// System health + enforce mode + uptime
Status,
/// Recent threat alerts
Alerts {
#[arg(long, default_value = "20")]
limit: u32,
},
/// Add IP to blacklist
Block {
ip: String,
#[arg(long, default_value = "1800")]
ttl: u64,
},
/// Remove IP from blacklist
Unblock { ip: String },
/// List all ACL rules
Rules,
/// Generate security report
Report {
#[arg(long, default_value = "text")]
format: String,
},
/// Get or set enforce mode
Mode {
/// Set mode to "monitor" or "enforce"
mode: Option<String>,
},
/// Authenticate and save JWT
Login,
/// MCP API key management
McpKey {
#[command(subcommand)]
action: McpKeyAction,
},
}
#[derive(Subcommand)]
enum McpKeyAction {
/// Generate a new MCP API key
Generate {
#[arg(long, default_value = "default")]
name: String,
#[arg(long, default_value = "read_only")]
level: String,
},
/// List all MCP API keys
List,
/// Revoke an MCP API key
Revoke { id: i64 },
}
struct ApiClient {
client: Client,
base_url: String,
token_path: PathBuf,
}
impl ApiClient {
fn new(base_url: String) -> Self {
let client = Client::builder()
.timeout(Duration::from_secs(10))
.build()
.expect("Failed to create HTTP client");
let token_path = dirs_next().join("token");
Self { client, base_url, token_path }
}
fn load_token(&self) -> Option<String> {
std::fs::read_to_string(&self.token_path).ok()
}
fn save_token(&self, token: &str) {
if let Some(parent) = self.token_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(&self.token_path, token);
}
async fn get(&self, path: &str) -> Result<Value, String> {
let url = format!("{}{}", self.base_url, path);
let mut req = self.client.get(&url);
if let Some(token) = self.load_token() {
req = req.header("Authorization", format!("Bearer {}", token.trim()));
}
let resp = req.send().await.map_err(|e| format!("Connection error: {}", e))?;
if resp.status().as_u16() == 401 {
return Err("Session expired. Run `ng login` to re-authenticate.".into());
}
resp.json().await.map_err(|e| format!("Parse error: {}", e))
}
async fn post(&self, path: &str, body: Value) -> Result<Value, String> {
let url = format!("{}{}", self.base_url, path);
let mut req = self.client.post(&url).json(&body);
if let Some(token) = self.load_token() {
req = req.header("Authorization", format!("Bearer {}", token.trim()));
}
let resp = req.send().await.map_err(|e| format!("Connection error: {}", e))?;
if resp.status().as_u16() == 401 {
return Err("Session expired. Run `ng login` to re-authenticate.".into());
}
resp.json().await.map_err(|e| format!("Parse error: {}", e))
}
async fn delete(&self, path: &str) -> Result<Value, String> {
let url = format!("{}{}", self.base_url, path);
let mut req = self.client.delete(&url);
if let Some(token) = self.load_token() {
req = req.header("Authorization", format!("Bearer {}", token.trim()));
}
let resp = req.send().await.map_err(|e| format!("Connection error: {}", e))?;
if resp.status().as_u16() == 401 {
return Err("Session expired. Run `ng login` to re-authenticate.".into());
}
resp.json().await.map_err(|e| format!("Parse error: {}", e))
}
async fn 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
.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())
}
}
fn dirs_next() -> PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
PathBuf::from(home).join(".ng")
}
fn format_report_text(data: &Value) -> String {
let mut out = String::new();
out.push_str("=== NetGuardia Security Report ===\n\n");
if let Some(obj) = data.as_object() {
for (key, value) in obj {
let label = key.replace('_', " ");
match value {
Value::String(s) => {
out.push_str(&format!("{}: {}\n", label, s));
}
Value::Number(n) => {
out.push_str(&format!("{}: {}\n", label, n));
}
Value::Bool(b) => {
out.push_str(&format!("{}: {}\n", label, b));
}
Value::Array(arr) => {
out.push_str(&format!("{}:\n", label));
for item in arr {
out.push_str(&format!(" - {}\n", item));
}
}
Value::Object(_) => {
out.push_str(&format!("{}:\n{}\n", label, serde_json::to_string_pretty(value).unwrap_or_default()));
}
Value::Null => {
out.push_str(&format!("{}: N/A\n", label));
}
}
}
} else {
out.push_str(&serde_json::to_string_pretty(data).unwrap_or_default());
}
out
}
#[tokio::main]
async fn main() {
let cli = Cli::parse();
let api = ApiClient::new(cli.url);
let result = match cli.command {
Commands::Status => {
match api.get("/api/health/status").await {
Ok(data) => {
println!("{}", serde_json::to_string_pretty(&data).unwrap_or_default());
Ok(())
}
Err(e) => Err(e),
}
}
// Issue 8: Use limit parameter in alerts query
Commands::Alerts { limit } => {
match api.get(&format!("/api/ml/alerts?limit={}", limit)).await {
Ok(data) => {
println!("{}", serde_json::to_string_pretty(&data).unwrap_or_default());
Ok(())
}
Err(e) => Err(e),
}
}
// Issue 9: Use ttl parameter in block request body
Commands::Block { ip, ttl } => {
let ip_ver = if ip.contains(':') { 6 } else { 4 };
let body = serde_json::json!({
"ip_version": ip_ver, "direction": "source",
"list_type": "blacklist", "ip_address": ip, "port": 0,
"ttl_secs": ttl
});
match api.post("/api/acl/add", body).await {
Ok(data) => { println!("Blocked: {}", serde_json::to_string(&data).unwrap_or_default()); Ok(()) }
Err(e) => Err(e),
}
}
Commands::Unblock { ip } => {
let ip_ver = if ip.contains(':') { 6 } else { 4 };
let body = serde_json::json!({
"ip_version": ip_ver, "direction": "source",
"list_type": "blacklist", "ip_address": ip, "port": 0
});
match api.post("/api/acl/delete", body).await {
Ok(data) => { println!("Unblocked: {}", serde_json::to_string(&data).unwrap_or_default()); Ok(()) }
Err(e) => Err(e),
}
}
Commands::Rules => {
match api.get("/api/acl/list").await {
Ok(data) => { println!("{}", serde_json::to_string_pretty(&data).unwrap_or_default()); Ok(()) }
Err(e) => Err(e),
}
}
// Issue 10: Use format parameter for report output
Commands::Report { format } => {
match api.post("/api/report/generate", serde_json::json!({})).await {
Ok(data) => {
if format == "json" {
println!("{}", serde_json::to_string_pretty(&data).unwrap_or_default());
} else {
print!("{}", format_report_text(&data));
}
Ok(())
}
Err(e) => Err(e),
}
}
Commands::Mode { mode } => {
match mode {
Some(m) => {
let body = serde_json::json!({"mode": m});
match api.post("/api/system/enforce-mode", body).await {
Ok(data) => { println!("{}", serde_json::to_string_pretty(&data).unwrap_or_default()); Ok(()) }
Err(e) => Err(e),
}
}
None => {
match api.get("/api/system/enforce-mode").await {
Ok(data) => { println!("{}", serde_json::to_string_pretty(&data).unwrap_or_default()); Ok(()) }
Err(e) => Err(e),
}
}
}
}
Commands::Login => {
print!("Username: ");
std::io::Write::flush(&mut std::io::stdout()).unwrap();
let mut username = String::new();
std::io::stdin().read_line(&mut username).unwrap();
let username = username.trim();
// Read password without echo (simple version)
print!("Password: ");
std::io::Write::flush(&mut std::io::stdout()).unwrap();
let mut password = String::new();
std::io::stdin().read_line(&mut password).unwrap();
let password = password.trim();
match api.login(username, password).await {
Ok(token) => {
api.save_token(&token);
println!("Login successful. Token saved to ~/.ng/token");
Ok(())
}
Err(e) => Err(e),
}
}
Commands::McpKey { action } => {
match action {
// Issue 13: Generate key via API so it persists
McpKeyAction::Generate { name, level } => {
let body = serde_json::json!({
"name": name,
"level": level,
});
match api.post("/api/mcp-keys/generate", body).await {
Ok(data) => {
if let Some(key) = data.get("key").and_then(|k| k.as_str()) {
println!("Generated MCP API key: {}", key);
println!("Name: {}, Level: {}", name, level);
println!("Set NETGUARDIA_MCP_KEY={} in your MCP client config", key);
} else {
println!("{}", serde_json::to_string_pretty(&data).unwrap_or_default());
}
Ok(())
}
Err(e) => Err(e),
}
}
// Issue 11: List keys via API
McpKeyAction::List => {
match api.get("/api/mcp-keys").await {
Ok(data) => {
if let Some(keys) = data.as_array() {
if keys.is_empty() {
println!("No MCP keys found.");
} else {
println!("{:<6} {:<20} {:<15} {:<22} Last Used", "ID", "Name", "Level", "Created");
println!("{}", "-".repeat(80));
for key in keys {
println!("{:<6} {:<20} {:<15} {:<22} {}",
key.get("id").and_then(|v| v.as_i64()).unwrap_or(0),
key.get("name").and_then(|v| v.as_str()).unwrap_or("-"),
key.get("permission_level").and_then(|v| v.as_str()).unwrap_or("-"),
key.get("created_at").and_then(|v| v.as_str()).unwrap_or("-"),
key.get("last_used_at").and_then(|v| v.as_str()).unwrap_or("never"),
);
}
}
} else {
println!("{}", serde_json::to_string_pretty(&data).unwrap_or_default());
}
Ok(())
}
Err(e) => Err(e),
}
}
// Issue 12: Revoke key via API
McpKeyAction::Revoke { id } => {
match api.delete(&format!("/api/mcp-keys/{}", id)).await {
Ok(data) => {
if data.get("deleted").and_then(|v| v.as_bool()).unwrap_or(false) {
println!("Key #{} revoked successfully.", id);
} else {
println!("{}", serde_json::to_string_pretty(&data).unwrap_or_default());
}
Ok(())
}
Err(e) => Err(e),
}
}
}
}
};
if let Err(e) = result {
eprintln!("Error: {}", e);
std::process::exit(1);
}
}

View File

@ -28,27 +28,29 @@ unsafe fn parse_ipv4_packet(start: usize, end: usize, target: *mut ParsedPacket)
return Err(());
}
let ipv4 = &*((start + IPV4_HEADER_START) as *const Ipv4Hdr);
let packet_length = (end - start) as u32;
unsafe {
let ipv4 = &*((start + IPV4_HEADER_START) as *const Ipv4Hdr);
let packet_length = (end - start) as u32;
let t = &mut *target;
t.timestamp_ns = bpf_ktime_get_ns();
core::ptr::copy_nonoverlapping(ipv4.src_addr.as_ptr(), t.src_ip.as_mut_ptr(), 4);
core::ptr::copy_nonoverlapping(ipv4.dst_addr.as_ptr(), t.dst_ip.as_mut_ptr(), 4);
t.packet_length = packet_length;
t.ip_version = 4;
t.protocol = ipv4.proto;
let t = &mut *target;
t.timestamp_ns = bpf_ktime_get_ns();
core::ptr::copy_nonoverlapping(ipv4.src_addr.as_ptr(), t.src_ip.as_mut_ptr(), 4);
core::ptr::copy_nonoverlapping(ipv4.dst_addr.as_ptr(), t.dst_ip.as_mut_ptr(), 4);
t.packet_length = packet_length;
t.ip_version = 4;
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)?,
_ => (0, 0, 0, 0),
};
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)?,
_ => (0, 0, 0, 0),
};
t.payload_length = packet_length.saturating_sub((IPV4_HEADER_END + l4_header_len) as u32);
t.src_port = src_port;
t.dst_port = dst_port;
t.tcp_flags = tcp_flags;
t.payload_length = packet_length.saturating_sub((IPV4_HEADER_END + l4_header_len) as u32);
t.src_port = src_port;
t.dst_port = dst_port;
t.tcp_flags = tcp_flags;
}
Ok(())
}
@ -59,27 +61,29 @@ unsafe fn parse_ipv6_packet(start: usize, end: usize, target: *mut ParsedPacket)
return Err(());
}
let ipv6 = &*((start + IPV6_HEADER_START) as *const Ipv6Hdr);
let packet_length = (end - start) as u32;
unsafe {
let ipv6 = &*((start + IPV6_HEADER_START) as *const Ipv6Hdr);
let packet_length = (end - start) as u32;
let t = &mut *target;
t.timestamp_ns = bpf_ktime_get_ns();
core::ptr::copy_nonoverlapping(ipv6.src_addr.as_ptr(), t.src_ip.as_mut_ptr(), 16);
core::ptr::copy_nonoverlapping(ipv6.dst_addr.as_ptr(), t.dst_ip.as_mut_ptr(), 16);
t.packet_length = packet_length;
t.ip_version = 6;
t.protocol = ipv6.next_hdr;
let t = &mut *target;
t.timestamp_ns = bpf_ktime_get_ns();
core::ptr::copy_nonoverlapping(ipv6.src_addr.as_ptr(), t.src_ip.as_mut_ptr(), 16);
core::ptr::copy_nonoverlapping(ipv6.dst_addr.as_ptr(), t.dst_ip.as_mut_ptr(), 16);
t.packet_length = packet_length;
t.ip_version = 6;
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)?,
_ => (0, 0, 0, 0),
};
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)?,
_ => (0, 0, 0, 0),
};
t.payload_length = packet_length.saturating_sub((IPV6_HEADER_END + l4_header_len) as u32);
t.src_port = src_port;
t.dst_port = dst_port;
t.tcp_flags = tcp_flags;
t.payload_length = packet_length.saturating_sub((IPV6_HEADER_END + l4_header_len) as u32);
t.src_port = src_port;
t.dst_port = dst_port;
t.tcp_flags = tcp_flags;
}
Ok(())
}
@ -89,22 +93,25 @@ unsafe fn parse_tcp(start: usize, end: usize, tcp_start: usize, tcp_end: usize)
if start + tcp_end > end {
return Err(());
}
let tcp = &*((start + tcp_start) as *const TcpHdr);
let data_offset = (*((start + tcp_start + 12) as *const u8) >> 4) as usize;
if data_offset < 5 || data_offset > 15 {
return Err(());
unsafe {
let tcp = &*((start + tcp_start) as *const TcpHdr);
let data_offset = (*((start + tcp_start + 12) as *const u8) >> 4) as usize;
if data_offset < 5 || data_offset > 15 {
return Err(());
}
let header_len = data_offset * 4;
if start + tcp_start + header_len > end {
return Err(());
}
let flags = *((start + tcp_start + 13) as *const u8);
Ok((
u16::from_be_bytes(tcp.source),
u16::from_be_bytes(tcp.dest),
flags,
header_len,
))
}
let header_len = data_offset * 4;
if start + tcp_start + header_len > end {
return Err(());
}
let flags = *((start + tcp_start + 13) as *const u8);
Ok((
u16::from_be_bytes(tcp.source),
u16::from_be_bytes(tcp.dest),
flags,
header_len,
))
}
#[inline(always)]
@ -112,6 +119,7 @@ unsafe fn parse_udp(start: usize, end: usize, udp_start: usize, udp_end: usize)
if start + udp_end > end {
return Err(());
}
let udp = &*((start + udp_start) as *const UdpHdr);
let udp = unsafe { &*((start + udp_start) as *const UdpHdr) };
Ok((udp.src_port(), udp.dst_port(), 0u8, 8usize))
}

View File

@ -30,7 +30,6 @@ traffic_log_csv_path = "traffic_log.csv"
[Misc]
geoip_db_name = "net-guardia/static/geo/GeoLite2-City.mmdb"
database_path = "net-guardia.db"
license_file = "license.key"
[Pipeline]
ingress = ["access_control", "rate_limit", "service"]

View File

@ -30,10 +30,12 @@ pub fn net_guardia(ctx: XdpContext) -> u32 {
#[inline(always)]
unsafe fn compute_symmetric_queue_id(ctx: &XdpContext) -> Option<u32> {
let mut pkt = core::mem::zeroed::<ParsedPacket>();
parsing::parse_packet(ctx.data(), ctx.data_end(), &mut pkt).ok()?;
let num_q = *NUM_QUEUES.get(0)?;
symmetric_queue_id(&pkt, num_q)
unsafe {
let mut pkt = core::mem::zeroed::<ParsedPacket>();
parsing::parse_packet(ctx.data(), ctx.data_end(), &mut pkt).ok()?;
let num_q = *NUM_QUEUES.get(0)?;
symmetric_queue_id(&pkt, num_q)
}
}
#[cfg(not(test))]

View File

@ -200,9 +200,11 @@ unsafe fn try_protocol_filter(ctx: &XdpContext) -> Result<u32, ()> {
#[inline(always)]
unsafe fn compute_symmetric_queue_id() -> Option<u32> {
let pkt = &*PARSED_PACKET.get_ptr(0)?;
let num_q = *NUM_QUEUES.get(0)?;
symmetric_queue_id(pkt, num_q)
unsafe {
let pkt = &*PARSED_PACKET.get_ptr(0)?;
let num_q = *NUM_QUEUES.get(0)?;
symmetric_queue_id(pkt, num_q)
}
}
#[xdp]

17
mcp-server/Cargo.toml Normal file
View File

@ -0,0 +1,17 @@
[package]
name = "mcp-server"
version = "0.1.0"
edition = "2024"
[dependencies]
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
clap = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
[[bin]]
name = "netguardia-mcp"
path = "src/main.rs"

280
mcp-server/src/main.rs Normal file
View File

@ -0,0 +1,280 @@
use std::io::{self, BufRead, Write};
use std::time::Duration;
use clap::Parser;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// NetGuardia MCP Server — thin proxy to the NetGuardia HTTP API.
/// Communicates via stdin/stdout using the MCP JSON-RPC protocol.
#[derive(Parser)]
#[command(name = "netguardia-mcp", about = "NetGuardia MCP Server")]
struct Args {
/// NetGuardia API base URL
#[arg(long, default_value = "http://127.0.0.1:8080")]
api_url: String,
/// API key for authentication (prefer NETGUARDIA_MCP_KEY env var)
#[arg(long)]
api_key: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
struct JsonRpcRequest {
jsonrpc: String,
id: Option<Value>,
method: String,
#[serde(default)]
params: Value,
}
#[derive(Debug, Serialize)]
struct JsonRpcResponse {
jsonrpc: String,
id: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
result: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<JsonRpcError>,
}
#[derive(Debug, Serialize)]
struct JsonRpcError {
code: i64,
message: String,
}
struct McpServer {
client: Client,
api_url: String,
api_key: String,
}
impl McpServer {
fn new(api_url: String, api_key: String) -> Self {
let client = Client::builder()
.timeout(Duration::from_secs(30))
.build()
.expect("Failed to create HTTP client");
Self { client, api_url, api_key }
}
async fn handle_request(&self, req: JsonRpcRequest) -> JsonRpcResponse {
match req.method.as_str() {
"initialize" => self.handle_initialize(req.id),
"tools/list" => self.handle_tools_list(req.id),
"tools/call" => self.handle_tool_call(req.id, req.params).await,
_ => JsonRpcResponse {
jsonrpc: "2.0".into(),
id: req.id,
result: None,
error: Some(JsonRpcError { code: -32601, message: "Method not found".into() }),
},
}
}
fn handle_initialize(&self, id: Option<Value>) -> JsonRpcResponse {
JsonRpcResponse {
jsonrpc: "2.0".into(),
id,
result: Some(serde_json::json!({
"protocolVersion": "2024-11-05",
"capabilities": { "tools": {} },
"serverInfo": {
"name": "netguardia-mcp",
"version": "0.1.0"
}
})),
error: None,
}
}
fn handle_tools_list(&self, id: Option<Value>) -> JsonRpcResponse {
let tools = serde_json::json!({
"tools": [
{ "name": "get_health", "description": "System health status (CPU, memory, uptime, eBPF status)", "inputSchema": { "type": "object", "properties": {} } },
{ "name": "get_stats", "description": "Traffic statistics summary", "inputSchema": { "type": "object", "properties": {} } },
{ "name": "list_alerts", "description": "Recent threat alerts with details", "inputSchema": { "type": "object", "properties": { "limit": { "type": "integer", "default": 20 } } } },
{ "name": "list_blocked_ips", "description": "Currently blocked IPs (manual + auto)", "inputSchema": { "type": "object", "properties": {} } },
{ "name": "get_geo_stats", "description": "GeoIP traffic breakdown", "inputSchema": { "type": "object", "properties": {} } },
{ "name": "get_flow_summary", "description": "Top talkers, protocols, ports", "inputSchema": { "type": "object", "properties": {} } },
{ "name": "get_enforce_mode", "description": "Current mode (monitor/enforce)", "inputSchema": { "type": "object", "properties": {} } },
{ "name": "list_playbooks", "description": "SOAR playbook configurations", "inputSchema": { "type": "object", "properties": {} } },
{ "name": "generate_report", "description": "Generate security summary report", "inputSchema": { "type": "object", "properties": {} } },
{ "name": "block_ip", "description": "Add IP to blacklist", "inputSchema": { "type": "object", "properties": { "ip": { "type": "string" }, "ttl_secs": { "type": "integer", "default": 1800 } }, "required": ["ip"] } },
{ "name": "unblock_ip", "description": "Remove IP from blacklist", "inputSchema": { "type": "object", "properties": { "ip": { "type": "string" } }, "required": ["ip"] } },
{ "name": "set_enforce_mode", "description": "Toggle monitor/enforce mode", "inputSchema": { "type": "object", "properties": { "mode": { "type": "string", "enum": ["monitor", "enforce"] } }, "required": ["mode"] } },
{ "name": "add_dns_filter", "description": "Add domain to DNS blacklist", "inputSchema": { "type": "object", "properties": { "domain": { "type": "string" } }, "required": ["domain"] } },
{ "name": "add_geo_block", "description": "Block country by code", "inputSchema": { "type": "object", "properties": { "country_code": { "type": "string" } }, "required": ["country_code"] } },
]
});
JsonRpcResponse {
jsonrpc: "2.0".into(),
id,
result: Some(tools),
error: None,
}
}
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 (method, path, body) = match tool_name {
"get_health" => ("GET", "/api/health/status", None),
"get_stats" => ("GET", "/api/stats/summary", None),
"list_alerts" => ("GET", "/api/ml/alerts", None),
"list_blocked_ips" => ("GET", "/api/soar/blocks", None),
"get_geo_stats" => ("GET", "/api/stats/geo", None),
"get_flow_summary" => ("GET", "/api/stats/flows", None),
"get_enforce_mode" => ("GET", "/api/system/enforce-mode", None),
"list_playbooks" => ("GET", "/api/soar/playbooks", None),
"generate_report" => ("POST", "/api/report/generate", None),
"block_ip" => {
let ip = arguments.get("ip").and_then(|v| v.as_str()).unwrap_or("");
let body = serde_json::json!({
"ip_version": if ip.contains(':') { 6 } else { 4 },
"direction": "source",
"list_type": "blacklist",
"ip_address": ip,
"port": 0
});
("POST", "/api/acl/add", Some(body))
}
"unblock_ip" => {
let ip = arguments.get("ip").and_then(|v| v.as_str()).unwrap_or("");
let body = serde_json::json!({
"ip_version": if ip.contains(':') { 6 } else { 4 },
"direction": "source",
"list_type": "blacklist",
"ip_address": ip,
"port": 0
});
("POST", "/api/acl/delete", Some(body))
}
"set_enforce_mode" => {
let mode = arguments.get("mode").and_then(|v| v.as_str()).unwrap_or("monitor");
let body = serde_json::json!({"mode": mode});
("POST", "/api/system/enforce-mode", Some(body))
}
"add_dns_filter" => {
let domain = arguments.get("domain").and_then(|v| v.as_str()).unwrap_or("");
let body = serde_json::json!({"domain": domain});
("POST", "/api/filter/dns/add", Some(body))
}
"add_geo_block" => {
let code = arguments.get("country_code").and_then(|v| v.as_str()).unwrap_or("");
let body = serde_json::json!({"codes": [code]});
("POST", "/api/acl/geo/block", Some(body))
}
_ => {
return JsonRpcResponse {
jsonrpc: "2.0".into(),
id,
result: None,
error: Some(JsonRpcError { code: -32602, message: format!("Unknown tool: {}", tool_name) }),
};
}
};
let url = format!("{}{}", self.api_url, path);
let mut req_builder = match method {
"POST" => self.client.post(&url),
_ => self.client.get(&url),
};
req_builder = req_builder.header("X-API-Key", &self.api_key);
if let Some(body) = body {
req_builder = req_builder.json(&body);
}
match req_builder.send().await {
Ok(resp) => {
let status = resp.status();
let body: Value = resp.json().await.unwrap_or(Value::Null);
if status.is_success() {
JsonRpcResponse {
jsonrpc: "2.0".into(),
id,
result: Some(serde_json::json!({
"content": [{ "type": "text", "text": serde_json::to_string_pretty(&body).unwrap_or_default() }]
})),
error: None,
}
} else {
JsonRpcResponse {
jsonrpc: "2.0".into(),
id,
result: Some(serde_json::json!({
"content": [{ "type": "text", "text": format!("API error ({}): {}", status, serde_json::to_string(&body).unwrap_or_default()) }],
"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,
}
}
}
}
}
#[tokio::main]
async fn main() {
let args = Args::parse();
let api_key = args.api_key
.or_else(|| std::env::var("NETGUARDIA_MCP_KEY").ok())
.unwrap_or_else(|| {
eprintln!("Error: No API key provided. Set NETGUARDIA_MCP_KEY env var or use --api-key flag.");
std::process::exit(1);
});
let server = McpServer::new(args.api_url, api_key);
let stdin = io::stdin();
let mut stdout = io::stdout();
for line in stdin.lock().lines() {
let line = match line {
Ok(l) => l,
Err(_) => break,
};
if line.trim().is_empty() {
continue;
}
let req: JsonRpcRequest = match serde_json::from_str(&line) {
Ok(r) => r,
Err(e) => {
let err_resp = JsonRpcResponse {
jsonrpc: "2.0".into(),
id: None,
result: None,
error: Some(JsonRpcError { code: -32700, message: format!("Parse error: {}", e) }),
};
let _ = writeln!(stdout, "{}", serde_json::to_string(&err_resp).unwrap());
let _ = stdout.flush();
continue;
}
};
let resp = server.handle_request(req).await;
let _ = writeln!(stdout, "{}", serde_json::to_string(&resp).unwrap());
let _ = stdout.flush();
}
}

@ -1 +1 @@
Subproject commit 9bebac106b9ff3322f4236355c32dc9ae9c84f41
Subproject commit c651241916f82fcb5df4f60ddda9c46bd06fc6e0

View File

@ -47,6 +47,9 @@ tract-onnx = { workspace = true }
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"] }
# HTTP client (Telegram, MCP proxy)
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
# Architecture
async-trait = "0.1"
dashmap = "6"
@ -59,15 +62,16 @@ maxminddb = { workspace = true }
ipnetwork = { workspace = true }
lru = { workspace = true }
rusqlite = { workspace = true }
r2d2 = "0.8"
r2d2_sqlite = "0.27"
jsonwebtoken = { workspace = true }
argon2 = { workspace = true }
sha2 = "0.10"
sd-notify = "0.4"
rand = { workspace = true }
ed25519-dalek = { workspace = true, optional = true }
base64 = { workspace = true, optional = true }
[features]
default = []
license = ["dep:ed25519-dalek", "dep:base64"]
[build-dependencies]
cargo_metadata = { workspace = true }

View File

@ -11,36 +11,6 @@ fn main() {
build_ebpf_package("ingress-ebpf", "ingress-ebpf");
build_ebpf_package("egress-ebpf", "egress-ebpf");
build_frontend();
embed_license_public_key();
}
fn embed_license_public_key() {
let license_enabled = env::var("CARGO_FEATURE_LICENSE").is_ok();
let project_root = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap())
.parent()
.unwrap()
.to_path_buf();
let key_path = project_root.join("license_pub.key");
println!("cargo:rerun-if-changed={}", key_path.display());
if key_path.exists() {
let key_hex = fs::read_to_string(&key_path)
.expect("Failed to read license_pub.key")
.trim()
.to_string();
println!("cargo:rustc-env=LICENSE_PUBLIC_KEY={}", key_hex);
} else if license_enabled {
panic!(
"license feature enabled but license_pub.key not found at {}.\n\
Generate it with: cd license-generator && cargo run -- keygen\n\
Then copy license_pub.key to the repo root.",
key_path.display()
);
} else {
println!("cargo:rustc-env=LICENSE_PUBLIC_KEY=DISABLED");
}
}
/// Resolve the absolute path of bpf-linker.
@ -183,8 +153,12 @@ fn build_ebpf_package(package_name: &str, target_subdir: &str) {
for (name, binary) in executables {
let dst = out_dir.join(name);
let _: u64 =
fs::copy(&binary, &dst).unwrap_or_else(|err| panic!("failed to copy {binary:?} to {dst:?}: {err}"));
// Only copy if content actually changed to avoid updating mtime,
// which would cause cargo to unnecessarily relink the binary.
if !files_equal(&binary, &dst) {
let _: u64 =
fs::copy(&binary, &dst).unwrap_or_else(|err| panic!("failed to copy {binary:?} to {dst:?}: {err}"));
}
}
} else {
let Package { targets, .. } = ebpf_package;
@ -214,16 +188,24 @@ fn build_frontend() {
panic!("Frontend directory {:?} does not exist", frontend_dir);
}
println!("cargo:rerun-if-changed={}", frontend_dir.join("src").display());
println!("cargo:rerun-if-changed={}", frontend_dir.join("public").display());
println!("cargo:rerun-if-changed={}", frontend_dir.join("package.json").display());
println!("cargo:rerun-if-changed={}", frontend_dir.join("package-lock.json").display());
println!("cargo:rerun-if-changed={}", frontend_dir.join("next.config.js").display());
println!("cargo:rerun-if-changed={}", frontend_dir.join("tailwind.config.js").display());
println!("cargo:rerun-if-changed={}", frontend_dir.join("postcss.config.js").display());
println!("cargo:rerun-if-changed={}", frontend_dir.join("tsconfig.json").display());
// Emit rerun-if-changed for individual files so that edits inside
// subdirectories (e.g. src/components/Foo.vue) actually trigger a rebuild.
// Directory-level rerun-if-changed only watches the directory mtime, which
// doesn't change when files in subdirectories are modified on Linux.
for dir_name in ["src", "public"] {
let dir_path = frontend_dir.join(dir_name);
if dir_path.exists() {
emit_rerun_if_changed_recursive(&dir_path);
}
}
for file_name in ["package.json", "package-lock.json", "vite.config.ts", "tsconfig.json"] {
let file_path = frontend_dir.join(file_name);
if file_path.exists() {
println!("cargo:rerun-if-changed={}", file_path.display());
}
}
let out_dir = frontend_dir.join("out");
let out_dir = frontend_dir.join("dist");
let need_build = needs_frontend_rebuild(&frontend_dir, &out_dir, &static_dir);
if !need_build {
return;
@ -233,7 +215,7 @@ fn build_frontend() {
.unwrap_or_else(|_| panic!("npm not found in PATH. Install Node.js first."));
let status = Command::new(&npm)
.arg("install")
.args(["install", "--include=optional"])
.current_dir(&frontend_dir)
.status()
.unwrap_or_else(|err| panic!("failed to run npm install: {err}"));
@ -245,12 +227,12 @@ fn build_frontend() {
.unwrap_or_else(|_| panic!("npx not found in PATH. Install Node.js first."));
let status = Command::new(&npx)
.args(["next", "build"])
.args(["vite", "build"])
.current_dir(&frontend_dir)
.status()
.unwrap_or_else(|err| panic!("failed to run next build: {err}"));
.unwrap_or_else(|err| panic!("failed to run vite build: {err}"));
if !status.success() {
panic!("next build failed with exit code: {:?}", status.code());
panic!("vite build failed with exit code: {:?}", status.code());
}
if static_dir.exists() {
@ -259,6 +241,11 @@ fn build_frontend() {
fs::create_dir_all(&static_dir).unwrap_or_else(|err| panic!("failed to create {:?}: {err}", static_dir));
copy_dir_all(&out_dir, &static_dir).unwrap_or_else(|err| panic!("failed to copy frontend build: {err}"));
// rust_embed embeds static/ at compile time. After copying new frontend
// output into static/web/, we must tell cargo to recompile the crate so
// the embedded files are refreshed in the binary.
emit_rerun_if_changed_recursive(&static_dir);
}
fn needs_frontend_rebuild(frontend_dir: &std::path::Path, out_dir: &std::path::Path, static_dir: &std::path::Path) -> bool {
@ -277,8 +264,8 @@ fn needs_frontend_rebuild(frontend_dir: &std::path::Path, out_dir: &std::path::P
};
let essential_items = [
"src", "public", "package.json", "next.config.js",
"tailwind.config.js", "postcss.config.js", "tsconfig.json", "package-lock.json",
"src", "public", "package.json", "vite.config.ts",
"tsconfig.json", "package-lock.json",
];
for item_name in essential_items {
@ -318,6 +305,32 @@ fn get_dir_last_modified(path: &std::path::Path) -> Option<SystemTime> {
None
}
/// Returns true if both files exist and have identical contents.
fn files_equal(a: &std::path::Path, b: &std::path::Path) -> bool {
let Ok(a_meta) = fs::metadata(a) else { return false };
let Ok(b_meta) = fs::metadata(b) else { return false };
if a_meta.len() != b_meta.len() {
return false;
}
let Ok(a_bytes) = fs::read(a) else { return false };
let Ok(b_bytes) = fs::read(b) else { return false };
a_bytes == b_bytes
}
fn emit_rerun_if_changed_recursive(path: &std::path::Path) {
if path.is_file() {
println!("cargo:rerun-if-changed={}", path.display());
return;
}
if path.is_dir()
&& let Ok(entries) = fs::read_dir(path)
{
for entry in entries.flatten() {
emit_rerun_if_changed_recursive(&entry.path());
}
}
}
fn copy_dir_all(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<()> {
for entry in fs::read_dir(src)? {
let entry = entry?;

View File

@ -0,0 +1,64 @@
use std::net::{IpAddr, SocketAddrV4, SocketAddrV6};
use std::sync::Arc;
use async_trait::async_trait;
use crate::core::ebpf::access_control::AccessControl;
use crate::interface::port::access_control::AccessControlPort;
use crate::model::direction::FlowDirection;
use crate::model::error::Error;
use crate::model::list_type::ListType;
/// Adapter that implements AccessControlPort by delegating to the eBPF AccessControl.
pub struct EbpfAccessControlAdapter {
access_control: Arc<AccessControl>,
}
impl EbpfAccessControlAdapter {
pub fn new(access_control: Arc<AccessControl>) -> Self {
Self { access_control }
}
}
#[async_trait]
impl AccessControlPort for EbpfAccessControlAdapter {
async fn block_ip(&self, ip: &str) -> Result<(), Error> {
let addr: IpAddr = ip.parse().map_err(|_| {
Error::from(crate::model::error::ebpf::EbpfError::InvalidIpAddress { ip: ip.to_string() })
})?;
match addr {
IpAddr::V4(v4) => {
let socket = SocketAddrV4::new(v4, 0);
self.access_control
.add_ipv4_list(FlowDirection::Source, ListType::Black, socket)
.await
}
IpAddr::V6(v6) => {
let socket = SocketAddrV6::new(v6, 0, 0, 0);
self.access_control
.add_ipv6_list(FlowDirection::Source, ListType::Black, socket)
.await
}
}
}
async fn unblock_ip(&self, ip: &str) -> Result<(), Error> {
let addr: IpAddr = ip.parse().map_err(|_| {
Error::from(crate::model::error::ebpf::EbpfError::InvalidIpAddress { ip: ip.to_string() })
})?;
match addr {
IpAddr::V4(v4) => {
let socket = SocketAddrV4::new(v4, 0);
self.access_control
.remove_ipv4_list(FlowDirection::Source, ListType::Black, socket)
.await
}
IpAddr::V6(v6) => {
let socket = SocketAddrV6::new(v6, 0, 0, 0);
self.access_control
.remove_ipv6_list(FlowDirection::Source, ListType::Black, socket)
.await
}
}
}
}

View File

@ -3,11 +3,7 @@ use std::net::{SocketAddrV4, SocketAddrV6};
use actix_web::{web, HttpResponse, Responder, Scope};
use serde::Deserialize;
use crate::interface::port::repository::RepositoryPort;
type Repo = dyn RepositoryPort;
use crate::core::ebpf::access_control::AccessControl;
use crate::core::ebpf::geo_block::GeoBlock;
use crate::core::acl_service::AclService;
use crate::model::direction::FlowDirection;
use crate::model::list_type::ListType;
@ -31,42 +27,29 @@ pub fn initialize() -> Scope {
async fn get_ipv4_list(
path: web::Path<(FlowDirection, ListType)>,
access_control: web::Data<AccessControl>,
acl: web::Data<AclService>,
) -> impl Responder {
let (direction, list_type) = path.into_inner();
let list = access_control.get_ipv4_list(direction, list_type).await;
let list = acl.access_control().get_ipv4_list(direction, list_type).await;
HttpResponse::Ok().json(list)
}
async fn get_ipv6_list(
path: web::Path<(FlowDirection, ListType)>,
access_control: web::Data<AccessControl>,
acl: web::Data<AclService>,
) -> impl Responder {
let (direction, list_type) = path.into_inner();
let list = access_control.get_ipv6_list(direction, list_type).await;
let list = acl.access_control().get_ipv6_list(direction, list_type).await;
HttpResponse::Ok().json(list)
}
fn direction_str(d: FlowDirection) -> &'static str {
match d { FlowDirection::Source => "source", FlowDirection::Destination => "destination" }
}
fn list_type_str(l: ListType) -> &'static str {
match l { ListType::White => "whitelist", ListType::Black => "blacklist" }
}
async fn add_ipv4_list(
address: web::Json<SocketAddrV4>,
path: web::Path<(FlowDirection, ListType)>,
access_control: web::Data<AccessControl>,
db: web::Data<Repo>,
acl: web::Data<AclService>,
) -> impl Responder {
let address = address.into_inner();
let (direction, list_type) = path.into_inner();
if let Err(e) = db.insert_acl_rule(4, direction_str(direction), list_type_str(list_type), &address.ip().to_string(), address.port()) {
return HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()}));
}
match access_control.add_ipv4_list(direction, list_type, address).await {
match acl.add_ipv4(direction, list_type, address.into_inner()).await {
Ok(_) => HttpResponse::Ok().finish(),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
@ -75,15 +58,10 @@ async fn add_ipv4_list(
async fn add_ipv6_list(
address: web::Json<SocketAddrV6>,
path: web::Path<(FlowDirection, ListType)>,
access_control: web::Data<AccessControl>,
db: web::Data<Repo>,
acl: web::Data<AclService>,
) -> impl Responder {
let address = address.into_inner();
let (direction, list_type) = path.into_inner();
if let Err(e) = db.insert_acl_rule(6, direction_str(direction), list_type_str(list_type), &address.ip().to_string(), address.port()) {
return HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()}));
}
match access_control.add_ipv6_list(direction, list_type, address).await {
match acl.add_ipv6(direction, list_type, address.into_inner()).await {
Ok(_) => HttpResponse::Ok().finish(),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
@ -92,15 +70,10 @@ async fn add_ipv6_list(
async fn remove_ipv4_list(
address: web::Json<SocketAddrV4>,
path: web::Path<(FlowDirection, ListType)>,
access_control: web::Data<AccessControl>,
db: web::Data<Repo>,
acl: web::Data<AclService>,
) -> impl Responder {
let address = address.into_inner();
let (direction, list_type) = path.into_inner();
if let Err(e) = db.delete_acl_rule(4, direction_str(direction), list_type_str(list_type), &address.ip().to_string(), address.port()) {
return HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()}));
}
match access_control.remove_ipv4_list(direction, list_type, address).await {
match acl.remove_ipv4(direction, list_type, address.into_inner()).await {
Ok(_) => HttpResponse::Ok().finish(),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
@ -109,42 +82,27 @@ async fn remove_ipv4_list(
async fn remove_ipv6_list(
address: web::Json<SocketAddrV6>,
path: web::Path<(FlowDirection, ListType)>,
access_control: web::Data<AccessControl>,
db: web::Data<Repo>,
acl: web::Data<AclService>,
) -> impl Responder {
let address = address.into_inner();
let (direction, list_type) = path.into_inner();
if let Err(e) = db.delete_acl_rule(6, direction_str(direction), list_type_str(list_type), &address.ip().to_string(), address.port()) {
return HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()}));
}
match access_control.remove_ipv6_list(direction, list_type, address).await {
match acl.remove_ipv6(direction, list_type, address.into_inner()).await {
Ok(_) => HttpResponse::Ok().finish(),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
}
async fn get_geo_blocked(
geo_block: web::Data<GeoBlock>,
) -> impl Responder {
let blocked = geo_block.get_blocked_countries();
HttpResponse::Ok().json(serde_json::json!({"blocked_countries": blocked}))
async fn get_geo_blocked(acl: web::Data<AclService>) -> impl Responder {
HttpResponse::Ok().json(serde_json::json!({"blocked_countries": acl.get_blocked_countries()}))
}
async fn block_geo_countries(
body: web::Json<CountryCodesRequest>,
geo_block: web::Data<GeoBlock>,
db: web::Data<Repo>,
acl: web::Data<AclService>,
) -> impl Responder {
let codes = body.into_inner().country_codes;
for code in &codes {
if let Err(e) = db.insert_geo_country(code) {
return HttpResponse::InternalServerError()
.json(serde_json::json!({"error": e.to_string()}));
}
}
match geo_block.block_countries(&codes) {
match acl.block_geo_countries(&codes) {
Ok(total_prefixes) => HttpResponse::Ok().json(serde_json::json!({
"blocked_countries": geo_block.get_blocked_countries(),
"blocked_countries": acl.get_blocked_countries(),
"total_prefixes": total_prefixes,
})),
Err(e) => HttpResponse::InternalServerError()
@ -154,19 +112,12 @@ async fn block_geo_countries(
async fn unblock_geo_countries(
body: web::Json<CountryCodesRequest>,
geo_block: web::Data<GeoBlock>,
db: web::Data<Repo>,
acl: web::Data<AclService>,
) -> impl Responder {
let codes = body.into_inner().country_codes;
for code in &codes {
if let Err(e) = db.delete_geo_country(code) {
return HttpResponse::InternalServerError()
.json(serde_json::json!({"error": e.to_string()}));
}
}
match geo_block.unblock_countries(&codes) {
match acl.unblock_geo_countries(&codes) {
Ok(total_prefixes) => HttpResponse::Ok().json(serde_json::json!({
"blocked_countries": geo_block.get_blocked_countries(),
"blocked_countries": acl.get_blocked_countries(),
"total_prefixes": total_prefixes,
})),
Err(e) => HttpResponse::InternalServerError()

View File

@ -1,10 +1,12 @@
use actix_web::{web, HttpMessage, HttpRequest, HttpResponse, Responder, Scope};
use actix_web::{web, HttpResponse, Responder, Scope};
use macros::log;
use serde::Deserialize;
use crate::core::auth::extractor::AuthClaims;
use crate::core::auth::jwt::JwtService;
use crate::model::auth::Claims;
use crate::core::auth::password;
use crate::interface::port::repository::RepositoryPort;
use crate::model::error::auth::AuthError;
type Repo = dyn RepositoryPort;
@ -62,13 +64,10 @@ fn validate_password(password: &str) -> Result<(), &'static str> {
Ok(())
}
fn extract_claims(req: &HttpRequest) -> Option<Claims> {
req.extensions().get::<Claims>().cloned()
}
fn has_permission(claims: &Claims, permission: &str) -> bool {
claims.permissions.iter().any(|p| p == permission)
}
/// 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>,
@ -93,7 +92,11 @@ async fn login(
let user = match db.find_user(&req.username) {
Ok(Some(u)) => u,
_ => {
let _ = db.record_login_failure(&req.username);
// 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"}));
}
@ -104,14 +107,18 @@ async fn login(
match password::verify_password(&req.password, &hash) {
Ok(true) => {}
_ => {
let _ = db.record_login_failure(&req.username);
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
let _ = db.clear_login_failures(&req.username);
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();
@ -136,19 +143,10 @@ async fn login(
}
async fn register(
req: HttpRequest,
auth: AuthClaims,
body: web::Json<RegisterRequest>,
db: web::Data<Repo>,
) -> impl Responder {
// Check caller has users:admin permission
let _claims = match extract_claims(&req) {
Some(c) if has_permission(&c, "users:admin") => c,
_ => {
return HttpResponse::Forbidden()
.json(serde_json::json!({"error": "Admin access required"}));
}
};
let reg = body.into_inner();
// Validate input
@ -165,6 +163,12 @@ async fn register(
.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(_) => {
@ -179,8 +183,9 @@ async fn register(
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])
{
let _ = 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}))
@ -191,47 +196,32 @@ async fn register(
}
}
async fn me(req: HttpRequest, db: web::Data<Repo>) -> impl Responder {
match extract_claims(&req) {
Some(claims) => {
let user_groups = db.get_user_groups(claims.sub).unwrap_or_default();
let group_names: Vec<String> = user_groups.iter()
.map(|(_id, name, _desc, _perms)| name.clone())
.collect();
// Derive role from groups for backwards compat
let role = if group_names.iter().any(|n| n == "Administrator") {
"admin"
} else {
"viewer"
};
// Get fresh permissions from groups (not from JWT claims which may be stale)
let permissions = db.get_user_permissions(claims.sub).unwrap_or_default();
HttpResponse::Ok().json(serde_json::json!({
"id": claims.sub,
"username": claims.username,
"role": role,
"permissions": permissions,
"groups": group_names,
}))
}
None => HttpResponse::Unauthorized()
.json(serde_json::json!({"error": "Not authenticated"})),
}
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 change_password(
req: HttpRequest,
auth: AuthClaims,
body: web::Json<ChangePasswordRequest>,
db: web::Data<Repo>,
) -> impl Responder {
let claims = match extract_claims(&req) {
Some(c) => c,
None => {
return HttpResponse::Unauthorized()
.json(serde_json::json!({"error": "Not authenticated"}));
}
};
let claims = &*auth;
let change_req = body.into_inner();
// Validate new password
@ -278,31 +268,17 @@ async fn change_password(
// --- User Management (admin only) ---
async fn list_users(
req: HttpRequest,
_auth: AuthClaims,
db: web::Data<Repo>,
) -> impl Responder {
let claims = match extract_claims(&req) {
Some(c) => c,
None => {
return HttpResponse::Unauthorized()
.json(serde_json::json!({"error": "Not authenticated"}));
}
};
if !has_permission(&claims, "users:admin") {
return HttpResponse::Forbidden()
.json(serde_json::json!({"error": "Admin access required"}));
}
match db.list_users() {
match db.list_users_with_groups() {
Ok(users) => {
let result: Vec<serde_json::Value> = users.into_iter().map(|(id, username, _role, force_pw, created_at)| {
let user_groups = db.get_user_groups(id).unwrap_or_default();
let result: Vec<serde_json::Value> = users.into_iter().map(|(id, username, _role, force_pw, created_at, user_groups)| {
let groups: Vec<serde_json::Value> = user_groups.iter()
.map(|(gid, name, _desc, _perms)| serde_json::json!({"id": gid, "name": name}))
.map(|(gid, name)| serde_json::json!({"id": gid, "name": name}))
.collect();
// Derive role from groups for backwards compat
let role = if user_groups.iter().any(|(_id, name, _desc, _perms)| name == "Administrator") {
let role = if user_groups.iter().any(|(_id, name)| name == "Administrator") {
"admin"
} else {
"viewer"
@ -324,27 +300,14 @@ async fn list_users(
}
async fn delete_user(
req: HttpRequest,
_auth: AuthClaims,
path: web::Path<i64>,
db: web::Data<Repo>,
) -> impl Responder {
let claims = match extract_claims(&req) {
Some(c) => c,
None => {
return HttpResponse::Unauthorized()
.json(serde_json::json!({"error": "Not authenticated"}));
}
};
if !has_permission(&claims, "users:admin") {
return HttpResponse::Forbidden()
.json(serde_json::json!({"error": "Admin access required"}));
}
let user_id = path.into_inner();
// Can't delete self
if claims.sub == user_id {
if _auth.sub == user_id {
return HttpResponse::BadRequest()
.json(serde_json::json!({"error": "Cannot delete your own account"}));
}
@ -369,28 +332,15 @@ async fn delete_user(
}
async fn update_role(
req: HttpRequest,
_auth: AuthClaims,
path: web::Path<i64>,
body: web::Json<serde_json::Value>,
db: web::Data<Repo>,
) -> impl Responder {
let claims = match extract_claims(&req) {
Some(c) => c,
None => {
return HttpResponse::Unauthorized()
.json(serde_json::json!({"error": "Not authenticated"}));
}
};
if !has_permission(&claims, "users:admin") {
return HttpResponse::Forbidden()
.json(serde_json::json!({"error": "Admin access required"}));
}
let user_id = path.into_inner();
// Can't change own role
if claims.sub == user_id {
if _auth.sub == user_id {
return HttpResponse::BadRequest()
.json(serde_json::json!({"error": "Cannot change your own role"}));
}
@ -425,24 +375,11 @@ async fn update_role(
}
async fn reset_password(
req: HttpRequest,
_auth: AuthClaims,
path: web::Path<i64>,
body: web::Json<serde_json::Value>,
db: web::Data<Repo>,
) -> impl Responder {
let claims = match extract_claims(&req) {
Some(c) => c,
None => {
return HttpResponse::Unauthorized()
.json(serde_json::json!({"error": "Not authenticated"}));
}
};
if !has_permission(&claims, "users:admin") {
return HttpResponse::Forbidden()
.json(serde_json::json!({"error": "Admin access required"}));
}
let user_id = path.into_inner();
let new_password = match body.get("new_password").or_else(|| body.get("password")).and_then(|v| v.as_str()) {
@ -489,32 +426,25 @@ async fn reset_password(
// --- User Group Management (users:admin required) ---
async fn list_groups(
req: HttpRequest,
_auth: AuthClaims,
db: web::Data<Repo>,
) -> impl Responder {
let claims = match extract_claims(&req) {
Some(c) => c,
None => {
return HttpResponse::Unauthorized()
.json(serde_json::json!({"error": "Not authenticated"}));
}
};
if !has_permission(&claims, "users:admin") {
return HttpResponse::Forbidden()
.json(serde_json::json!({"error": "Admin access required"}));
}
match db.list_user_groups() {
Ok(groups) => {
let result: Vec<serde_json::Value> = groups.into_iter().map(|(id, name, description, permissions, created_at)| {
let perms: serde_json::Value = serde_json::from_str(&permissions).unwrap_or(serde_json::json!([]));
let members: Vec<serde_json::Value> = db.get_group_members(id)
.unwrap_or_default()
.into_iter()
.map(|(uid, username)| serde_json::json!({"id": uid, "username": username}))
.collect();
serde_json::json!({
"id": id,
"name": name,
"description": description,
"permissions": perms,
"created_at": created_at,
"members": members,
})
}).collect();
HttpResponse::Ok().json(result)
@ -525,23 +455,10 @@ async fn list_groups(
}
async fn create_group(
req: HttpRequest,
_auth: AuthClaims,
body: web::Json<serde_json::Value>,
db: web::Data<Repo>,
) -> impl Responder {
let claims = match extract_claims(&req) {
Some(c) => c,
None => {
return HttpResponse::Unauthorized()
.json(serde_json::json!({"error": "Not authenticated"}));
}
};
if !has_permission(&claims, "users:admin") {
return HttpResponse::Forbidden()
.json(serde_json::json!({"error": "Admin access required"}));
}
let name = match body.get("name").and_then(|v| v.as_str()) {
Some(n) if !n.is_empty() => n,
_ => {
@ -569,23 +486,10 @@ async fn create_group(
}
async fn get_group(
req: HttpRequest,
_auth: AuthClaims,
path: web::Path<i64>,
db: web::Data<Repo>,
) -> impl Responder {
let claims = match extract_claims(&req) {
Some(c) => c,
None => {
return HttpResponse::Unauthorized()
.json(serde_json::json!({"error": "Not authenticated"}));
}
};
if !has_permission(&claims, "users:admin") {
return HttpResponse::Forbidden()
.json(serde_json::json!({"error": "Admin access required"}));
}
let group_id = path.into_inner();
match db.get_user_group(group_id) {
@ -609,24 +513,11 @@ async fn get_group(
}
async fn update_group(
req: HttpRequest,
_auth: AuthClaims,
path: web::Path<i64>,
body: web::Json<serde_json::Value>,
db: web::Data<Repo>,
) -> impl Responder {
let claims = match extract_claims(&req) {
Some(c) => c,
None => {
return HttpResponse::Unauthorized()
.json(serde_json::json!({"error": "Not authenticated"}));
}
};
if !has_permission(&claims, "users:admin") {
return HttpResponse::Forbidden()
.json(serde_json::json!({"error": "Admin access required"}));
}
let group_id = path.into_inner();
// Check group exists
@ -669,23 +560,10 @@ async fn update_group(
}
async fn delete_group(
req: HttpRequest,
_auth: AuthClaims,
path: web::Path<i64>,
db: web::Data<Repo>,
) -> impl Responder {
let claims = match extract_claims(&req) {
Some(c) => c,
None => {
return HttpResponse::Unauthorized()
.json(serde_json::json!({"error": "Not authenticated"}));
}
};
if !has_permission(&claims, "users:admin") {
return HttpResponse::Forbidden()
.json(serde_json::json!({"error": "Admin access required"}));
}
let group_id = path.into_inner();
// Protect built-in groups
@ -708,24 +586,11 @@ async fn delete_group(
}
async fn set_user_groups(
req: HttpRequest,
_auth: AuthClaims,
path: web::Path<i64>,
body: web::Json<serde_json::Value>,
db: web::Data<Repo>,
) -> impl Responder {
let claims = match extract_claims(&req) {
Some(c) => c,
None => {
return HttpResponse::Unauthorized()
.json(serde_json::json!({"error": "Not authenticated"}));
}
};
if !has_permission(&claims, "users:admin") {
return HttpResponse::Forbidden()
.json(serde_json::json!({"error": "Admin access required"}));
}
let user_id = path.into_inner();
// Protect the default admin account
@ -803,4 +668,13 @@ mod tests {
assert!(validate_password("1234567").is_err());
assert!(validate_password("a").is_err());
}
#[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
let parsed = PasswordHash::new(DUMMY_HASH);
assert!(parsed.is_ok(), "DUMMY_HASH should be a valid Argon2 hash format, got error: {:?}", parsed.err());
}
}

View File

@ -4,37 +4,30 @@ use mime_guess::from_path;
use crate::utils::static_files::StaticFiles;
pub async fn default_route(req: HttpRequest) -> impl Responder {
let request_path = req.path();
let path = req.path();
let file_system_path = if request_path == "/" {
let file_path = if path == "/" {
"web/index.html".to_string()
} else {
format!("web{}", request_path)
format!("web{}", path)
};
if let Some(content) = StaticFiles::get(&file_system_path) {
let mime_type = from_path(&file_system_path).first_or_octet_stream();
// 1. Try exact static file match
if let Some(content) = StaticFiles::get(&file_path) {
let mime_type = from_path(&file_path).first_or_octet_stream();
return HttpResponse::Ok()
.content_type(mime_type.as_ref())
.body(content.data.into_owned());
}
let html_path = format!("{}.html", file_system_path);
if let Some(content) = StaticFiles::get(&html_path) {
return HttpResponse::Ok()
.content_type("text/html")
.body(content.data.into_owned());
// 2. Has file extension (contains '.') but not found → 404
if path.contains('.') {
return HttpResponse::NotFound().body("404 Not Found");
}
let index_path = format!("{}/index.html", file_system_path);
if let Some(content) = StaticFiles::get(&index_path) {
return HttpResponse::Ok()
.content_type("text/html")
.body(content.data.into_owned());
}
match StaticFiles::get("web/404.html") {
Some(page) => HttpResponse::NotFound()
// 3. Clean path → SPA fallback to index.html
match StaticFiles::get("web/index.html") {
Some(page) => HttpResponse::Ok()
.content_type("text/html")
.body(page.data.into_owned()),
None => HttpResponse::NotFound().body("404 Not Found"),

View File

@ -5,10 +5,7 @@ use actix_web::{web, HttpResponse, Responder, Scope};
use common::model::http_method::HttpMethod;
use serde::Deserialize;
use crate::interface::port::repository::RepositoryPort;
type Repo = dyn RepositoryPort;
use crate::core::ebpf::dns_filter::DnsFilter;
use crate::core::dns_filter_service::DnsFilterService;
use crate::core::ebpf::protocol_filter::ProtocolFilter;
/// Convert a fallible result into an Ok (200) or InternalServerError (500) response.
@ -42,56 +39,32 @@ fn dns_scope() -> Scope {
)
}
const MAX_DNS_DOMAINS_PER_REQUEST: usize = 1000;
async fn get_dns_blacklist(service: web::Data<DnsFilter>) -> impl Responder {
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<DnsFilter>,
db: web::Data<Repo>,
service: web::Data<DnsFilterService>,
) -> impl Responder {
let domains = payload.into_inner().domains;
if domains.len() > MAX_DNS_DOMAINS_PER_REQUEST {
return HttpResponse::BadRequest()
.json(serde_json::json!({"error": format!("too many domains (max {})", MAX_DNS_DOMAINS_PER_REQUEST)}));
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()})),
}
for domain in &domains {
if let Err(e) = db.insert_dns_domain(domain) {
return HttpResponse::InternalServerError()
.json(serde_json::json!({"error": e.to_string()}));
}
}
for domain in &domains {
if let Err(e) = service.add_domain(domain) {
return HttpResponse::InternalServerError()
.json(serde_json::json!({"error": e.to_string()}));
}
}
HttpResponse::Ok().json(serde_json::json!({"added": domains.len()}))
}
async fn remove_dns_blacklist(
payload: web::Json<DnsDomainsPayload>,
service: web::Data<DnsFilter>,
db: web::Data<Repo>,
service: web::Data<DnsFilterService>,
) -> impl Responder {
let domains = payload.into_inner().domains;
for domain in &domains {
if let Err(e) = db.delete_dns_domain(domain) {
return HttpResponse::InternalServerError()
.json(serde_json::json!({"error": e.to_string()}));
}
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()})),
}
for domain in &domains {
if let Err(e) = service.remove_domain(domain) {
return HttpResponse::InternalServerError()
.json(serde_json::json!({"error": e.to_string()}));
}
}
HttpResponse::Ok().json(serde_json::json!({"removed": domains.len()}))
}
fn http_scope() -> Scope {

View File

@ -0,0 +1,88 @@
use actix_web::{web, HttpResponse, Scope};
use serde::Deserialize;
use crate::adapter::persistence::Database;
use crate::core::auth::extractor::AuthClaims;
pub fn initialize() -> Scope {
web::scope("/mcp-keys")
.route("", web::get().to(list_keys))
.route("/generate", web::post().to(generate_key))
.route("/{id}", web::delete().to(delete_key))
}
async fn list_keys(
_auth: AuthClaims,
db: web::Data<Database>,
) -> HttpResponse {
match db.list_mcp_keys() {
Ok(keys) => {
let responses: Vec<serde_json::Value> = keys.into_iter().map(|(id, name, level, created, last_used)| {
serde_json::json!({
"id": id,
"name": name,
"permission_level": level,
"created_at": created,
"last_used_at": last_used,
})
}).collect();
HttpResponse::Ok().json(responses)
}
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
}
#[derive(Deserialize)]
struct GenerateKeyRequest {
name: String,
level: Option<String>,
}
async fn generate_key(
_auth: AuthClaims,
db: web::Data<Database>,
body: web::Json<GenerateKeyRequest>,
) -> HttpResponse {
use rand::Rng;
use rand::distr::Alphanumeric;
let raw_key: String = rand::rng()
.sample_iter(&Alphanumeric)
.take(32)
.map(char::from)
.collect();
use sha2::{Sha256, Digest};
let key_hash = {
let mut hasher = Sha256::new();
hasher.update(raw_key.as_bytes());
format!("{:x}", hasher.finalize())
};
let level = body.level.as_deref().unwrap_or("read_only");
match db.insert_mcp_key(&key_hash, &body.name, level) {
Ok(id) => {
HttpResponse::Created().json(serde_json::json!({
"id": id,
"key": raw_key,
"name": body.name,
"permission_level": level,
}))
}
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
}
async fn delete_key(
_auth: AuthClaims,
db: web::Data<Database>,
path: web::Path<i64>,
) -> HttpResponse {
let id = path.into_inner();
match db.delete_mcp_key(id) {
Ok(true) => HttpResponse::Ok().json(serde_json::json!({"deleted": true})),
Ok(false) => HttpResponse::NotFound().json(serde_json::json!({"error": "Key not found"})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
}

View File

@ -3,7 +3,12 @@ pub mod auth;
pub mod default;
pub mod filter;
pub mod health;
pub mod mcp_keys;
pub mod ml;
pub mod notification;
pub mod rate_limit;
pub mod report;
pub mod setup;
pub mod soar;
pub mod stats;
pub mod system;

View File

@ -0,0 +1,60 @@
use actix_web::{web, HttpResponse, Scope};
use serde::Deserialize;
use crate::core::auth::extractor::AuthClaims;
use crate::core::notification_service::NotificationService;
pub fn initialize() -> Scope {
web::scope("/notifications")
.route("/telegram/config", web::get().to(get_telegram_config))
.route("/telegram/config", web::post().to(set_telegram_config))
.route("/telegram/test", web::post().to(test_telegram))
.route("/smtp/test", web::post().to(test_smtp))
}
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()})),
}
}
#[derive(Deserialize)]
struct TelegramConfigRequest {
bot_token: String,
chat_id: String,
}
async fn set_telegram_config(
_auth: AuthClaims,
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()})),
}
}
async fn test_telegram(
_auth: AuthClaims,
svc: web::Data<NotificationService>,
) -> HttpResponse {
match svc.test_telegram().await {
Ok(()) => HttpResponse::Ok().json(serde_json::json!({"success": true, "message": "Test message sent"})),
Err(e) => HttpResponse::BadRequest().json(serde_json::json!({"success": false, "error": e.to_string()})),
}
}
async fn test_smtp(
_auth: AuthClaims,
svc: web::Data<NotificationService>,
) -> HttpResponse {
match svc.test_smtp() {
Ok(msg) => HttpResponse::Ok().json(serde_json::json!({"success": true, "message": msg})),
Err(e) => HttpResponse::BadRequest().json(serde_json::json!({"success": false, "error": e.to_string()})),
}
}

View File

@ -1,20 +1,7 @@
use actix_web::{web, HttpResponse, Responder, Scope};
use serde::{Deserialize, Serialize};
use common::define::setting::*;
use crate::interface::port::repository::RepositoryPort;
type Repo = dyn RepositoryPort;
use crate::core::ebpf::rate_limit::RateLimitConfig;
#[derive(Serialize, Deserialize)]
pub struct RateLimitSettings {
pub packet_rate: Option<u64>,
pub syn_rate: Option<u64>,
pub udp_rate: Option<u64>,
pub dns_rate: Option<u64>,
pub window_ns: Option<u64>,
}
use crate::core::rate_limit_service::{RateLimitService, RateLimitSettings};
pub fn initialize() -> Scope {
web::scope("/rate-limit")
@ -22,63 +9,22 @@ pub fn initialize() -> Scope {
.route("/config", web::put().to(set_config))
}
async fn get_config(
config: web::Data<RateLimitConfig>,
) -> impl Responder {
async fn get_config(service: web::Data<RateLimitService>) -> impl Responder {
HttpResponse::Ok().json(RateLimitSettings {
packet_rate: Some(config.get_packet_rate().unwrap_or(DEFAULT_PACKET_RATE)),
syn_rate: Some(config.get_syn_rate().unwrap_or(DEFAULT_SYN_RATE)),
udp_rate: Some(config.get_udp_rate().unwrap_or(DEFAULT_UDP_RATE)),
dns_rate: Some(config.get_dns_rate().unwrap_or(DEFAULT_DNS_RATE)),
window_ns: Some(config.get_window_ns().unwrap_or(DEFAULT_WINDOW_NS)),
packet_rate: Some(service.config().get_packet_rate().unwrap_or(DEFAULT_PACKET_RATE)),
syn_rate: Some(service.config().get_syn_rate().unwrap_or(DEFAULT_SYN_RATE)),
udp_rate: Some(service.config().get_udp_rate().unwrap_or(DEFAULT_UDP_RATE)),
dns_rate: Some(service.config().get_dns_rate().unwrap_or(DEFAULT_DNS_RATE)),
window_ns: Some(service.config().get_window_ns().unwrap_or(DEFAULT_WINDOW_NS)),
})
}
async fn set_config(
settings: web::Json<RateLimitSettings>,
config: web::Data<RateLimitConfig>,
db: web::Data<Repo>,
service: web::Data<RateLimitService>,
) -> impl Responder {
let s = settings.into_inner();
if let Some(v) = s.packet_rate {
if let Err(e) = db.set_rate_limit("packet_rate", v) {
return HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()}));
}
if let Err(e) = config.set_packet_rate(v) {
return HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()}));
}
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()})),
}
if let Some(v) = s.syn_rate {
if let Err(e) = db.set_rate_limit("syn_rate", v) {
return HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()}));
}
if let Err(e) = config.set_syn_rate(v) {
return HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()}));
}
}
if let Some(v) = s.udp_rate {
if let Err(e) = db.set_rate_limit("udp_rate", v) {
return HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()}));
}
if let Err(e) = config.set_udp_rate(v) {
return HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()}));
}
}
if let Some(v) = s.dns_rate {
if let Err(e) = db.set_rate_limit("dns_rate", v) {
return HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()}));
}
if let Err(e) = config.set_dns_rate(v) {
return HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()}));
}
}
if let Some(v) = s.window_ns {
if let Err(e) = db.set_rate_limit("window_ns", v) {
return HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()}));
}
if let Err(e) = config.set_window_ns(v) {
return HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()}));
}
}
HttpResponse::Ok().json(serde_json::json!({"status": "ok"}))
}

View File

@ -0,0 +1,53 @@
use actix_web::{web, HttpResponse, Scope};
use crate::adapter::persistence::Database;
use crate::core::auth::extractor::AuthClaims;
use crate::core::report::engine;
use crate::interface::port::repository::RepositoryPort;
pub fn initialize() -> Scope {
web::scope("/report")
.route("/generate", web::post().to(generate_report))
.route("/data", web::get().to(report_data))
}
async fn generate_report(
_auth: AuthClaims,
db: web::Data<Database>,
) -> HttpResponse {
let db_ref = db.get_ref();
match engine::generate_html_report(db_ref as &dyn RepositoryPort, "/tmp/netguardia-reports") {
Ok(path) => {
match std::fs::read(&path) {
Ok(content) => {
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.insert_header(("Content-Disposition", format!(
"attachment; filename=\"{}\"",
path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "report.html".into())
)))
.body(content)
}
Err(_) => {
HttpResponse::Ok().json(serde_json::json!({
"success": true,
"path": path.to_string_lossy(),
"message": "HTML report generated."
}))
}
}
}
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
}
async fn report_data(
_auth: AuthClaims,
db: web::Data<Database>,
) -> HttpResponse {
let db_ref = db.get_ref();
match engine::generate_report_json(db_ref as &dyn RepositoryPort) {
Ok(data) => HttpResponse::Ok().json(data),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
}

View File

@ -0,0 +1,251 @@
use std::sync::atomic::Ordering;
use actix_web::{web, HttpResponse, Scope};
use serde::Deserialize;
use macros::log;
use crate::adapter::persistence::Database;
use crate::core::auth::password;
use crate::core::auth::setup_guard::SetupCompleteFlag;
use crate::model::error::system::SystemError;
pub fn initialize() -> Scope {
web::scope("/setup")
.route("/status", web::get().to(setup_status))
.route("/interfaces", web::get().to(list_interfaces))
.route("/complete", web::post().to(complete_setup))
}
async fn setup_status(
setup_flag: web::Data<SetupCompleteFlag>,
) -> HttpResponse {
let complete = setup_flag.load(Ordering::SeqCst);
HttpResponse::Ok().json(serde_json::json!({
"setup_complete": complete,
}))
}
async fn list_interfaces() -> HttpResponse {
// List available network interfaces
let interfaces: Vec<serde_json::Value> = match std::fs::read_dir("/sys/class/net") {
Ok(entries) => {
entries
.filter_map(|e| e.ok())
.map(|e| {
let name = e.file_name().to_string_lossy().to_string();
serde_json::json!({
"name": name,
"is_loopback": name == "lo",
})
})
.collect()
}
Err(_) => Vec::new(),
};
HttpResponse::Ok().json(serde_json::json!({
"interfaces": interfaces,
}))
}
#[derive(Deserialize)]
struct SetupRequest {
/// Ingress network interface (external-facing)
ingress_interface: String,
/// Egress network interface (internal-facing)
egress_interface: String,
/// Admin password
admin_password: String,
/// HTTP port (optional, default 8080)
http_port: Option<u16>,
/// SMTP config (optional)
smtp_host: Option<String>,
smtp_port: Option<u16>,
smtp_username: Option<String>,
smtp_password: Option<String>,
smtp_recipient: Option<String>,
/// Telegram config (optional)
telegram_bot_token: Option<String>,
telegram_chat_id: Option<String>,
}
/// Validate interface name: only alphanumeric, dots, underscores, hyphens allowed.
/// Prevents path traversal via crafted interface names like "../../etc/shadow".
fn is_valid_interface_name(name: &str) -> bool {
!name.is_empty()
&& name.len() <= 16
&& name.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-')
}
async fn complete_setup(
db: web::Data<Database>,
setup_flag: web::Data<SetupCompleteFlag>,
body: web::Json<SetupRequest>,
) -> HttpResponse {
// Check if already completed (concurrent access protection)
if setup_flag.load(Ordering::SeqCst) {
return HttpResponse::Conflict().json(serde_json::json!({
"error": "Setup already completed"
}));
}
// Validate interface names (prevent path traversal)
for iface in [&body.ingress_interface, &body.egress_interface] {
if !is_valid_interface_name(iface) {
return HttpResponse::BadRequest().json(serde_json::json!({
"error": format!("Invalid interface name '{}': only alphanumeric, dots, underscores, hyphens allowed (max 16 chars)", iface)
}));
}
let iface_path = format!("/sys/class/net/{}", iface);
if !std::path::Path::new(&iface_path).exists() {
return HttpResponse::BadRequest().json(serde_json::json!({
"error": format!("Network interface '{}' not found", iface)
}));
}
}
// Validate ingress != egress
if body.ingress_interface == body.egress_interface {
return HttpResponse::BadRequest().json(serde_json::json!({
"error": "Ingress and egress interfaces must be different"
}));
}
// Validate password strength: min 8 chars, must contain letter + digit + symbol
let pw = &body.admin_password;
if pw.len() < 8 {
return HttpResponse::BadRequest().json(serde_json::json!({
"error": "Password must be at least 8 characters"
}));
}
let has_letter = pw.chars().any(|c| c.is_ascii_alphabetic());
let has_digit = pw.chars().any(|c| c.is_ascii_digit());
let has_symbol = pw.chars().any(|c| !c.is_ascii_alphanumeric());
if !has_letter || !has_digit || !has_symbol {
return HttpResponse::BadRequest().json(serde_json::json!({
"error": "Password must contain at least one letter, one digit, and one symbol"
}));
}
// Save configuration to database
if let Err(e) = save_config(&db, &body) {
return HttpResponse::InternalServerError().json(serde_json::json!({
"error": format!("Failed to save configuration: {}", e)
}));
}
// Update admin password
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));
}
}
}
Err(e) => {
return HttpResponse::InternalServerError().json(serde_json::json!({
"error": format!("Failed to hash password: {}", e)
}));
}
}
// Mark setup as complete
if let Err(e) = db.set_setting("setup_complete", "true") {
log!(SystemError::SetupCompleteFlagFailed(e));
}
setup_flag.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.
HttpResponse::Ok().json(serde_json::json!({
"success": true,
"setup_complete": true,
"message": "Setup complete. System is initializing services..."
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_valid_interface_names() {
assert!(is_valid_interface_name("eth0"));
assert!(is_valid_interface_name("ens33"));
assert!(is_valid_interface_name("br-lan"));
assert!(is_valid_interface_name("wlan0.1"));
}
#[test]
fn test_invalid_interface_empty() {
assert!(!is_valid_interface_name(""));
}
#[test]
fn test_invalid_interface_too_long() {
let long = "a".repeat(17);
assert!(!is_valid_interface_name(&long));
// Exactly 16 should be valid
let exact = "a".repeat(16);
assert!(is_valid_interface_name(&exact));
}
#[test]
fn test_invalid_interface_path_traversal() {
assert!(!is_valid_interface_name("../etc"));
assert!(!is_valid_interface_name("../../shadow"));
assert!(!is_valid_interface_name("/sys/class"));
}
#[test]
fn test_invalid_interface_special_chars() {
assert!(!is_valid_interface_name("eth0;rm"));
assert!(!is_valid_interface_name("lo&&cat"));
assert!(!is_valid_interface_name("eth0 space"));
}
}
fn save_config(db: &Database, req: &SetupRequest) -> Result<(), crate::model::error::Error> {
// Save network config
db.set_setting("ingress_interface", &req.ingress_interface)?;
db.set_setting("egress_interface", &req.egress_interface)?;
if let Some(port) = req.http_port {
db.set_setting("http_port", &port.to_string())?;
}
// Save SMTP config
if let Some(host) = &req.smtp_host {
db.set_setting("smtp_host", host)?;
}
if let Some(port) = req.smtp_port {
db.set_setting("smtp_port", &port.to_string())?;
}
if let Some(user) = &req.smtp_username {
db.set_setting("smtp_username", user)?;
}
if let Some(pass) = &req.smtp_password {
db.set_setting("smtp_password", pass)?;
}
if let Some(recipient) = &req.smtp_recipient {
db.set_setting("smtp_recipient", recipient)?;
}
// Save Telegram config
if let (Some(token), Some(chat_id)) = (&req.telegram_bot_token, &req.telegram_chat_id) {
let config_json = serde_json::json!({
"bot_token": token,
"chat_id": chat_id,
}).to_string();
db.set_notification_config("telegram", &config_json)?;
}
Ok(())
}

View File

@ -0,0 +1,198 @@
use actix_web::{web, HttpResponse, Scope};
use serde::Deserialize;
use crate::core::auth::extractor::AuthClaims;
use crate::core::playbook_service::{CreatePlaybookInput, PlaybookService};
#[derive(Deserialize)]
struct CreatePlaybookRequest {
name: String,
trigger_event: String,
condition_threshold: Option<f64>,
condition_count: Option<i64>,
condition_window_secs: Option<i64>,
cooldown_secs: Option<i64>,
actions: Vec<CreateActionRequest>,
}
#[derive(Deserialize)]
struct CreateActionRequest {
action_type: String,
params: Option<serde_json::Value>,
}
pub fn initialize() -> Scope {
web::scope("/soar")
.route("/playbooks", web::get().to(list_playbooks))
.route("/playbooks", web::post().to(create_playbook))
.route("/playbooks/{id}", web::delete().to(delete_playbook))
.route("/blocks", web::get().to(list_active_blocks))
.route("/blocks/{id}/unblock", web::post().to(manual_unblock))
.route("/executions", web::get().to(list_executions))
.route("/whitelist", web::get().to(list_whitelist))
.route("/whitelist", web::post().to(add_whitelist))
.route("/whitelist/{ip}", web::delete().to(remove_whitelist))
}
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();
serde_json::json!({
"id": pb.id,
"name": pb.name,
"enabled": pb.enabled,
"trigger_event": pb.trigger_event,
"condition_threshold": pb.condition_threshold,
"condition_count": pb.condition_count,
"condition_window_secs": pb.condition_window_secs,
"cooldown_secs": pb.cooldown_secs,
"actions": actions,
})
}).collect();
HttpResponse::Ok().json(responses)
}
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
}
async fn create_playbook(
_auth: AuthClaims,
svc: web::Data<PlaybookService>,
body: web::Json<CreatePlaybookRequest>,
) -> HttpResponse {
let actions: Vec<(String, String)> = body.actions.iter().map(|a| {
let params_str = a.params.as_ref()
.map(|v| serde_json::to_string(v).unwrap_or_else(|_| "{}".into()))
.unwrap_or_else(|| "{}".into());
(a.action_type.clone(), params_str)
}).collect();
let 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,
};
match svc.create_playbook(&input) {
Ok(id) => HttpResponse::Created().json(serde_json::json!({"id": id})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
}
async fn delete_playbook(
_auth: AuthClaims,
svc: web::Data<PlaybookService>,
path: web::Path<i64>,
) -> HttpResponse {
match svc.delete_playbook(path.into_inner()) {
Ok(true) => HttpResponse::Ok().json(serde_json::json!({"deleted": true})),
Ok(false) => HttpResponse::NotFound().json(serde_json::json!({"error": "Playbook not found"})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
}
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()})),
}
}
async fn manual_unblock(
_auth: AuthClaims,
svc: web::Data<PlaybookService>,
path: web::Path<i64>,
) -> HttpResponse {
match svc.manual_unblock(path.into_inner()).await {
Ok(()) => HttpResponse::Ok().json(serde_json::json!({"unblocked": true})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
}
async fn list_executions(
_auth: AuthClaims,
svc: web::Data<PlaybookService>,
) -> HttpResponse {
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_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()})),
}
}
#[derive(Deserialize)]
struct WhitelistRequest {
ip: String,
}
async fn add_whitelist(
_auth: AuthClaims,
svc: web::Data<PlaybookService>,
body: web::Json<WhitelistRequest>,
) -> HttpResponse {
match svc.add_whitelist(&body.ip) {
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()})),
}
}

View File

@ -1,6 +1,7 @@
use actix_web::{web, HttpResponse, Responder, Scope};
use serde::Deserialize;
use crate::core::config_service::ConfigService;
use crate::infrastructure::communication_manager::CommunicationManager;
use crate::interface::communication::command_types::ChangeEnforceModeCommand;
use crate::interface::communication::query_types::GetEnforceModeQuery;
@ -14,16 +15,13 @@ struct EnforceModeRequest {
}
pub fn initialize() -> Scope {
let scope = web::scope("/system")
web::scope("/system")
.route("/boot-time", web::get().to(get_boot_time))
.route("/enforce-mode", web::get().to(get_enforce_mode))
.route("/enforce-mode", web::put().to(set_enforce_mode))
.route("/xdp-mode", web::get().to(get_xdp_mode));
#[cfg(feature = "license")]
let scope = scope.route("/license", web::get().to(get_license_info));
scope
.route("/xdp-mode", web::get().to(get_xdp_mode))
.route("/config", web::get().to(get_config))
.route("/config", web::put().to(update_config))
}
async fn get_boot_time() -> impl Responder {
@ -69,7 +67,21 @@ async fn get_xdp_mode(db: web::Data<Repo>) -> impl Responder {
}))
}
#[cfg(feature = "license")]
async fn get_license_info(license_info: web::Data<crate::core::license::LicenseInfo>) -> impl Responder {
HttpResponse::Ok().json(license_info.get_ref())
async fn get_config(svc: web::Data<ConfigService>) -> impl Responder {
HttpResponse::Ok().json(svc.get_config())
}
async fn update_config(
body: web::Json<serde_json::Value>,
svc: web::Data<ConfigService>,
) -> impl Responder {
match svc.update_config(&body) {
Ok(updated) => {
HttpResponse::Ok().json(serde_json::json!({
"updated": updated,
"message": if updated.is_empty() { "No changes" } else { "Settings updated. Restart required for changes to take effect." }
}))
}
Err(e) => HttpResponse::BadRequest().json(serde_json::json!({"error": e.to_string()})),
}
}

View File

@ -1,3 +1,5 @@
pub mod access_control_adapter;
pub mod http;
pub mod persistence;
pub mod telegram;
pub mod websocket;

View File

@ -1,24 +1,53 @@
use parking_lot::Mutex;
use rusqlite::{Connection, params};
use std::collections::HashMap;
use r2d2::Pool;
use r2d2_sqlite::SqliteConnectionManager;
use rusqlite::params;
use crate::model::error::database::DatabaseError;
use crate::model::error::Error;
/// Applies SQLite PRAGMAs to each new connection in the pool.
#[derive(Debug)]
struct SqlitePragmaCustomizer;
impl r2d2::CustomizeConnection<rusqlite::Connection, rusqlite::Error> for SqlitePragmaCustomizer {
fn on_acquire(&self, conn: &mut rusqlite::Connection) -> Result<(), rusqlite::Error> {
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")?;
Ok(())
}
}
pub struct Database {
conn: Mutex<Connection>,
pool: Pool<SqliteConnectionManager>,
}
impl Database {
pub fn new(path: &str) -> Result<Self, Error> {
let conn = Connection::open(path)?;
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")?;
let db = Self { conn: Mutex::new(conn) };
let manager = if path == ":memory:" {
SqliteConnectionManager::memory()
} else {
SqliteConnectionManager::file(path)
};
let pool = Pool::builder()
.max_size(if path == ":memory:" { 1 } else { 6 })
.connection_customizer(Box::new(SqlitePragmaCustomizer))
.build(manager)
.map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?;
let db = Self { pool };
db.create_tables()?;
Ok(db)
}
fn conn(&self) -> Result<r2d2::PooledConnection<SqliteConnectionManager>, Error> {
self.pool.get().map_err(|e| -> Error {
DatabaseError::QueryFailed { reason: e.to_string() }.into()
})
}
fn create_tables(&self) -> Result<(), Error> {
let conn = self.conn.lock();
let conn = self.conn()?;
conn.execute_batch("
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@ -65,6 +94,76 @@ impl Database {
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 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
);
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 mcp_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'))
);
")?;
// Migration: add force_password_change column if missing (for existing DBs)
@ -155,7 +254,7 @@ impl Database {
// --- ACL ---
pub fn insert_acl_rule(&self, ip_version: u8, direction: &str, list_type: &str, ip_address: &str, port: u16) -> Result<(), Error> {
let conn = self.conn.lock();
let conn = self.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],
@ -164,7 +263,7 @@ impl Database {
}
pub fn delete_acl_rule(&self, ip_version: u8, direction: &str, list_type: &str, ip_address: &str, port: u16) -> Result<(), Error> {
let conn = self.conn.lock();
let conn = self.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],
@ -173,7 +272,7 @@ impl Database {
}
pub fn load_acl_rules(&self) -> Result<Vec<crate::interface::port::repository::AclRuleTuple>, Error> {
let conn = self.conn.lock();
let conn = self.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((
@ -193,7 +292,7 @@ impl Database {
// --- Rate Limit ---
pub fn set_rate_limit(&self, key: &str, value: u64) -> Result<(), Error> {
let conn = self.conn.lock();
let conn = self.conn()?;
conn.execute(
"INSERT OR REPLACE INTO rate_limit_config (key, value) VALUES (?1, ?2)",
params![key, value as i64],
@ -202,7 +301,7 @@ impl Database {
}
pub fn load_rate_limit_config(&self) -> Result<Vec<(String, u64)>, Error> {
let conn = self.conn.lock();
let conn = self.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))
@ -216,19 +315,19 @@ impl Database {
// --- DNS ---
pub fn insert_dns_domain(&self, domain: &str) -> Result<(), Error> {
let conn = self.conn.lock();
let conn = self.conn()?;
conn.execute("INSERT OR IGNORE INTO dns_blacklist (domain) VALUES (?1)", params![domain])?;
Ok(())
}
pub fn delete_dns_domain(&self, domain: &str) -> Result<(), Error> {
let conn = self.conn.lock();
let conn = self.conn()?;
conn.execute("DELETE FROM dns_blacklist WHERE domain = ?1", params![domain])?;
Ok(())
}
pub fn load_dns_domains(&self) -> Result<Vec<String>, Error> {
let conn = self.conn.lock();
let conn = self.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();
@ -240,19 +339,19 @@ impl Database {
// --- Geo ---
pub fn insert_geo_country(&self, code: &str) -> Result<(), Error> {
let conn = self.conn.lock();
let conn = self.conn()?;
conn.execute("INSERT OR IGNORE INTO geo_blocked_countries (country_code) VALUES (?1)", params![code])?;
Ok(())
}
pub fn delete_geo_country(&self, code: &str) -> Result<(), Error> {
let conn = self.conn.lock();
let conn = self.conn()?;
conn.execute("DELETE FROM geo_blocked_countries WHERE country_code = ?1", params![code])?;
Ok(())
}
pub fn load_geo_countries(&self) -> Result<Vec<String>, Error> {
let conn = self.conn.lock();
let conn = self.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();
@ -264,7 +363,7 @@ impl Database {
// --- Settings ---
pub fn get_setting(&self, key: &str) -> Result<Option<String>, Error> {
let conn = self.conn.lock();
let conn = self.conn()?;
let result = conn.query_row(
"SELECT value FROM settings WHERE key = ?1",
params![key],
@ -278,7 +377,7 @@ impl Database {
}
pub fn set_setting(&self, key: &str, value: &str) -> Result<(), Error> {
let conn = self.conn.lock();
let conn = self.conn()?;
conn.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
params![key, value],
@ -288,7 +387,7 @@ impl Database {
// --- Users ---
pub fn find_user(&self, username: &str) -> Result<Option<crate::interface::port::repository::UserTuple>, Error> {
let conn = self.conn.lock();
let conn = self.conn()?;
let result = conn.query_row(
"SELECT id, username, password_hash, role, force_password_change FROM users WHERE username = ?1",
params![username],
@ -302,7 +401,7 @@ impl Database {
}
pub fn insert_user(&self, username: &str, password_hash: &str, role: &str, force_password_change: bool) -> Result<i64, Error> {
let conn = self.conn.lock();
let conn = self.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],
@ -317,7 +416,7 @@ impl Database {
}
pub fn update_user_password(&self, user_id: i64, password_hash: &str) -> Result<(), Error> {
let conn = self.conn.lock();
let conn = self.conn()?;
conn.execute(
"UPDATE users SET password_hash = ?1, force_password_change = 0 WHERE id = ?2",
params![password_hash, user_id],
@ -326,12 +425,12 @@ impl Database {
}
pub fn user_count(&self) -> Result<i64, Error> {
let conn = self.conn.lock();
let conn = self.conn()?;
Ok(conn.query_row("SELECT COUNT(*) FROM users", [], |row| row.get(0))?)
}
pub fn list_users(&self) -> Result<Vec<crate::interface::port::repository::UserListItem>, Error> {
let conn = self.conn.lock();
let conn = self.conn()?;
let mut stmt = conn.prepare("SELECT id, username, role, force_password_change, created_at FROM users ORDER BY id")?;
let rows = stmt.query_map([], |row| {
Ok((
@ -349,21 +448,60 @@ impl Database {
Ok(results)
}
pub fn list_users_with_groups(&self) -> Result<Vec<crate::interface::port::repository::UserWithGroups>, Error> {
let conn = self.conn()?;
let mut stmt = conn.prepare(
"SELECT u.id, u.username, u.role, 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::<_, String>(2)?,
row.get::<_, i64>(3)? != 0,
row.get::<_, String>(4)?,
row.get::<_, Option<i64>>(5)?,
row.get::<_, Option<String>>(6)?,
))
})?;
let mut user_map: HashMap<i64, crate::interface::port::repository::UserWithGroups> = HashMap::new();
let mut order: Vec<i64> = Vec::new();
for row in rows {
let (id, username, role, force_pw, created_at, group_id, group_name) = row?;
let entry = user_map.entry(id).or_insert_with(|| {
order.push(id);
(id, username, role, force_pw, created_at, Vec::new())
});
if let (Some(gid), Some(gname)) = (group_id, group_name) {
entry.5.push((gid, gname));
}
}
Ok(order.into_iter().filter_map(|id| user_map.remove(&id)).collect())
}
pub fn delete_user(&self, user_id: i64) -> Result<bool, Error> {
self.cleanup_user_memberships(user_id)?;
let conn = self.conn.lock();
let conn = self.conn()?;
let affected = conn.execute("DELETE FROM users WHERE id = ?1", params![user_id])?;
Ok(affected > 0)
}
pub fn update_user_role(&self, user_id: i64, role: &str) -> Result<(), Error> {
let conn = self.conn.lock();
let conn = self.conn()?;
conn.execute("UPDATE users SET role = ?1 WHERE id = ?2", params![role, user_id])?;
Ok(())
}
pub fn reset_user_password(&self, user_id: i64, password_hash: &str) -> Result<(), Error> {
let conn = self.conn.lock();
let conn = self.conn()?;
conn.execute(
"UPDATE users SET password_hash = ?1, force_password_change = 1 WHERE id = ?2",
params![password_hash, user_id],
@ -372,7 +510,7 @@ impl Database {
}
pub fn find_user_by_id(&self, user_id: i64) -> Result<Option<crate::interface::port::repository::UserTuple>, Error> {
let conn = self.conn.lock();
let conn = self.conn()?;
let result = conn.query_row(
"SELECT id, username, password_hash, role, force_password_change FROM users WHERE id = ?1",
params![user_id],
@ -387,7 +525,7 @@ impl Database {
// --- User Groups ---
pub fn list_user_groups(&self) -> Result<Vec<crate::interface::port::repository::UserGroupTuple>, Error> {
let conn = self.conn.lock();
let conn = self.conn()?;
let mut stmt = conn.prepare("SELECT id, name, description, permissions, created_at FROM user_groups ORDER BY id")?;
let rows = stmt.query_map([], |row| {
Ok((
@ -406,7 +544,7 @@ impl Database {
}
pub fn create_user_group(&self, name: &str, description: &str, permissions: &str) -> Result<i64, Error> {
let conn = self.conn.lock();
let conn = self.conn()?;
conn.execute(
"INSERT INTO user_groups (name, description, permissions) VALUES (?1, ?2, ?3)",
params![name, description, permissions],
@ -421,7 +559,7 @@ impl Database {
}
pub fn update_user_group(&self, id: i64, name: &str, description: &str, permissions: &str) -> Result<(), Error> {
let conn = self.conn.lock();
let conn = self.conn()?;
conn.execute(
"UPDATE user_groups SET name = ?1, description = ?2, permissions = ?3 WHERE id = ?4",
params![name, description, permissions, id],
@ -430,14 +568,14 @@ impl Database {
}
pub fn delete_user_group(&self, id: i64) -> Result<bool, Error> {
let conn = self.conn.lock();
let conn = self.conn()?;
conn.execute("DELETE FROM user_group_members WHERE group_id = ?1", params![id])?;
let affected = conn.execute("DELETE FROM user_groups WHERE id = ?1", params![id])?;
Ok(affected > 0)
}
pub fn get_user_group(&self, id: i64) -> Result<Option<crate::interface::port::repository::UserGroupTuple>, Error> {
let conn = self.conn.lock();
let conn = self.conn()?;
let result = conn.query_row(
"SELECT id, name, description, permissions, created_at FROM user_groups WHERE id = ?1",
params![id],
@ -458,7 +596,7 @@ impl Database {
// --- User Group Membership ---
pub fn get_user_groups(&self, user_id: i64) -> Result<Vec<(i64, String, String, String)>, Error> {
let conn = self.conn.lock();
let conn = self.conn()?;
let mut stmt = conn.prepare(
"SELECT g.id, g.name, g.description, g.permissions FROM user_groups g \
INNER JOIN user_group_members m ON g.id = m.group_id \
@ -480,7 +618,7 @@ impl Database {
}
pub fn set_user_groups(&self, user_id: i64, group_ids: &[i64]) -> Result<(), Error> {
let conn = self.conn.lock();
let conn = self.conn()?;
conn.execute("DELETE FROM user_group_members WHERE user_id = ?1", params![user_id])?;
for &gid in group_ids {
conn.execute(
@ -507,13 +645,13 @@ impl Database {
}
pub fn cleanup_user_memberships(&self, user_id: i64) -> Result<(), Error> {
let conn = self.conn.lock();
let conn = self.conn()?;
conn.execute("DELETE FROM user_group_members WHERE user_id = ?1", params![user_id])?;
Ok(())
}
pub fn get_group_member_ids(&self, group_id: i64) -> Result<Vec<i64>, Error> {
let conn = self.conn.lock();
let conn = self.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();
@ -523,6 +661,23 @@ impl Database {
Ok(results)
}
pub fn get_group_members(&self, group_id: i64) -> Result<Vec<(i64, String)>, Error> {
let conn = self.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((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
})?;
let mut results = Vec::new();
for row in rows {
results.push(row?);
}
Ok(results)
}
// --- Login Rate Limiting ---
pub fn record_login_failure(&self, username: &str) -> Result<(u32, Option<u64>), Error> {
let key_count = format!("login_failures:{}", username);
@ -565,11 +720,438 @@ impl Database {
}
pub fn clear_login_failures(&self, username: &str) -> Result<(), Error> {
let conn = self.conn.lock();
let conn = self.conn()?;
conn.execute("DELETE FROM settings WHERE key = ?1", params![format!("login_failures:{}", username)])?;
conn.execute("DELETE FROM settings WHERE key = ?1", params![format!("login_locked_until:{}", username)])?;
Ok(())
}
// --- MCP API Keys ---
/// Validate an API key and return Claims if valid.
/// Computes SHA-256 hash of the key and looks it up in mcp_keys table.
pub fn validate_api_key(&self, api_key: &str) -> Result<Option<crate::model::auth::Claims>, Error> {
use std::fmt::Write;
// SHA-256 hash the key
let digest = {
use sha2::{Sha256, Digest};
let mut hasher = Sha256::new();
hasher.update(api_key.as_bytes());
let result = hasher.finalize();
let mut hex = String::with_capacity(64);
for byte in result {
write!(&mut hex, "{:02x}", byte).unwrap();
}
hex
};
let conn = self.conn()?;
let result = conn.query_row(
"SELECT id, name, permission_level FROM mcp_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)) => {
// Update last_used_at
let _ = conn.execute(
"UPDATE mcp_keys SET last_used_at = datetime('now') WHERE id = ?1",
params![id],
);
// Build permissions based on permission level
let permissions = match level.as_str() {
"read_write" => vec![
"dashboard:read".into(), "statistics:read".into(),
"ai_detection:read".into(), "ai_detection:write".into(),
"access_control:read".into(), "access_control:write".into(),
"geo_block:read".into(), "geo_block:write".into(),
"dns_filter:read".into(), "dns_filter:write".into(),
"rate_limit:read".into(), "rate_limit:write".into(),
"system:read".into(), "system:write".into(),
],
_ => vec![
"dashboard:read".into(), "statistics:read".into(),
"ai_detection:read".into(), "access_control:read".into(),
"geo_block:read".into(), "dns_filter:read".into(),
"rate_limit:read".into(), "system:read".into(),
],
};
Ok(Some(crate::model::auth::Claims {
sub: -id, // negative ID to distinguish from user IDs
username: format!("mcp:{}", name),
role: level,
permissions,
exp: usize::MAX, // API keys don't expire (revocation via DB deletion)
}))
}
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e.into()),
}
}
// --- SOAR ---
pub fn insert_playbook(&self, name: &str, trigger_event: &str, threshold: Option<f64>, count: Option<i64>, window: Option<i64>, cooldown: i64) -> Result<i64, Error> {
let conn = self.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())
}
pub fn insert_playbook_action(&self, playbook_id: i64, action_order: i64, action_type: &str, params_json: &str) -> Result<i64, Error> {
let conn = self.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())
}
pub fn insert_soar_block_rule(&self, source_ip: &str, playbook_id: i64, expires_at: &str) -> Result<i64, Error> {
let conn = self.conn()?;
conn.execute(
"INSERT INTO soar_block_rules (source_ip, playbook_id, expires_at) VALUES (?1, ?2, ?3)",
params![source_ip, playbook_id, expires_at],
)?;
Ok(conn.last_insert_rowid())
}
pub fn count_active_soar_blocks(&self) -> Result<u32, Error> {
let conn = self.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)
}
pub fn get_expired_soar_blocks(&self) -> Result<Vec<(i64, String, i64)>, Error> {
let conn = self.conn()?;
let mut stmt = conn.prepare(
"SELECT id, source_ip, playbook_id FROM soar_block_rules WHERE expires_at <= datetime('now') AND unblocked_at IS NULL"
)?;
let rows = stmt.query_map([], |row| {
Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?, row.get::<_, i64>(2)?))
})?;
let mut result = Vec::new();
for row in rows { result.push(row?); }
Ok(result)
}
/// Get a single SOAR block rule by ID, returning (id, source_ip, playbook_id, expires_at).
pub fn get_soar_block_by_id(&self, id: i64) -> Result<Option<(i64, String, i64, String)>, Error> {
let conn = self.conn()?;
let mut stmt = conn.prepare(
"SELECT id, source_ip, playbook_id, expires_at FROM soar_block_rules WHERE id = ?1"
)?;
let mut rows = stmt.query_map(params![id], |row| {
Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?, row.get::<_, i64>(2)?, row.get::<_, String>(3)?))
})?;
match rows.next() {
Some(row) => Ok(Some(row?)),
None => Ok(None),
}
}
/// Load all playbooks with their actions in a single JOIN query (avoids N+1).
/// Returns Vec of (playbook fields..., action fields...).
#[allow(clippy::type_complexity)]
pub fn load_playbooks_with_actions(&self) -> Result<Vec<(i64, String, bool, String, Option<f64>, Option<i64>, Option<i64>, i64, Option<i64>, Option<i64>, Option<String>, Option<String>)>, Error> {
let conn = self.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((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, bool>(2)?,
row.get::<_, String>(3)?,
row.get::<_, Option<f64>>(4)?,
row.get::<_, Option<i64>>(5)?,
row.get::<_, Option<i64>>(6)?,
row.get::<_, i64>(7)?,
row.get::<_, Option<i64>>(8)?,
row.get::<_, Option<i64>>(9)?,
row.get::<_, Option<String>>(10)?,
row.get::<_, Option<String>>(11)?,
))
})?;
let mut result = Vec::new();
for row in rows { result.push(row?); }
Ok(result)
}
pub fn mark_soar_block_unblocked(&self, id: i64) -> Result<(), Error> {
let conn = self.conn()?;
conn.execute(
"UPDATE soar_block_rules SET unblocked_at = datetime('now') WHERE id = ?1",
params![id],
)?;
Ok(())
}
pub fn get_active_soar_blocks(&self) -> Result<Vec<(i64, String, i64, String)>, Error> {
let conn = self.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([], |row| {
Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?, row.get::<_, i64>(2)?, row.get::<_, String>(3)?))
})?;
let mut result = Vec::new();
for row in rows { result.push(row?); }
Ok(result)
}
pub fn insert_soar_execution(&self, playbook_id: i64, source_ip: Option<&str>, trigger_event: &str, actions_json: &str) -> Result<i64, Error> {
let conn = self.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())
}
pub fn delete_playbook(&self, id: i64) -> Result<bool, Error> {
let conn = self.conn()?;
let rows = conn.execute(
"DELETE FROM playbooks WHERE id = ?1",
params![id],
)?;
Ok(rows > 0)
}
#[allow(clippy::type_complexity)]
pub fn list_soar_executions(&self, limit: i64) -> Result<Vec<(i64, i64, Option<String>, String, String, String)>, Error> {
let conn = self.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| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, i64>(1)?,
row.get::<_, Option<String>>(2)?,
row.get::<_, String>(3)?,
row.get::<_, String>(4)?,
row.get::<_, String>(5)?,
))
})?;
let mut result = Vec::new();
for row in rows { result.push(row?); }
Ok(result)
}
pub fn has_manual_acl_rule(&self, ip_address: &str) -> Result<bool, Error> {
let conn = self.conn()?;
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM acl_rules WHERE ip_address = ?1 AND list_type = 'blacklist'",
params![ip_address],
|row| row.get(0),
)?;
Ok(count > 0)
}
// --- Admin Whitelist ---
pub fn load_admin_whitelist(&self) -> Result<Vec<String>, Error> {
let conn = self.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)
}
pub fn insert_admin_whitelist(&self, ip: &str) -> Result<(), Error> {
let conn = self.conn()?;
conn.execute("INSERT OR IGNORE INTO admin_whitelist (ip) VALUES (?1)", params![ip])?;
Ok(())
}
pub fn delete_admin_whitelist(&self, ip: &str) -> Result<(), Error> {
let conn = self.conn()?;
conn.execute("DELETE FROM admin_whitelist WHERE ip = ?1", params![ip])?;
Ok(())
}
// --- Notification Config ---
pub fn get_notification_config(&self, channel: &str) -> Result<Option<String>, Error> {
let conn = self.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(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e.into()),
}
}
pub fn set_notification_config(&self, channel: &str, config_json: &str) -> Result<(), Error> {
let conn = self.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(())
}
// --- MCP Key Management ---
pub fn insert_mcp_key(&self, key_hash: &str, name: &str, permission_level: &str) -> Result<i64, Error> {
let conn = self.conn()?;
conn.execute(
"INSERT INTO mcp_keys (key_hash, name, permission_level) VALUES (?1, ?2, ?3)",
params![key_hash, name, permission_level],
)?;
Ok(conn.last_insert_rowid())
}
#[allow(clippy::type_complexity)]
pub fn list_mcp_keys(&self) -> Result<Vec<(i64, String, String, String, Option<String>)>, Error> {
let conn = self.conn()?;
let mut stmt = conn.prepare("SELECT id, name, permission_level, created_at, last_used_at FROM mcp_keys")?;
let rows = stmt.query_map([], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
row.get::<_, Option<String>>(4)?,
))
})?;
let mut result = Vec::new();
for row in rows { result.push(row?); }
Ok(result)
}
pub fn delete_mcp_key(&self, id: i64) -> Result<bool, Error> {
let conn = self.conn()?;
let affected = conn.execute("DELETE FROM mcp_keys WHERE id = ?1", params![id])?;
Ok(affected > 0)
}
// --- Stats Aggregation ---
/// Count SOAR executions in the last N days.
pub fn count_weekly_executions(&self, days: i64) -> Result<u64, Error> {
let conn = self.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)
}
/// Count SOAR blocks created in the last N days.
pub fn count_weekly_blocks(&self, days: i64) -> Result<u64, Error> {
let conn = self.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)
}
/// Count SOAR unblocks in the last N days.
pub fn count_weekly_unblocks(&self, days: i64) -> Result<u64, Error> {
let conn = self.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)
}
/// Get threat breakdown by trigger_event in the last N days.
pub fn weekly_threat_breakdown(&self, days: i64) -> Result<Vec<(String, u64)>, Error> {
let conn = self.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((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as u64))
})?;
let mut result = Vec::new();
for row in rows { result.push(row?); }
Ok(result)
}
/// Get top blocked IPs in the last N days.
pub fn weekly_top_ips(&self, days: i64, limit: i64) -> Result<Vec<(String, u64)>, Error> {
let conn = self.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((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as u64))
})?;
let mut result = Vec::new();
for row in rows { result.push(row?); }
Ok(result)
}
/// Count current ACL rules.
pub fn count_acl_rules(&self) -> Result<u64, Error> {
let conn = self.conn()?;
let count: i64 = conn.query_row("SELECT COUNT(*) FROM acl_rules", [], |row| row.get(0))?;
Ok(count as u64)
}
// --- Default Playbooks ---
pub fn seed_default_playbooks(&self) -> Result<(), Error> {
let conn = self.conn()?;
let count: i64 = conn.query_row("SELECT COUNT(*) FROM playbooks", [], |row| row.get(0))?;
if count > 0 {
return Ok(());
}
drop(conn);
// 1. default_block: threat_detected, threshold 0.85 → block_ip(1800s) + log
let pb1 = self.insert_playbook("default_block", "threat_detected", Some(0.85), None, None, 300)?;
self.insert_playbook_action(pb1, 1, "block_ip", r#"{"ttl_secs": 1800}"#)?;
self.insert_playbook_action(pb1, 2, "log", r#"{"level": "warn"}"#)?;
// 2. brute_force_block: brute_force, count 5 in 60s → block_ip(3600s) + send_telegram + log
let pb2 = self.insert_playbook("brute_force_block", "brute_force", None, Some(5), Some(60), 600)?;
self.insert_playbook_action(pb2, 1, "block_ip", r#"{"ttl_secs": 3600}"#)?;
self.insert_playbook_action(pb2, 2, "send_telegram", "{}")?;
self.insert_playbook_action(pb2, 3, "log", r#"{"level": "warn"}"#)?;
// 3. port_scan_alert: port_scan, threshold 0.7 → send_telegram + log (no block)
let pb3 = self.insert_playbook("port_scan_alert", "port_scan", Some(0.7), None, None, 300)?;
self.insert_playbook_action(pb3, 1, "send_telegram", "{}")?;
self.insert_playbook_action(pb3, 2, "log", r#"{"level": "warn"}"#)?;
Ok(())
}
}
/// Implement the RepositoryPort trait, proving Database satisfies the port contract.
@ -593,6 +1175,7 @@ impl crate::interface::port::repository::RepositoryPort for Database {
fn update_user_password(&self, user_id: i64, password_hash: &str) -> Result<(), Error> { self.update_user_password(user_id, password_hash) }
fn user_count(&self) -> Result<i64, Error> { self.user_count() }
fn list_users(&self) -> Result<Vec<crate::interface::port::repository::UserListItem>, Error> { self.list_users() }
fn list_users_with_groups(&self) -> Result<Vec<crate::interface::port::repository::UserWithGroups>, Error> { self.list_users_with_groups() }
fn delete_user(&self, user_id: i64) -> Result<bool, Error> { self.delete_user(user_id) }
fn update_user_role(&self, user_id: i64, role: &str) -> Result<(), Error> { self.update_user_role(user_id, role) }
fn reset_user_password(&self, user_id: i64, password_hash: &str) -> Result<(), Error> { self.reset_user_password(user_id, password_hash) }
@ -607,6 +1190,7 @@ impl crate::interface::port::repository::RepositoryPort for Database {
fn get_user_permissions(&self, user_id: i64) -> Result<Vec<String>, Error> { self.get_user_permissions(user_id) }
fn cleanup_user_memberships(&self, user_id: i64) -> Result<(), Error> { self.cleanup_user_memberships(user_id) }
fn get_group_member_ids(&self, group_id: i64) -> Result<Vec<i64>, Error> { self.get_group_member_ids(group_id) }
fn get_group_members(&self, group_id: i64) -> Result<Vec<(i64, String)>, Error> { self.get_group_members(group_id) }
fn record_login_failure(&self, username: &str) -> Result<(u32, Option<u64>), Error> { self.record_login_failure(username) }
fn check_login_locked(&self, username: &str) -> Result<Option<u64>, Error> { self.check_login_locked(username) }
fn clear_login_failures(&self, username: &str) -> Result<(), Error> { self.clear_login_failures(username) }

View File

@ -0,0 +1,213 @@
use std::sync::Arc;
use std::time::{Duration, Instant};
use async_trait::async_trait;
use parking_lot::Mutex;
use reqwest::Client;
use tracing::{debug, warn};
use crate::adapter::persistence::Database;
use crate::interface::port::notification::{AlertNotifier, AlertPayload};
use crate::model::error::notification::NotificationError;
use crate::model::error::Error;
/// Rate limit: max 20 messages per minute.
const MAX_MESSAGES_PER_MINUTE: u32 = 20;
/// Max retries on 429 (rate limited).
const MAX_RETRIES: u32 = 2;
/// Telegram Bot API adapter implementing AlertNotifier.
pub struct TelegramAdapter {
client: Client,
db: Arc<Database>,
/// Rate limiter: (count, window_start)
rate_state: Mutex<(u32, Instant)>,
}
impl TelegramAdapter {
pub fn new(db: Arc<Database>) -> Result<Self, Error> {
let client = Client::builder()
.timeout(Duration::from_secs(10))
.build()
.map_err(|e| NotificationError::TelegramApiError {
reason: format!("Failed to create HTTP client: {}", e),
})?;
Ok(Self {
client,
db,
rate_state: Mutex::new((0, Instant::now())),
})
}
/// Get bot token and chat ID from DB. Returns None if not configured.
fn get_config(&self) -> Result<Option<(String, String)>, Error> {
match self.db.get_notification_config("telegram")? {
Some(json_str) => {
let config: serde_json::Value = serde_json::from_str(&json_str)
.map_err(|e| NotificationError::TelegramApiError {
reason: format!("Invalid telegram config JSON: {}", e),
})?;
let token = config.get("bot_token")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let chat_id = config.get("chat_id")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
match (token, chat_id) {
(Some(t), Some(c)) if !t.is_empty() && !c.is_empty() => Ok(Some((t, c))),
_ => Ok(None),
}
}
None => Ok(None),
}
}
/// Check rate limit. Returns true if send is allowed.
fn check_rate_limit(&self) -> bool {
let mut state = self.rate_state.lock();
let (count, window_start) = &mut *state;
// Reset window if >60s has passed
if window_start.elapsed() > Duration::from_secs(60) {
*count = 0;
*window_start = Instant::now();
}
if *count >= MAX_MESSAGES_PER_MINUTE {
return false;
}
*count += 1;
true
}
/// 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);
for attempt in 0..=MAX_RETRIES {
let resp = self.client
.post(&url)
.json(&serde_json::json!({
"chat_id": chat_id,
"text": text,
"parse_mode": "HTML",
}))
.send()
.await
.map_err(|e| {
if e.is_timeout() {
NotificationError::Timeout
} else {
NotificationError::TelegramApiError { reason: e.to_string() }
}
})?;
let status = resp.status();
if status.is_success() {
return Ok(());
}
if status.as_u16() == 401 || status.as_u16() == 403 {
let body = resp.text().await.unwrap_or_default();
if body.contains("chat not found") || body.contains("CHAT_NOT_FOUND") {
return Err(NotificationError::TelegramChatNotFound {
chat_id: chat_id.to_string(),
}.into());
}
return Err(NotificationError::TelegramAuthError.into());
}
if status.as_u16() == 429 {
// Rate limited by Telegram
let body: serde_json::Value = resp.json().await.unwrap_or_default();
let retry_after = body.get("parameters")
.and_then(|p| p.get("retry_after"))
.and_then(|r| r.as_u64())
.unwrap_or(5);
if attempt < MAX_RETRIES {
warn!("Telegram rate limited, retrying after {}s (attempt {}/{})",
retry_after, attempt + 1, MAX_RETRIES);
tokio::time::sleep(Duration::from_secs(retry_after)).await;
continue;
} else {
return Err(NotificationError::TelegramRateLimited {
retry_after_secs: retry_after,
}.into());
}
}
// Other error
let body = resp.text().await.unwrap_or_default();
return Err(NotificationError::TelegramApiError {
reason: format!("HTTP {}: {}", status, body),
}.into());
}
unreachable!()
}
/// Format alert payload into Telegram message using the system template.
fn format_alert_message(payload: &AlertPayload) -> String {
let country_str = payload.country.as_deref().unwrap_or("Unknown");
format!(
"🛡 <b>[NetGuardia] {action}</b>\n\
Source: <code>{src}</code> ({country})\n\
Target: <code>{dst}</code>\n\
Threat: {threat} (confidence: {confidence:.0}%)\n\
Action: {action_desc}\n\
Time: {time}",
action = "Threat Detected",
src = payload.source_ip,
dst = payload.dest_ip,
country = country_str,
threat = payload.threat_type,
confidence = payload.confidence * 100.0,
action_desc = payload.action_description,
time = payload.timestamp,
)
}
}
#[async_trait]
impl AlertNotifier for TelegramAdapter {
async fn send_alert(&self, payload: &AlertPayload) -> Result<(), Error> {
let (bot_token, chat_id) = match self.get_config()? {
Some(config) => config,
None => {
debug!("Telegram not configured, skipping alert");
return Ok(());
}
};
if !self.check_rate_limit() {
warn!("Telegram rate limit reached ({}/min), dropping alert for IP {}",
MAX_MESSAGES_PER_MINUTE, payload.source_ip);
return Ok(());
}
let message = Self::format_alert_message(payload);
self.send_message(&bot_token, &chat_id, &message).await
}
async fn send_test_message(&self) -> Result<(), Error> {
let (bot_token, chat_id) = match self.get_config()? {
Some(config) => config,
None => {
return Err(NotificationError::NotConfigured {
channel: "telegram".to_string(),
}.into());
}
};
self.send_message(
&bot_token,
&chat_id,
"✅ <b>NetGuardia connected successfully</b>\n\nTelegram notifications are working.",
).await
}
}

View File

@ -18,6 +18,12 @@ fn default_subscription() -> FlowSubscription {
}
}
/// Push { summary, flows } payload to the client.
fn push_payload(stats: &FlowStatistics, sub: &FlowSubscription) -> Option<String> {
let payload = stats.get_flow_payload(sub);
serde_json::to_string(&payload).ok()
}
pub async fn flow_stats_ws(
req: HttpRequest,
body: web::Payload,
@ -34,8 +40,7 @@ pub async fn flow_stats_ws(
loop {
tokio::select! {
_ = ticker.tick() => {
let flows = stats.get_filtered_flows(&subscription);
if let Ok(json) = serde_json::to_string(&flows)
if let Some(json) = push_payload(&stats, &subscription)
&& session.text(json).await.is_err() {
break;
}
@ -50,8 +55,7 @@ pub async fn flow_stats_ws(
subscription.interval_secs = Some(new_interval);
ticker = interval(Duration::from_secs(new_interval));
let flows = stats.get_filtered_flows(&subscription);
if let Ok(json) = serde_json::to_string(&flows)
if let Some(json) = push_payload(&stats, &subscription)
&& session.text(json).await.is_err() {
break;
}

View File

@ -21,16 +21,30 @@ pub fn initialize() -> Scope {
.route("/drops", web::get().to(drops_ws))
}
fn validate_ws_token(query: &web::Query<WsQuery>, jwt: &web::Data<JwtService>) -> Result<(), HttpResponse> {
match &query.token {
Some(token) => {
jwt.validate_token(token)
.map(|_| ())
.map_err(|_| HttpResponse::Unauthorized()
.json(serde_json::json!({"error": "Invalid or expired token"})))
}
fn validate_ws_token(
req: &HttpRequest,
query: &web::Query<WsQuery>,
jwt: &web::Data<JwtService>,
) -> Result<(), HttpResponse> {
// Prefer Authorization header over query parameter
let token = req
.headers()
.get("Authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.map(|t| t.to_string())
.or_else(|| query.token.clone());
match token {
Some(ref t) => jwt
.validate_token(t)
.map(|_| ())
.map_err(|_| {
HttpResponse::Unauthorized()
.json(serde_json::json!({"error": "Invalid or expired token"}))
}),
None => Err(HttpResponse::Unauthorized()
.json(serde_json::json!({"error": "Missing token query parameter"}))),
.json(serde_json::json!({"error": "Missing authentication: provide Authorization header or token query parameter"}))),
}
}
@ -41,7 +55,7 @@ async fn health_ws(
query: web::Query<WsQuery>,
jwt: web::Data<JwtService>,
) -> impl Responder {
if let Err(resp) = validate_ws_token(&query, &jwt) {
if let Err(resp) = validate_ws_token(&req, &query, &jwt) {
return resp;
}
match health_websocket::websocket_system_health(req, stream, health).await {
@ -57,7 +71,7 @@ async fn alerts_ws(
query: web::Query<WsQuery>,
jwt: web::Data<JwtService>,
) -> impl Responder {
if let Err(resp) = validate_ws_token(&query, &jwt) {
if let Err(resp) = validate_ws_token(&req, &query, &jwt) {
return resp;
}
match alert_websocket::websocket_alert(req, stream, ai).await {
@ -73,7 +87,7 @@ async fn flows_ws(
query: web::Query<WsQuery>,
jwt: web::Data<JwtService>,
) -> impl Responder {
if let Err(resp) = validate_ws_token(&query, &jwt) {
if let Err(resp) = validate_ws_token(&req, &query, &jwt) {
return resp;
}
match flow_websocket::flow_stats_ws(req, stream, stats).await {
@ -89,7 +103,7 @@ async fn drops_ws(
query: web::Query<WsQuery>,
jwt: web::Data<JwtService>,
) -> impl Responder {
if let Err(resp) = validate_ws_token(&query, &jwt) {
if let Err(resp) = validate_ws_token(&req, &query, &jwt) {
return resp;
}
match drop_websocket::websocket_drops(req, stream, monitor).await {

View File

@ -0,0 +1,158 @@
use std::net::{SocketAddrV4, SocketAddrV6};
use std::sync::Arc;
use crate::core::ebpf::access_control::AccessControl;
use crate::core::ebpf::geo_block::GeoBlock;
use crate::interface::port::repository::RepositoryPort;
use crate::model::direction::FlowDirection;
use macros::log;
use crate::model::error::Error;
use crate::model::error::ebpf::EbpfError;
use crate::model::list_type::ListType;
/// Domain service that coordinates ACL changes between DB persistence and eBPF data plane.
/// Atomic write: eBPF first, then DB. If DB fails, rollback eBPF.
pub struct AclService {
db: Arc<dyn RepositoryPort>,
access_control: Arc<AccessControl>,
geo_block: Arc<GeoBlock>,
}
impl AclService {
pub fn new(
db: Arc<dyn RepositoryPort>,
access_control: Arc<AccessControl>,
geo_block: Arc<GeoBlock>,
) -> Self {
Self { db, access_control, geo_block }
}
pub async fn add_ipv4(
&self,
direction: FlowDirection,
list_type: ListType,
address: SocketAddrV4,
) -> Result<(), Error> {
self.access_control.add_ipv4_list(direction, list_type, address).await?;
if let Err(e) = self.db.insert_acl_rule(
4,
direction_str(direction),
list_type_str(list_type),
&address.ip().to_string(),
address.port(),
) {
if let Err(rollback_err) = self.access_control.remove_ipv4_list(direction, list_type, address).await {
log!(EbpfError::RollbackFailed(rollback_err));
}
return Err(e);
}
Ok(())
}
pub async fn add_ipv6(
&self,
direction: FlowDirection,
list_type: ListType,
address: SocketAddrV6,
) -> Result<(), Error> {
self.access_control.add_ipv6_list(direction, list_type, address).await?;
if let Err(e) = self.db.insert_acl_rule(
6,
direction_str(direction),
list_type_str(list_type),
&address.ip().to_string(),
address.port(),
) {
if let Err(rollback_err) = self.access_control.remove_ipv6_list(direction, list_type, address).await {
log!(EbpfError::RollbackFailed(rollback_err));
}
return Err(e);
}
Ok(())
}
pub async fn remove_ipv4(
&self,
direction: FlowDirection,
list_type: ListType,
address: SocketAddrV4,
) -> Result<(), Error> {
self.access_control.remove_ipv4_list(direction, list_type, address).await?;
if let Err(e) = self.db.delete_acl_rule(
4,
direction_str(direction),
list_type_str(list_type),
&address.ip().to_string(),
address.port(),
) {
if let Err(rollback_err) = self.access_control.add_ipv4_list(direction, list_type, address).await {
log!(EbpfError::RollbackFailed(rollback_err));
}
return Err(e);
}
Ok(())
}
pub async fn remove_ipv6(
&self,
direction: FlowDirection,
list_type: ListType,
address: SocketAddrV6,
) -> Result<(), Error> {
self.access_control.remove_ipv6_list(direction, list_type, address).await?;
if let Err(e) = self.db.delete_acl_rule(
6,
direction_str(direction),
list_type_str(list_type),
&address.ip().to_string(),
address.port(),
) {
if let Err(rollback_err) = self.access_control.add_ipv6_list(direction, list_type, address).await {
log!(EbpfError::RollbackFailed(rollback_err));
}
return Err(e);
}
Ok(())
}
pub fn block_geo_countries(&self, codes: &[String]) -> Result<u64, Error> {
let total = self.geo_block.block_countries(codes)?;
if let Err(e) = codes.iter().try_for_each(|code| self.db.insert_geo_country(code)) {
let _ = self.geo_block.unblock_countries(codes);
return Err(e);
}
Ok(total)
}
pub fn unblock_geo_countries(&self, codes: &[String]) -> Result<u64, Error> {
let total = self.geo_block.unblock_countries(codes)?;
if let Err(e) = codes.iter().try_for_each(|code| self.db.delete_geo_country(code)) {
let _ = self.geo_block.block_countries(codes);
return Err(e);
}
Ok(total)
}
pub fn get_blocked_countries(&self) -> Vec<String> {
self.geo_block.get_blocked_countries()
}
pub fn access_control(&self) -> &AccessControl {
&self.access_control
}
}
fn direction_str(d: FlowDirection) -> &'static str {
match d {
FlowDirection::Source => "source",
FlowDirection::Destination => "destination",
}
}
fn list_type_str(l: ListType) -> &'static str {
match l {
ListType::White => "whitelist",
ListType::Black => "blacklist",
}
}

View File

@ -0,0 +1,41 @@
use std::future::{ready, Ready};
use actix_web::dev::Payload;
use actix_web::{FromRequest, HttpMessage, HttpRequest};
use crate::model::auth::Claims;
/// Actix-web extractor that pulls `Claims` from request extensions.
///
/// The `AuthMiddleware` validates JWT/API key and stores Claims in extensions.
/// This extractor simply reads them out, returning 401 if missing.
///
/// Usage:
/// ```ignore
/// async fn handler(auth: AuthClaims, ...) -> HttpResponse {
/// let user_id = auth.sub;
/// // ...
/// }
/// ```
pub struct AuthClaims(pub Claims);
impl std::ops::Deref for AuthClaims {
type Target = Claims;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl FromRequest for AuthClaims {
type Error = actix_web::Error;
type Future = Ready<Result<Self, Self::Error>>;
fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future {
match req.extensions().get::<Claims>().cloned() {
Some(claims) => ready(Ok(AuthClaims(claims))),
None => ready(Err(actix_web::error::ErrorUnauthorized(
serde_json::json!({"error": "Unauthorized"}),
))),
}
}
}

View File

@ -1,4 +1,4 @@
use jsonwebtoken::{decode, encode, errors::ErrorKind, DecodingKey, EncodingKey, Header, Validation};
use jsonwebtoken::{decode, encode, errors::ErrorKind, Algorithm, DecodingKey, EncodingKey, Header, Validation};
use crate::interface::port::repository::RepositoryPort;
use crate::model::auth::Claims;
@ -13,21 +13,19 @@ pub struct JwtService {
impl JwtService {
pub fn new(db: &dyn RepositoryPort, expiry_hours: u64) -> Result<Self, Error> {
let secret = match db.get_setting("jwt_secret")? {
Some(s) => s,
let raw_bytes = match db.get_setting("jwt_secret")? {
Some(hex_str) => hex_decode(&hex_str).map_err(|_| AuthError::InvalidToken)?,
None => {
use rand::Rng;
let secret: Vec<u8> = rand::rng().random::<[u8; 32]>().to_vec();
let encoded = hex_encode(&secret);
db.set_setting("jwt_secret", &encoded)?;
encoded
let secret: [u8; 32] = rand::rng().random();
db.set_setting("jwt_secret", &hex_encode(&secret))?;
secret.to_vec()
}
};
let secret_bytes = secret.as_bytes();
Ok(Self {
encoding_key: EncodingKey::from_secret(secret_bytes),
decoding_key: DecodingKey::from_secret(secret_bytes),
encoding_key: EncodingKey::from_secret(&raw_bytes),
decoding_key: DecodingKey::from_secret(&raw_bytes),
expiry_hours,
})
}
@ -51,7 +49,7 @@ impl JwtService {
}
pub fn validate_token(&self, token: &str) -> Result<Claims, Error> {
let token_data = decode::<Claims>(token, &self.decoding_key, &Validation::default())
let token_data = decode::<Claims>(token, &self.decoding_key, &Validation::new(Algorithm::HS256))
.map_err(|e| {
match e.kind() {
ErrorKind::ExpiredSignature => Error::from(AuthError::TokenExpired),
@ -71,6 +69,16 @@ fn hex_encode(data: &[u8]) -> String {
s
}
fn hex_decode(hex: &str) -> Result<Vec<u8>, &'static str> {
if !hex.len().is_multiple_of(2) {
return Err("odd-length hex string");
}
(0..hex.len())
.step_by(2)
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16).map_err(|_| "invalid hex"))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
@ -141,4 +149,36 @@ mod tests {
let result = jwt2.validate_token(&token);
assert!(result.is_err());
}
#[test]
fn test_hex_decode_valid() {
let result = hex_decode("48656c6c6f").unwrap();
assert_eq!(result, b"Hello");
}
#[test]
fn test_hex_decode_empty() {
let result = hex_decode("").unwrap();
assert_eq!(result, Vec::<u8>::new());
}
#[test]
fn test_hex_decode_odd_length() {
let result = hex_decode("abc");
assert!(result.is_err());
}
#[test]
fn test_hex_decode_invalid_chars() {
let result = hex_decode("gg");
assert!(result.is_err());
}
#[test]
fn test_hex_roundtrip() {
let data = b"NetGuardia\x00\xff";
let encoded = hex_encode(data);
let decoded = hex_decode(&encoded).unwrap();
assert_eq!(decoded, data);
}
}

View File

@ -6,7 +6,11 @@ use actix_web::body::EitherBody;
use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform};
use actix_web::{web, Error as ActixError, HttpMessage, HttpResponse};
use macros::log;
use crate::adapter::persistence::Database;
use crate::core::auth::jwt::JwtService;
use crate::model::error::auth::AuthError;
pub struct AuthMiddleware;
@ -33,8 +37,11 @@ pub struct AuthMiddlewareService<S> {
}
fn required_permission(path: &str, method: &actix_web::http::Method) -> Option<String> {
let resource = if path.starts_with("/api/auth/") {
return None; // Auth endpoints handled separately
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)
} 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"
} else if path.starts_with("/api/ml/") {
@ -51,6 +58,15 @@ fn required_permission(path: &str, method: &actix_web::http::Method) -> Option<S
"rate_limit"
} 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/")
|| path.starts_with("/api/report/")
|| path.starts_with("/api/mcp/")
{
"system"
} else {
return None;
};
@ -85,8 +101,11 @@ where
Box::pin(async move {
let path = req.path().to_string();
// Skip auth for login endpoint and non-API routes
if path == "/api/auth/login" || !path.starts_with("/api/") {
// 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);
}
@ -101,34 +120,74 @@ where
}
};
// Extract token from Authorization header
let auth_header = req.headers().get("Authorization");
let token = match auth_header {
Some(val) => {
let val_str = val.to_str().unwrap_or("");
if let Some(token_str) = val_str.strip_prefix("Bearer ") {
token_str
} else {
// 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());
}
}
None => {
let resp = HttpResponse::Unauthorized()
.json(serde_json::json!({"error": "Missing authorization header"}));
return Ok(req.into_response(resp).map_into_right_body());
}
};
} else if let Some(api_key_header) = req.headers().get("X-API-Key") {
// MCP API key auth with rate limiting
let api_key = api_key_header.to_str().unwrap_or("");
let db = match req.app_data::<web::Data<Database>>() {
Some(d) => d.clone(),
None => {
let resp = HttpResponse::InternalServerError()
.json(serde_json::json!({"error": "Database not configured"}));
return Ok(req.into_response(resp).map_into_right_body());
}
};
// Validate token
let claims = match jwt_service.validate_token(token) {
Ok(c) => c,
Err(_) => {
let resp = HttpResponse::Unauthorized()
.json(serde_json::json!({"error": "Invalid or expired token"}));
// Rate limit check for API key attempts (reuse login failure tracking)
let rate_key = format!("apikey:{}", req.peer_addr().map(|a| a.ip().to_string()).unwrap_or_default());
if let Ok(Some(remaining)) = db.check_login_locked(&rate_key) {
let resp = HttpResponse::TooManyRequests()
.json(serde_json::json!({
"error": "Too many failed API key attempts",
"retry_after_secs": remaining,
}));
return Ok(req.into_response(resp).map_into_right_body());
}
match db.validate_api_key(api_key) {
Ok(Some(key_claims)) => {
if let Err(e) = db.clear_login_failures(&rate_key) {
log!(AuthError::LoginClearError(e));
}
key_claims
}
Ok(None) => {
if let Err(e) = db.record_login_failure(&rate_key) {
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

View File

@ -1,3 +1,5 @@
pub mod extractor;
pub mod jwt;
pub mod middleware;
pub mod password;
pub mod setup_guard;

View File

@ -0,0 +1,98 @@
use std::future::{ready, Future, Ready};
use std::pin::Pin;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use actix_web::body::EitherBody;
use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform};
use actix_web::{web, Error as ActixError, HttpResponse};
/// Shared flag indicating whether setup has completed.
/// When false, only setup wizard routes are allowed; all others get 503.
pub type SetupCompleteFlag = Arc<AtomicBool>;
pub struct SetupGuard;
impl<S, B> Transform<S, ServiceRequest> for SetupGuard
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
B: 'static,
{
type Response = ServiceResponse<EitherBody<B>>;
type Error = ActixError;
type Transform = SetupGuardService<S>;
type InitError = ();
type Future = Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future {
ready(Ok(SetupGuardService {
service: std::rc::Rc::new(service),
}))
}
}
pub struct SetupGuardService<S> {
service: std::rc::Rc<S>,
}
impl<S, B> Service<ServiceRequest> for SetupGuardService<S>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
B: 'static,
{
type Response = ServiceResponse<EitherBody<B>>;
type Error = ActixError;
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>>>>;
fn poll_ready(
&self,
ctx: &mut core::task::Context<'_>,
) -> std::task::Poll<Result<(), Self::Error>> {
self.service.poll_ready(ctx)
}
fn call(&self, req: ServiceRequest) -> Self::Future {
let service = std::rc::Rc::clone(&self.service);
Box::pin(async move {
let path = req.path().to_string();
// 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
if setup_complete {
// Normal mode: pass through, but block setup mutation endpoints.
// Allow /api/setup/status (read-only) so frontend can check setup state.
if path.starts_with("/api/setup/") && path != "/api/setup/status" {
let resp = HttpResponse::Gone()
.json(serde_json::json!({"error": "Setup already completed"}));
return Ok(req.into_response(resp).map_into_right_body());
}
let res = service.call(req).await?.map_into_left_body();
return Ok(res);
}
// Setup mode: only allow setup wizard and health endpoints
if path.starts_with("/api/setup/")
|| path.starts_with("/api/health/")
|| path == "/api/auth/login"
|| !path.starts_with("/api/")
{
let res = service.call(req).await?.map_into_left_body();
return Ok(res);
}
// Block all other API routes with 503
let resp = HttpResponse::ServiceUnavailable()
.json(serde_json::json!({
"error": "System setup in progress",
"setup_required": true,
"message": "Please complete the setup wizard at /setup"
}));
Ok(req.into_response(resp).map_into_right_body())
})
}
}

View File

@ -0,0 +1,147 @@
use std::sync::Arc;
use crate::interface::port::repository::RepositoryPort;
use crate::model::error::Error;
use crate::model::error::misc::MiscError;
/// Valid eBPF pipeline stage names.
const VALID_PIPELINE_STAGES: &[&str] = &["access_control", "rate_limit", "service"];
/// All configurable settings grouped by section.
const SETTINGS_MAP: &[(&str, &[&str])] = &[
("network", &["ingress_interface", "egress_interface", "refresh_interval"]),
("http", &["http_port", "jwt_expiry_hours"]),
("inference", &["max_concurrent_flows", "min_packets_for_inference",
"inference_interval_secs", "aggregator_window_secs",
"inference_batch_size", "traffic_logging_mode",
"traffic_log_csv_path"]),
("xdp", &["combined_queue_count", "channel_size", "fill_queue_size", "comp_queue_size",
"tx_queue_size", "rx_queue_size", "frame_size", "frame_count",
"packet_buffer_size", "buffer_pool_capacity"]),
("models", &["deep_autoencoder_name", "classifier_name", "models_config_name"]),
("misc", &["geoip_db_name"]),
("smtp", &["smtp_host", "smtp_port", "smtp_username", "smtp_password", "smtp_recipient"]),
];
/// Domain service for system configuration read/write.
pub struct ConfigService {
db: Arc<dyn RepositoryPort>,
}
impl ConfigService {
pub fn new(db: Arc<dyn RepositoryPort>) -> Self {
Self { db }
}
/// Read all user-configurable settings from DB as structured JSON.
pub fn get_config(&self) -> serde_json::Value {
let get = |key: &str| -> String {
self.db.get_setting(key).ok().flatten().unwrap_or_default()
};
serde_json::json!({
"network": {
"ingress_interface": get("ingress_interface"),
"egress_interface": get("egress_interface"),
"refresh_interval": get("refresh_interval"),
},
"http": {
"http_port": get("http_port"),
"jwt_expiry_hours": get("jwt_expiry_hours"),
},
"inference": {
"max_concurrent_flows": get("max_concurrent_flows"),
"min_packets_for_inference": get("min_packets_for_inference"),
"inference_interval_secs": get("inference_interval_secs"),
"aggregator_window_secs": get("aggregator_window_secs"),
"inference_batch_size": get("inference_batch_size"),
"traffic_logging_mode": get("traffic_logging_mode"),
"traffic_log_csv_path": get("traffic_log_csv_path"),
},
"xdp": {
"combined_queue_count": get("combined_queue_count"),
"channel_size": get("channel_size"),
"fill_queue_size": get("fill_queue_size"),
"comp_queue_size": get("comp_queue_size"),
"tx_queue_size": get("tx_queue_size"),
"rx_queue_size": get("rx_queue_size"),
"frame_size": get("frame_size"),
"frame_count": get("frame_count"),
"packet_buffer_size": get("packet_buffer_size"),
"buffer_pool_capacity": get("buffer_pool_capacity"),
},
"models": {
"deep_autoencoder_name": get("deep_autoencoder_name"),
"classifier_name": get("classifier_name"),
"models_config_name": get("models_config_name"),
},
"misc": {
"geoip_db_name": get("geoip_db_name"),
},
"pipeline": {
"ingress": get("pipeline_ingress"),
"egress": get("pipeline_egress"),
},
"smtp": {
"smtp_host": get("smtp_host"),
"smtp_port": get("smtp_port"),
"smtp_username": get("smtp_username"),
"smtp_recipient": get("smtp_recipient"),
},
})
}
/// Update settings from a JSON body. Returns list of updated keys.
/// Validates pipeline stage names. Only writes non-empty values.
pub fn update_config(&self, body: &serde_json::Value) -> Result<Vec<String>, Error> {
let mut updated = Vec::new();
// Standard key-value settings
for (section, keys) in SETTINGS_MAP {
if let Some(section_obj) = body.get(section).and_then(|v| v.as_object()) {
for key in *keys {
if let Some(val) = section_obj.get(*key).and_then(json_value_as_string) {
self.db.set_setting(key, &val)?;
updated.push(key.to_string());
}
}
}
}
// Pipeline settings — validate stage names
if let Some(pipeline_obj) = body.get("pipeline").and_then(|v| v.as_object()) {
for (field, db_key) in [("ingress", "pipeline_ingress"), ("egress", "pipeline_egress")] {
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) {
return Err(MiscError::ValidationError {
message: format!(
"Invalid pipeline stage '{}'. Valid stages: {}",
stage,
VALID_PIPELINE_STAGES.join(", ")
),
}.into());
}
}
}
self.db.set_setting(db_key, val)?;
updated.push(db_key.to_string());
}
}
}
Ok(updated)
}
}
/// Extract a JSON value as a non-empty string, handling string, boolean, and number types.
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,
}
}

View File

@ -0,0 +1,54 @@
use std::sync::Arc;
use crate::core::ebpf::dns_filter::DnsFilter;
use crate::interface::port::repository::RepositoryPort;
use crate::model::error::Error;
use crate::model::error::misc::MiscError;
/// Domain service that coordinates DNS filter changes between DB and in-memory service.
/// Write order: eBPF/in-memory first, then DB — if eBPF fails, DB remains clean.
pub struct DnsFilterService {
db: Arc<dyn RepositoryPort>,
dns_filter: Arc<DnsFilter>,
}
const MAX_DNS_DOMAINS_PER_REQUEST: usize = 1000;
impl DnsFilterService {
pub fn new(db: Arc<dyn RepositoryPort>, dns_filter: Arc<DnsFilter>) -> Self {
Self { db, dns_filter }
}
pub fn list_domains(&self) -> Vec<String> {
self.dns_filter.list_domains()
}
pub fn add_domains(&self, domains: &[String]) -> Result<usize, Error> {
if domains.len() > MAX_DNS_DOMAINS_PER_REQUEST {
return Err(MiscError::ValidationError {
message: format!("too many domains (max {})", MAX_DNS_DOMAINS_PER_REQUEST),
}.into());
}
// eBPF first
for domain in domains {
self.dns_filter.add_domain(domain)?;
}
// Then DB
for domain in domains {
self.db.insert_dns_domain(domain)?;
}
Ok(domains.len())
}
pub fn remove_domains(&self, domains: &[String]) -> Result<usize, Error> {
// eBPF first
for domain in domains {
self.dns_filter.remove_domain(domain)?;
}
// Then DB
for domain in domains {
self.db.delete_dns_domain(domain)?;
}
Ok(domains.len())
}
}

View File

@ -11,8 +11,7 @@ use crate::model::error::Error;
/// - `weekly_bandwidth_bytes`
/// - `weekly_system_health` (JSON object with cpu, memory, disk fields)
///
/// If a key is missing the report falls back to placeholder data so it can
/// be exercised before the ML aggregation pipeline is wired up.
/// If a key is missing the report uses empty/zero defaults.
pub fn generate_weekly_report(db: &dyn RepositoryPort) -> Result<String, Error> {
let threats_count = db
.get_setting("weekly_threats_count")
@ -22,30 +21,12 @@ pub fn generate_weekly_report(db: &dyn RepositoryPort) -> Result<String, Error>
let top_ips_json = db
.get_setting("weekly_top_ips")
?
.unwrap_or_else(|| {
serde_json::json!([
{"ip": "192.168.1.100", "count": 42},
{"ip": "10.0.0.55", "count": 31},
{"ip": "172.16.0.12", "count": 27},
{"ip": "192.168.2.200", "count": 19},
{"ip": "10.0.1.88", "count": 14}
])
.to_string()
});
.unwrap_or_else(|| "[]".to_string());
let threat_breakdown_json = db
.get_setting("weekly_threat_breakdown")
?
.unwrap_or_else(|| {
serde_json::json!({
"Port Scan": 38,
"DDoS": 22,
"Brute Force": 15,
"DNS Tunneling": 8,
"Data Exfiltration": 3
})
.to_string()
});
.unwrap_or_else(|| "{}".to_string());
let bandwidth = db
.get_setting("weekly_bandwidth_bytes")
@ -57,9 +38,9 @@ pub fn generate_weekly_report(db: &dyn RepositoryPort) -> Result<String, Error>
?
.unwrap_or_else(|| {
serde_json::json!({
"cpu_percent": 24.5,
"memory_percent": 61.2,
"disk_percent": 43.8
"cpu_percent": 0.0,
"memory_percent": 0.0,
"disk_percent": 0.0
})
.to_string()
});

View File

@ -1,5 +1,5 @@
use crate::interface::port::repository::RepositoryPort;
use crate::model::error::database::DatabaseError;
use crate::model::error::notification::NotificationError;
use crate::model::error::Error;
use lettre::message::header::ContentType;
use lettre::transport::smtp::authentication::Credentials;
@ -53,14 +53,14 @@ impl SmtpClient {
/// Send an HTML email using the configured SMTP transport.
pub fn send(&self, to: &str, subject: &str, html_body: &str) -> Result<(), Error> {
let from_addr = self.username.parse().map_err(|e| {
Error::Database(DatabaseError::QueryFailed {
NotificationError::InvalidAddress {
reason: format!("invalid from address: {e}"),
})
}
})?;
let to_addr = to.parse().map_err(|e| {
Error::Database(DatabaseError::QueryFailed {
NotificationError::InvalidAddress {
reason: format!("invalid to address: {e}"),
})
}
})?;
let email = Message::builder()
@ -70,27 +70,27 @@ impl SmtpClient {
.header(ContentType::TEXT_HTML)
.body(html_body.to_string())
.map_err(|e| {
Error::Database(DatabaseError::QueryFailed {
reason: format!("failed to build email: {e}"),
})
NotificationError::MessageBuildFailed {
reason: e.to_string(),
}
})?;
let creds = Credentials::new(self.username.clone(), self.password.clone());
let mailer = SmtpTransport::starttls_relay(&self.host)
.map_err(|e| {
Error::Database(DatabaseError::QueryFailed {
reason: format!("SMTP relay error: {e}"),
})
NotificationError::SmtpConnectionFailed {
reason: e.to_string(),
}
})?
.port(self.port)
.credentials(creds)
.build();
mailer.send(&email).map_err(|e| {
Error::Database(DatabaseError::QueryFailed {
reason: format!("SMTP send error: {e}"),
})
NotificationError::SmtpSendFailed {
reason: e.to_string(),
}
})?;
Ok(())
@ -175,6 +175,7 @@ impl ReportScheduler {
/// Returns `true` when the current local time falls within the Monday 08:00
/// hour (i.e. Monday, hour == 8).
fn is_send_window() -> bool {
use chrono::{Datelike, Timelike};
let now = chrono::Local::now();
now.format("%A").to_string() == "Monday" && now.format("%H").to_string() == "08"
now.weekday() == chrono::Weekday::Mon && now.hour() == 8
}

View File

@ -1,3 +0,0 @@
pub mod validator;
pub use crate::model::license::LicenseInfo;

View File

@ -1,302 +0,0 @@
use std::path::Path;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64;
use ed25519_dalek::{Signature, VerifyingKey, Verifier};
use crate::model::error::license::LicenseError;
use crate::model::license::{LicenseInfo, LicensePayload};
/// Public key auto-embedded from license_pub.key at compile time.
/// Generate with: cd license-generator && cargo run -- keygen
/// Then place license_pub.key in the repo root.
const PUBLIC_KEY_HEX: &str = env!("LICENSE_PUBLIC_KEY");
pub fn validate_license(license_path: &str, ingress_ifname: &str, egress_ifname: &str) -> Result<LicenseInfo, crate::model::error::Error> {
// Guard against builds where the license feature was not configured
if PUBLIC_KEY_HEX == "DISABLED" {
return Err(LicenseError::ValidationFailed {
reason: "License validation not configured".to_string(),
}.into());
}
// If path is empty, license is optional — return unlicensed
if license_path.is_empty() {
tracing::warn!("No license file configured — running without license");
return Ok(LicenseInfo::unlicensed());
}
let path = Path::new(license_path);
if !path.exists() {
tracing::warn!("License file '{}' not found — running without license", license_path);
return Ok(LicenseInfo::unlicensed());
}
let contents = std::fs::read_to_string(path)
.map_err(|_| LicenseError::FileNotFound { path: license_path.to_string() })?;
let contents = contents.trim();
// Format: base64(json_payload).base64(ed25519_signature)
let parts: Vec<&str> = contents.splitn(2, '.').collect();
if parts.len() != 2 {
return Err(LicenseError::ValidationFailed {
reason: "Invalid license format: expected <payload>.<signature>".to_string(),
}.into());
}
let payload_b64 = parts[0];
let signature_b64 = parts[1];
// Decode payload
let payload_bytes = BASE64.decode(payload_b64)
.map_err(|e| LicenseError::ValidationFailed {
reason: format!("Failed to decode payload: {}", e),
})?;
// Decode signature
let sig_bytes = BASE64.decode(signature_b64)
.map_err(|e| LicenseError::ValidationFailed {
reason: format!("Failed to decode signature: {}", e),
})?;
// Parse public key
let pub_key_bytes = hex_decode(PUBLIC_KEY_HEX)
.map_err(|e| LicenseError::ValidationFailed {
reason: format!("Invalid embedded public key: {}", e),
})?;
let pub_key_array: [u8; 32] = pub_key_bytes.try_into()
.map_err(|_| LicenseError::ValidationFailed {
reason: "Public key must be 32 bytes".to_string(),
})?;
let verifying_key = VerifyingKey::from_bytes(&pub_key_array)
.map_err(|_| LicenseError::ValidationFailed {
reason: "Invalid public key".to_string(),
})?;
// Parse signature
let sig_array: [u8; 64] = sig_bytes.try_into()
.map_err(|_| LicenseError::ValidationFailed {
reason: "Signature must be 64 bytes".to_string(),
})?;
let signature = Signature::from_bytes(&sig_array);
// Verify signature over the raw base64-encoded payload (not decoded bytes)
verifying_key.verify(payload_b64.as_bytes(), &signature)
.map_err(|_| LicenseError::InvalidSignature)?;
// Parse payload JSON
let payload: LicensePayload = serde_json::from_slice(&payload_bytes)
.map_err(|e| LicenseError::ValidationFailed {
reason: format!("Failed to parse license payload: {}", e),
})?;
// Verify NIC MAC addresses
let actual_ingress_mac = get_interface_mac(ingress_ifname).unwrap_or_default();
let actual_egress_mac = get_interface_mac(egress_ifname).unwrap_or_default();
if actual_ingress_mac != payload.ingress_mac {
return Err(LicenseError::ValidationFailed {
reason: "Ingress MAC mismatch — license not bound to this device".to_string(),
}.into());
}
if actual_egress_mac != payload.egress_mac {
return Err(LicenseError::ValidationFailed {
reason: "Egress MAC mismatch — license not bound to this device".to_string(),
}.into());
}
// Check expiry
let today = chrono_free_today();
let days_remaining = days_until(&payload.expires, &today)
.map_err(|e| LicenseError::ValidationFailed {
reason: format!("Invalid expiry date: {}", e),
})?;
if days_remaining < 0 {
return Err(LicenseError::Expired.into());
}
tracing::info!(
"License valid — ingress={}, egress={}, expires={}, days_remaining={}, features={:?}",
payload.ingress_mac, payload.egress_mac, payload.expires, days_remaining, payload.features
);
Ok(LicenseInfo {
payload: Some(payload),
valid: true,
days_remaining,
})
}
/// Read MAC address from /sys/class/net/<ifname>/address (Linux only).
fn get_interface_mac(ifname: &str) -> Option<String> {
// Prevent path traversal
if !ifname.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
return None;
}
let path = format!("/sys/class/net/{}/address", ifname);
std::fs::read_to_string(&path)
.ok()
.map(|s| s.trim().to_lowercase())
}
/// Simple hex decoder without external dependency.
fn hex_decode(hex: &str) -> Result<Vec<u8>, String> {
if hex.len() % 2 != 0 {
return Err("Odd-length hex string".to_string());
}
(0..hex.len())
.step_by(2)
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16).map_err(|e| e.to_string()))
.collect()
}
/// Parse YYYY-MM-DD date and return days until expiry (no chrono dependency).
fn chrono_free_today() -> (i32, u32, u32) {
// Use UNIX_EPOCH to get today's date
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
let days_since_epoch = (secs / 86400) as i32;
epoch_days_to_ymd(days_since_epoch)
}
fn parse_date(s: &str) -> Result<(i32, u32, u32), String> {
let parts: Vec<&str> = s.split('-').collect();
if parts.len() != 3 {
return Err("Expected YYYY-MM-DD".to_string());
}
let y = parts[0].parse::<i32>().map_err(|e| e.to_string())?;
let m = parts[1].parse::<u32>().map_err(|e| e.to_string())?;
let d = parts[2].parse::<u32>().map_err(|e| e.to_string())?;
Ok((y, m, d))
}
fn ymd_to_epoch_days(y: i32, m: u32, d: u32) -> i32 {
// Algorithm from Howard Hinnant
let y = if m <= 2 { y - 1 } else { y };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = (y - era * 400) as u32;
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146097 + doe as i32 - 719468
}
fn epoch_days_to_ymd(days: i32) -> (i32, u32, u32) {
let z = days + 719468;
let era = if z >= 0 { z } else { z - 146096 } / 146097;
let doe = (z - era * 146097) as u32;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe as i32 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
(y, m, d)
}
fn days_until(expiry_str: &str, today: &(i32, u32, u32)) -> Result<i64, String> {
let (ey, em, ed) = parse_date(expiry_str)?;
let expiry_days = ymd_to_epoch_days(ey, em, ed) as i64;
let today_days = ymd_to_epoch_days(today.0, today.1, today.2) as i64;
Ok(expiry_days - today_days)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hex_decode_valid() {
assert_eq!(hex_decode("48656c6c6f").unwrap(), b"Hello");
assert_eq!(hex_decode("ff00").unwrap(), vec![0xff, 0x00]);
}
#[test]
fn test_hex_decode_odd_length() {
assert!(hex_decode("abc").is_err());
}
#[test]
fn test_hex_decode_invalid_chars() {
assert!(hex_decode("zzzz").is_err());
}
#[test]
fn test_parse_date_valid() {
assert_eq!(parse_date("2026-03-21").unwrap(), (2026, 3, 21));
assert_eq!(parse_date("2000-01-01").unwrap(), (2000, 1, 1));
}
#[test]
fn test_parse_date_invalid() {
assert!(parse_date("not-a-date").is_err());
assert!(parse_date("2026-13").is_err());
assert!(parse_date("").is_err());
}
#[test]
fn test_epoch_roundtrip() {
// Test several dates
let dates = vec![
(2026, 3, 21),
(2000, 1, 1),
(1970, 1, 1),
(2024, 2, 29), // leap year
(2025, 12, 31),
];
for (y, m, d) in dates {
let days = ymd_to_epoch_days(y, m, d);
let (ry, rm, rd) = epoch_days_to_ymd(days);
assert_eq!((ry, rm, rd), (y, m, d), "Roundtrip failed for {}-{}-{}", y, m, d);
}
}
#[test]
fn test_epoch_day_1970() {
assert_eq!(ymd_to_epoch_days(1970, 1, 1), 0);
}
#[test]
fn test_days_until() {
let today = (2026, 3, 21);
assert_eq!(days_until("2026-03-21", &today).unwrap(), 0);
assert_eq!(days_until("2026-03-22", &today).unwrap(), 1);
assert_eq!(days_until("2026-03-20", &today).unwrap(), -1);
assert_eq!(days_until("2027-03-21", &today).unwrap(), 365);
}
#[test]
fn test_chrono_free_today_returns_reasonable_date() {
let (y, m, d) = chrono_free_today();
assert!(y >= 2025 && y <= 2030);
assert!(m >= 1 && m <= 12);
assert!(d >= 1 && d <= 31);
}
#[test]
fn test_get_interface_mac_path_traversal() {
// Should reject path traversal attempts
assert!(get_interface_mac("../etc/passwd").is_none());
assert!(get_interface_mac("eth0/../..").is_none());
}
#[test]
fn test_license_format_invalid() {
// Test with invalid license content (no file, just the parsing logic)
let bad_formats = vec!["", "nodot", "too.many.dots"];
for fmt in bad_formats {
let parts: Vec<&str> = fmt.splitn(2, '.').collect();
if parts.len() != 2 {
continue; // expected — this is what validate_license checks
}
}
}
}

View File

@ -103,23 +103,20 @@ impl Engine {
}
fn run_inference_tick(&self) {
let mut all_snapshots = Vec::new();
let mut all_flows = Vec::new();
let mut total_count = 0;
// Phase 1: O(1) lock per tracker — just swap
// Phase 1: short lock per tracker — clone uninferred flows, mark as inferred
for tracker in &self.trackers {
let mut t = tracker.lock();
total_count += t.flow_count();
all_snapshots.push(t.take_snapshot());
all_flows.extend(
t.get_uninferred_flows().into_iter()
.filter(|flow| flow.packet_count() >= self.min_packets)
);
// lock released here
}
// Phase 2: filter outside all locks — O(flows) but non-blocking
let all_flows: Vec<FlowData> = all_snapshots.into_iter()
.flat_map(|map| map.into_values())
.filter(|flow| flow.packet_count() >= self.min_packets)
.collect();
log!(MLLog::FlowStats(
total_count,
all_flows.len(),

View File

@ -34,6 +34,9 @@ pub struct FlowData {
pub bwd_bulk_state: BulkState,
pub act_data_pkt_fwd: u32,
is_first_packet: bool,
/// Timestamp (us) when this flow was last sent to ML inference.
/// 0 means never inferred. Used to avoid re-inferring unchanged flows.
pub last_inferred_us: u64,
}
impl FlowData {
@ -66,6 +69,7 @@ impl FlowData {
bwd_bulk_state: BulkState::default(),
act_data_pkt_fwd: 0,
is_first_packet: true,
last_inferred_us: 0,
}
}
@ -186,14 +190,6 @@ impl FlowTracker {
}
}
/// Swap active flows with an empty map and return the old one.
/// This is O(1) — the caller filters outside the lock.
pub fn take_snapshot(&mut self) -> HashMap<FlowKey, FlowData> {
let mut snapshot = HashMap::with_capacity(self.active.capacity());
std::mem::swap(&mut self.active, &mut snapshot);
snapshot
}
pub fn process_packet(&mut self, mut packet: UserPacket, is_ingress: bool) {
let packet_key = FlowKey::from_packet(&packet);
let reversed_key = packet_key.clone().reverse();
@ -247,11 +243,24 @@ impl FlowTracker {
}
}
/// Get a snapshot without draining.
/// Get all active flows (clone, no drain). Used by WebSocket.
pub fn get_flows(&self) -> Vec<FlowData> {
self.active.values().cloned().collect()
}
/// Get flows that received new packets since their last inference,
/// and mark them as inferred. Used by ML engine.
pub fn get_uninferred_flows(&mut self) -> Vec<FlowData> {
let mut result = Vec::new();
for flow in self.active.values_mut() {
if flow.last_time_us > flow.last_inferred_us {
result.push(flow.clone());
flow.last_inferred_us = flow.last_time_us;
}
}
result
}
pub fn flow_count(&self) -> usize {
self.active.len()
}

View File

@ -5,6 +5,7 @@ use std::thread;
use crossbeam::channel::{bounded, Sender, TrySendError};
use macros::log;
use crate::model::error::ml::MLError;
use crate::model::log::ml::MLLog;
pub struct TrafficLogger {
@ -33,7 +34,9 @@ impl TrafficLogger {
log!(MLLog::TrafficLogWriteError(e.to_string()));
}
}
let _ = writer.flush();
if let Err(e) = writer.flush() {
log!(MLError::TrafficLogFlushFailed(e));
}
})?;
Ok(Self { sender })

View File

@ -1,7 +1,14 @@
pub mod acl_service;
pub mod auth;
pub mod config_service;
pub mod dns_filter_service;
pub mod email;
pub mod ebpf;
#[cfg(feature = "license")]
pub mod license;
pub mod ml;
pub mod notification_service;
pub mod playbook_service;
pub mod rate_limit_service;
pub mod report;
pub mod soar;
pub mod stats_aggregator;
pub mod system;

View File

@ -0,0 +1,84 @@
use std::sync::Arc;
use crate::adapter::persistence::Database;
use crate::interface::port::notification::AlertNotifier;
use crate::interface::port::repository::RepositoryPort;
use crate::model::error::Error;
use crate::model::error::misc::MiscError;
/// Domain service for notification config (Telegram, SMTP).
/// Coordinates DB persistence and external service testing.
pub struct NotificationService {
db: Arc<Database>,
}
impl NotificationService {
pub fn new(db: Arc<Database>) -> Self {
Self { db }
}
/// Get Telegram config with redacted bot_token.
pub fn get_telegram_config(&self) -> Result<serde_json::Value, Error> {
match self.db.get_notification_config("telegram")? {
Some(json_str) => {
match serde_json::from_str::<serde_json::Value>(&json_str) {
Ok(mut config) => {
if let Some(token) = config.get("bot_token").and_then(|t| t.as_str())
&& token.len() > 8 {
let redacted = format!("{}...{}", &token[..4], &token[token.len()-4..]);
config["bot_token_redacted"] = serde_json::Value::String(redacted);
config.as_object_mut().map(|obj| obj.remove("bot_token"));
}
config["configured"] = serde_json::Value::Bool(true);
Ok(config)
}
Err(_) => Ok(serde_json::json!({"configured": false})),
}
}
None => Ok(serde_json::json!({"configured": false})),
}
}
/// Save Telegram bot_token + chat_id to DB.
pub fn set_telegram_config(&self, bot_token: &str, chat_id: &str) -> Result<(), Error> {
let config_json = serde_json::json!({
"bot_token": bot_token,
"chat_id": chat_id,
}).to_string();
self.db.set_notification_config("telegram", &config_json)
}
/// Send a test Telegram message using current config.
pub async fn test_telegram(&self) -> Result<(), Error> {
let adapter = crate::adapter::telegram::TelegramAdapter::new(self.db.clone())?;
adapter.send_test_message().await
}
/// Send a test email using current SMTP config.
pub fn test_smtp(&self) -> Result<String, Error> {
let smtp_client = crate::core::email::scheduler::SmtpClient::from_database(
self.db.as_ref() as &dyn RepositoryPort,
)?;
let smtp = smtp_client.ok_or_else(|| {
MiscError::ValidationError { message:
"SMTP not configured. Set smtp_host, smtp_port, smtp_username, smtp_password first.".into()
}
})?;
let recipient = self.db.get_setting("smtp_recipient")?
.filter(|r| !r.is_empty())
.ok_or_else(|| {
MiscError::ValidationError { message:
"No smtp_recipient configured.".into()
}
})?;
smtp.send(
&recipient,
"NetGuardia SMTP Test",
"<h3>NetGuardia SMTP Test</h3><p>If you see this email, SMTP is configured correctly.</p>",
)?;
Ok(format!("Test email sent to {}", recipient))
}
}

View File

@ -0,0 +1,243 @@
use std::sync::Arc;
use crate::adapter::persistence::Database;
use crate::core::soar::engine::SoarEngine;
use crate::interface::port::access_control::AccessControlPort;
use macros::log;
use crate::model::error::Error;
use crate::model::error::soar::SoarError;
/// Domain service for SOAR playbook CRUD operations.
/// Coordinates DB reads/writes, SOAR engine cache refresh, and eBPF unblock.
pub struct PlaybookService {
db: Arc<Database>,
soar_engine: Arc<SoarEngine>,
access_control: Arc<dyn AccessControlPort>,
}
/// Input for creating a new playbook.
pub struct CreatePlaybookInput {
pub name: String,
pub trigger_event: String,
pub condition_threshold: Option<f64>,
pub condition_count: Option<i64>,
pub condition_window_secs: Option<i64>,
pub cooldown_secs: i64,
pub actions: Vec<(String, String)>, // (action_type, params_json)
}
/// Flattened playbook representation for API responses.
pub struct PlaybookData {
pub id: i64,
pub name: String,
pub enabled: bool,
pub trigger_event: String,
pub condition_threshold: Option<f64>,
pub condition_count: Option<i64>,
pub condition_window_secs: Option<i64>,
pub cooldown_secs: i64,
pub actions: Vec<ActionData>,
}
pub struct ActionData {
pub id: i64,
pub action_order: i64,
pub action_type: String,
pub params: serde_json::Value,
}
/// Execution record from soar_executions table.
pub struct ExecutionData {
pub id: i64,
pub playbook_id: i64,
pub source_ip: Option<String>,
pub trigger_event: String,
pub actions_executed: serde_json::Value,
pub created_at: String,
}
/// Active block record from soar_block_rules table.
pub struct ActiveBlockData {
pub id: i64,
pub source_ip: String,
pub playbook_id: i64,
pub expires_at: String,
}
impl PlaybookService {
pub fn new(
db: Arc<Database>,
soar_engine: Arc<SoarEngine>,
access_control: Arc<dyn AccessControlPort>,
) -> Self {
Self { db, soar_engine, access_control }
}
pub fn list_playbooks(&self) -> Result<Vec<PlaybookData>, Error> {
let rows = self.db.load_playbooks_with_actions()?;
let mut result: Vec<PlaybookData> = Vec::new();
for (pb_id, name, enabled, trigger_event, threshold, count, window, cooldown,
action_id, action_order, action_type, action_params) in rows
{
// Find or create the playbook entry
let pb = if let Some(last) = result.last_mut() {
if last.id == pb_id {
last
} else {
result.push(PlaybookData {
id: pb_id, name, enabled, trigger_event,
condition_threshold: threshold,
condition_count: count,
condition_window_secs: window,
cooldown_secs: cooldown,
actions: Vec::new(),
});
result.last_mut().unwrap()
}
} else {
result.push(PlaybookData {
id: pb_id, name, enabled, trigger_event,
condition_threshold: threshold,
condition_count: count,
condition_window_secs: window,
cooldown_secs: cooldown,
actions: Vec::new(),
});
result.last_mut().unwrap()
};
// Append action if present (LEFT JOIN may yield NULLs)
if let (Some(aid), Some(order), Some(atype), Some(params_str)) =
(action_id, action_order, action_type, action_params)
{
pb.actions.push(ActionData {
id: aid,
action_order: order,
action_type: atype,
params: serde_json::from_str(&params_str)
.unwrap_or(serde_json::Value::Null),
});
}
}
Ok(result)
}
pub fn create_playbook(&self, input: &CreatePlaybookInput) -> Result<i64, Error> {
let playbook_id = self.db.insert_playbook(
&input.name, &input.trigger_event, input.condition_threshold,
input.condition_count, input.condition_window_secs, input.cooldown_secs,
)?;
for (i, (action_type, params_str)) in input.actions.iter().enumerate() {
self.db.insert_playbook_action(playbook_id, (i + 1) as i64, action_type, params_str)?;
}
self.soar_engine.reload_cache()?;
Ok(playbook_id)
}
pub fn delete_playbook(&self, id: i64) -> Result<bool, Error> {
let deleted = self.db.delete_playbook(id)?;
if deleted {
self.soar_engine.reload_cache()?;
}
Ok(deleted)
}
pub fn list_active_blocks(&self) -> Result<Vec<ActiveBlockData>, Error> {
let blocks = self.db.get_active_soar_blocks()?;
Ok(blocks.into_iter().map(|(id, ip, pb_id, expires)| {
ActiveBlockData { id, source_ip: ip, playbook_id: pb_id, expires_at: expires }
}).collect())
}
/// Manually unblock an IP: remove from eBPF, mark DB, decrement counter.
pub async fn manual_unblock(&self, id: i64) -> Result<(), Error> {
// Look up the block to get source_ip
let block = self.db.get_soar_block_by_id(id)?
.ok_or_else(|| SoarError::ActionFailed {
action_type: "manual_unblock".to_string(),
reason: format!("Block rule {} not found", id),
})?;
let source_ip = &block.1;
// Remove from eBPF ACL
self.access_control.unblock_ip(source_ip).await?;
// Also remove the auto-added acl_rules entry
let ip_version = ip_version_from_str(source_ip);
if let Err(e) = self.db.delete_acl_rule(ip_version, "source", "blacklist", source_ip, 0) {
log!(SoarError::AclCleanupFailed(e));
}
// Mark as unblocked in DB
self.db.mark_soar_block_unblocked(id)?;
// Decrement active block counter
self.soar_engine.decrement_block_count();
Ok(())
}
pub fn list_executions(&self, limit: i64) -> Result<Vec<ExecutionData>, Error> {
let rows = self.db.list_soar_executions(limit)?;
Ok(rows.into_iter().map(|(id, pb_id, source_ip, trigger_event, actions, created_at)| {
ExecutionData {
id,
playbook_id: pb_id,
source_ip,
trigger_event,
actions_executed: serde_json::from_str(&actions).unwrap_or(serde_json::Value::Null),
created_at,
}
}).collect())
}
pub fn list_whitelist(&self) -> Result<Vec<String>, Error> {
self.db.load_admin_whitelist()
}
pub fn add_whitelist(&self, ip: &str) -> Result<(), Error> {
self.db.insert_admin_whitelist(ip)?;
self.soar_engine.reload_cache()?;
Ok(())
}
pub fn remove_whitelist(&self, ip: &str) -> Result<(), Error> {
self.db.delete_admin_whitelist(ip)?;
self.soar_engine.reload_cache()?;
Ok(())
}
}
/// Determine IP version from a string address using proper parsing.
pub fn ip_version_from_str(ip: &str) -> u8 {
match ip.parse::<std::net::IpAddr>() {
Ok(std::net::IpAddr::V4(_)) => 4,
Ok(std::net::IpAddr::V6(_)) => 6,
Err(_) => if ip.contains(':') { 6 } else { 4 }, // fallback
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ip_version_from_str_ipv4() {
assert_eq!(ip_version_from_str("1.2.3.4"), 4);
assert_eq!(ip_version_from_str("192.168.1.1"), 4);
}
#[test]
fn ip_version_from_str_ipv6() {
assert_eq!(ip_version_from_str("::1"), 6);
assert_eq!(ip_version_from_str("2001:db8::1"), 6);
}
#[test]
fn ip_version_from_str_ipv4_mapped_ipv6() {
// ::ffff:1.2.3.4 should be recognized as IPv6 (it is an IPv6 address)
assert_eq!(ip_version_from_str("::ffff:1.2.3.4"), 6);
}
}

View File

@ -0,0 +1,56 @@
use std::sync::Arc;
use crate::core::ebpf::rate_limit::RateLimitConfig;
use crate::interface::port::repository::RepositoryPort;
use crate::model::error::Error;
/// Domain service that coordinates rate limit config updates between DB and eBPF.
pub struct RateLimitService {
db: Arc<dyn RepositoryPort>,
config: Arc<RateLimitConfig>,
}
impl RateLimitService {
pub fn new(db: Arc<dyn RepositoryPort>, config: Arc<RateLimitConfig>) -> Self {
Self { db, config }
}
pub fn config(&self) -> &RateLimitConfig {
&self.config
}
pub fn update(&self, settings: &RateLimitSettings) -> Result<(), Error> {
if let Some(v) = settings.packet_rate {
self.db.set_rate_limit("packet_rate", v)?;
self.config.set_packet_rate(v)?;
}
if let Some(v) = settings.syn_rate {
self.db.set_rate_limit("syn_rate", v)?;
self.config.set_syn_rate(v)?;
}
if let Some(v) = settings.udp_rate {
self.db.set_rate_limit("udp_rate", v)?;
self.config.set_udp_rate(v)?;
}
if let Some(v) = settings.dns_rate {
self.db.set_rate_limit("dns_rate", v)?;
self.config.set_dns_rate(v)?;
}
if let Some(v) = settings.window_ns {
self.db.set_rate_limit("window_ns", v)?;
self.config.set_window_ns(v)?;
}
Ok(())
}
}
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
pub struct RateLimitSettings {
pub packet_rate: Option<u64>,
pub syn_rate: Option<u64>,
pub udp_rate: Option<u64>,
pub dns_rate: Option<u64>,
pub window_ns: Option<u64>,
}

View File

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

View File

@ -0,0 +1,209 @@
use std::path::PathBuf;
use tracing::info;
use crate::core::report::data::ReportData;
use crate::interface::port::repository::RepositoryPort;
use crate::model::error::notification::NotificationError;
use crate::model::error::Error;
/// Generate a self-contained HTML security report and write to disk.
/// Returns the path to the generated HTML file.
pub fn generate_html_report(db: &dyn RepositoryPort, output_dir: &str) -> Result<PathBuf, Error> {
let data = ReportData::from_database(db)?;
let html = render_html_report(&data);
let html_path = PathBuf::from(output_dir).join(format!(
"netguardia-report-{}.html",
chrono::Local::now().format("%Y%m%d-%H%M%S")
));
std::fs::create_dir_all(output_dir).map_err(|e| {
NotificationError::TelegramApiError {
reason: format!("Failed to create report directory: {}", e),
}
})?;
std::fs::write(&html_path, &html).map_err(|e| {
NotificationError::TelegramApiError {
reason: format!("Failed to write HTML report: {}", e),
}
})?;
info!("HTML report generated at {:?}", html_path);
Ok(html_path)
}
/// Render a self-contained HTML report (inline CSS) from report data.
fn render_html_report(data: &ReportData) -> String {
// Threat breakdown rows
let mut breakdown_rows = String::new();
for item in &data.threat_breakdown {
breakdown_rows.push_str(&format!(
"<tr><td>{}</td><td class=\"num\">{}</td><td>{}</td></tr>",
html_escape(&item.threat_type), item.count, html_escape(&item.trend)
));
}
if data.threat_breakdown.is_empty() {
breakdown_rows.push_str("<tr><td colspan=\"3\" class=\"empty\">No threat data available for this period</td></tr>");
}
// Top blocked IPs rows
let mut ip_rows = String::new();
for ip in &data.top_blocked_ips {
ip_rows.push_str(&format!(
"<tr><td><code>{}</code></td><td class=\"num\">{}</td><td>{}</td></tr>",
html_escape(&ip.ip), ip.count, html_escape(&ip.country)
));
}
if data.top_blocked_ips.is_empty() {
ip_rows.push_str("<tr><td colspan=\"3\" class=\"empty\">No blocked IPs for this period</td></tr>");
}
// Geo distribution rows
let mut geo_rows = String::new();
for geo in &data.geo_distribution {
geo_rows.push_str(&format!(
"<tr><td>{}</td><td class=\"num\">{}</td></tr>",
html_escape(&geo.country), geo.threat_count
));
}
if data.geo_distribution.is_empty() {
geo_rows.push_str("<tr><td colspan=\"2\" class=\"empty\">No geographic data available</td></tr>");
}
// Recommendations
let mut rec_items = String::new();
for rec in &data.recommendations {
rec_items.push_str(&format!("<li>{}</li>", html_escape(rec)));
}
format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>NetGuardia Security Report {period}</title>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; background: #f4f6f9; color: #1a1a2e; line-height: 1.5; }}
.container {{ max-width: 900px; margin: 24px auto; background: #fff; border-radius: 10px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); overflow: hidden; }}
.header {{ background: linear-gradient(135deg, #1a237e, #283593); color: #fff; padding: 32px 40px; }}
.header h1 {{ font-size: 24px; margin-bottom: 4px; }}
.header .meta {{ font-size: 13px; opacity: 0.85; }}
.content {{ padding: 32px 40px; }}
h2 {{ font-size: 17px; color: #1a237e; border-bottom: 2px solid #1a237e; padding-bottom: 6px; margin: 28px 0 14px; }}
h2:first-child {{ margin-top: 0; }}
.summary-grid {{ display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 8px; }}
.summary-card {{ background: #f8f9fc; border-radius: 8px; padding: 16px; text-align: center; }}
.summary-card .value {{ font-size: 28px; font-weight: 700; color: #1a237e; }}
.summary-card .label {{ font-size: 12px; color: #666; margin-top: 4px; }}
table {{ width: 100%; border-collapse: collapse; font-size: 14px; margin-bottom: 8px; }}
th {{ background: #f0f0f5; text-align: left; padding: 10px 14px; font-weight: 600; }}
td {{ padding: 8px 14px; border-bottom: 1px solid #e8e8e8; }}
td.num {{ text-align: right; font-variant-numeric: tabular-nums; }}
td.empty {{ text-align: center; color: #999; font-style: italic; padding: 20px; }}
code {{ background: #f0f0f5; padding: 2px 6px; border-radius: 4px; font-size: 13px; }}
ul {{ padding-left: 20px; }}
li {{ margin-bottom: 6px; }}
.footer {{ background: #f0f0f5; padding: 18px 40px; font-size: 12px; color: #888; text-align: center; }}
@media print {{ body {{ background: #fff; }} .container {{ box-shadow: none; margin: 0; }} }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>NetGuardia Security Report</h1>
<div class="meta">Report Period: {period} &mdash; Generated: {generated_at}</div>
</div>
<div class="content">
<h2>Executive Summary</h2>
<div class="summary-grid">
<div class="summary-card"><div class="value">{total_threats}</div><div class="label">Total Threats</div></div>
<div class="summary-card"><div class="value">{total_blocked}</div><div class="label">Total Blocked</div></div>
<div class="summary-card"><div class="value">{uptime:.1}%</div><div class="label">Uptime</div></div>
<div class="summary-card"><div class="value">{active_rules}</div><div class="label">Active Rules</div></div>
</div>
<h2>Threat Breakdown</h2>
<table>
<thead><tr><th>Threat Type</th><th style="text-align:right">Count</th><th>Trend</th></tr></thead>
<tbody>{breakdown_rows}</tbody>
</table>
<h2>Top Blocked IPs</h2>
<table>
<thead><tr><th>IP Address</th><th style="text-align:right">Block Count</th><th>Country</th></tr></thead>
<tbody>{ip_rows}</tbody>
</table>
<h2>Geographic Distribution</h2>
<table>
<thead><tr><th>Country</th><th style="text-align:right">Threat Count</th></tr></thead>
<tbody>{geo_rows}</tbody>
</table>
<h2>SOAR Activity</h2>
<table>
<thead><tr><th>Auto-Blocks</th><th>Playbooks Triggered</th><th>Auto-Unblocks</th></tr></thead>
<tbody><tr><td class="num">{auto_blocks}</td><td class="num">{playbooks_triggered}</td><td class="num">{auto_unblocks}</td></tr></tbody>
</table>
<h2>System Health</h2>
<table>
<thead><tr><th>Metric</th><th style="text-align:right">Value</th></tr></thead>
<tbody>
<tr><td>Avg CPU</td><td class="num">{avg_cpu:.1}%</td></tr>
<tr><td>Avg Memory</td><td class="num">{avg_mem:.1}%</td></tr>
<tr><td>Disk Usage</td><td class="num">{disk:.1}%</td></tr>
<tr><td>eBPF Status</td><td>{ebpf_status}</td></tr>
</tbody>
</table>
<h2>Recommendations</h2>
<ul>{rec_items}</ul>
</div>
<div class="footer">Generated by NetGuardia &mdash; Network Security Platform</div>
</div>
</body>
</html>"#,
period = html_escape(&data.period),
generated_at = html_escape(&data.generated_at),
total_threats = data.executive_summary.total_threats,
total_blocked = data.executive_summary.total_blocked,
uptime = data.executive_summary.uptime_percent,
active_rules = data.executive_summary.active_rules,
breakdown_rows = breakdown_rows,
ip_rows = ip_rows,
geo_rows = geo_rows,
auto_blocks = data.soar_activity.auto_blocks_executed,
playbooks_triggered = data.soar_activity.playbooks_triggered,
auto_unblocks = data.soar_activity.auto_unblocks,
avg_cpu = data.system_health.avg_cpu_percent,
avg_mem = data.system_health.avg_memory_percent,
disk = data.system_health.disk_usage_percent,
ebpf_status = html_escape(&data.system_health.ebpf_status),
rec_items = rec_items,
)
}
/// Basic HTML escaping for report content.
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
/// Generate report data and format as JSON (for API responses).
pub fn generate_report_json(db: &dyn RepositoryPort) -> Result<serde_json::Value, Error> {
let data = ReportData::from_database(db)?;
serde_json::to_value(&data).map_err(|e| {
NotificationError::TelegramApiError {
reason: format!("Failed to serialize report: {}", e),
}.into()
})
}

View File

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

View File

@ -0,0 +1,988 @@
use std::collections::HashSet;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use dashmap::DashMap;
use macros::log;
use tokio::sync::broadcast;
use crate::adapter::persistence::Database;
use crate::core::ebpf::rate_limit::RateLimitConfig;
use crate::infrastructure::communication_manager::CommunicationManager;
use crate::infrastructure::geoip::GeoIpService;
use crate::interface::communication::event_types::ThreatDetectedEvent;
use crate::interface::port::access_control::AccessControlPort;
use crate::interface::port::notification::{AlertNotifier, AlertPayload};
use crate::model::error::Error;
use crate::model::error::soar::SoarError;
use crate::model::log::soar::SoarLog;
use crate::model::soar::playbook::{Playbook, PlaybookAction};
/// Maximum number of concurrent auto-blocked IPs.
const MAX_AUTO_BLOCK_CAP: u32 = 100;
/// Maximum TTL in seconds (24 hours).
const MAX_TTL_SECS: u64 = 86400;
/// Cooldown key: (playbook_id, source_ip)
type CooldownKey = (i64, String);
/// SOAR Engine — subscribes to ThreatDetectedEvent and executes matching playbooks.
pub struct SoarEngine {
db: Arc<Database>,
access_control: Arc<dyn AccessControlPort>,
/// In-memory cache of playbooks (loaded at startup, refreshed on change).
playbooks: parking_lot::RwLock<Vec<Playbook>>,
/// In-memory cache of admin whitelist IPs.
admin_whitelist: parking_lot::RwLock<HashSet<String>>,
/// Cooldown tracker: maps (playbook_id, source_ip) → last execution time.
cooldowns: DashMap<CooldownKey, std::time::Instant>,
/// AtomicU32 counter for active auto-blocks (avoids DB query per event).
active_block_count: AtomicU32,
/// Optional alert notifier (Telegram, etc.).
alert_notifier: Option<Arc<dyn AlertNotifier>>,
/// Optional GeoIP service for country lookups.
geoip: Option<Arc<GeoIpService>>,
/// Optional rate limit config for adjust_rate_limit action.
rate_limit: Option<Arc<RateLimitConfig>>,
}
impl SoarEngine {
pub fn new(
db: Arc<Database>,
access_control: Arc<dyn AccessControlPort>,
alert_notifier: Option<Arc<dyn AlertNotifier>>,
geoip: Option<Arc<GeoIpService>>,
rate_limit: Option<Arc<RateLimitConfig>>,
) -> Result<Self, Error> {
let engine = Self {
db,
access_control,
playbooks: parking_lot::RwLock::new(Vec::new()),
admin_whitelist: parking_lot::RwLock::new(HashSet::new()),
cooldowns: DashMap::new(),
active_block_count: AtomicU32::new(0),
alert_notifier,
geoip,
rate_limit,
};
engine.reload_cache()?;
Ok(engine)
}
/// Load playbooks and admin whitelist from DB into memory.
pub fn reload_cache(&self) -> Result<(), Error> {
// Load playbooks via single JOIN query (no N+1)
let rows = self.db.load_playbooks_with_actions()?;
let mut playbooks: Vec<Playbook> = Vec::new();
for (pb_id, name, enabled, trigger_event, threshold, _count, _window, cooldown,
_action_id, action_order, action_type, action_params) in rows
{
// Check if this row belongs to the same playbook as the last one
let needs_new = playbooks.last().is_none_or(|last| last.id != pb_id);
if needs_new {
playbooks.push(Playbook {
id: pb_id, name, enabled, trigger_event,
condition_threshold: threshold,
cooldown_secs: cooldown,
actions: Vec::new(),
});
}
// Safe: we just pushed if empty, and last() was Some otherwise
let Some(pb) = playbooks.last_mut() else {
continue;
};
if let (Some(order), Some(atype), Some(params_str)) =
(action_order, action_type, action_params)
{
pb.actions.push(PlaybookAction {
action_order: order,
action_type: atype,
params: serde_json::from_str(&params_str)
.unwrap_or(serde_json::Value::Object(Default::default())),
});
}
}
*self.playbooks.write() = playbooks;
// Load admin whitelist
let whitelist = self.db.load_admin_whitelist()?;
*self.admin_whitelist.write() = whitelist.into_iter().collect();
// Initialize block counter from DB
let count = self.db.count_active_soar_blocks()?;
self.active_block_count.store(count, Ordering::SeqCst);
log!(SoarLog::CacheLoaded(
self.playbooks.read().len(),
self.admin_whitelist.read().len(),
count,
));
Ok(())
}
/// Subscribe to ThreatDetectedEvent and start processing.
/// Returns an error if subscription fails — caller must handle this as a critical failure.
pub fn start(self: Arc<Self>, comm: Arc<CommunicationManager>) -> Result<(), Error> {
let rx = comm.subscribe_event::<ThreatDetectedEvent>().map_err(|e| {
log!(SoarLog::EventHandlingFailed(format!("CRITICAL: SOAR engine failed to subscribe — automated threat response is DISABLED: {}", e)));
SoarError::ActionFailed {
action_type: "subscribe".to_string(),
reason: e.to_string(),
}
})?;
tokio::spawn(async move {
Self::event_loop(self, rx).await;
});
Ok(())
}
async fn event_loop(
self: Arc<Self>,
mut rx: broadcast::Receiver<ThreatDetectedEvent>,
) {
log!(SoarLog::EngineStarted);
loop {
match rx.recv().await {
Ok(event) => {
let engine = Arc::clone(&self);
tokio::spawn(async move {
if let Err(e) = engine.handle_threat_event(&event).await {
log!(SoarLog::EventHandlingFailed(e.to_string()));
}
});
}
Err(broadcast::error::RecvError::Lagged(n)) => {
log!(SoarLog::ReceiverLagged(n));
}
Err(broadcast::error::RecvError::Closed) => {
log!(SoarLog::ChannelClosed);
break;
}
}
}
}
/// Handle a single threat event: find matching playbooks and execute them.
async fn handle_threat_event(
&self,
event: &ThreatDetectedEvent,
) -> Result<(), Error> {
let matching = self.find_matching_playbooks(event);
if matching.is_empty() {
// PlaybookNotFound fallback: only if source_ip is present
if !event.source_ip.is_empty() {
log!(SoarLog::FallbackTriggered(event.attack_type.clone()));
self.execute_fallback(event).await?;
}
return Ok(());
}
for playbook in matching {
if let Err(e) = self.execute_playbook(&playbook, event).await {
log!(SoarLog::PlaybookError(playbook.name.clone(), e.to_string()));
}
}
Ok(())
}
/// Pure function: find playbooks matching the event.
fn find_matching_playbooks(&self, event: &ThreatDetectedEvent) -> Vec<Playbook> {
let playbooks = self.playbooks.read();
playbooks
.iter()
.filter(|pb| {
pb.enabled && pb.trigger_event == event.attack_type
})
.filter(|pb| {
// Check threshold condition
if let Some(threshold) = pb.condition_threshold
&& (event.confidence as f64) < threshold {
return false;
}
true
})
.cloned()
.collect()
}
/// Check if cooldown is active for this playbook + source IP combination.
fn is_cooldown_active(&self, playbook_id: i64, source_ip: &str, cooldown_secs: i64) -> bool {
let key = (playbook_id, source_ip.to_string());
if let Some(last_exec) = self.cooldowns.get(&key) {
let elapsed = last_exec.elapsed();
if elapsed.as_secs() < cooldown_secs as u64 {
return true;
}
}
false
}
/// Record cooldown for a playbook + source IP combination.
fn record_cooldown(&self, playbook_id: i64, source_ip: &str) {
let key = (playbook_id, source_ip.to_string());
self.cooldowns.insert(key, std::time::Instant::now());
}
/// Execute a single playbook against an event.
async fn execute_playbook(
&self,
playbook: &Playbook,
event: &ThreatDetectedEvent,
) -> Result<(), Error> {
// Check cooldown
if self.is_cooldown_active(playbook.id, &event.source_ip, playbook.cooldown_secs) {
log!(SoarLog::CooldownActive(playbook.name.clone(), event.source_ip.clone()));
return Ok(());
}
// Check admin whitelist
if self.admin_whitelist.read().contains(&event.source_ip) {
log!(SoarLog::WhitelistSkipped(event.source_ip.clone(), playbook.name.clone()));
return Ok(());
}
// Execute actions in order
let mut action_results = Vec::new();
for action in &playbook.actions {
let result = self.execute_action(action, event, playbook.id).await;
let result_json = match &result {
Ok(msg) => serde_json::json!({"action": &action.action_type, "status": "ok", "message": msg}),
Err(e) => serde_json::json!({"action": &action.action_type, "status": "error", "message": e.to_string()}),
};
action_results.push(result_json);
if let Err(e) = result {
log!(SoarLog::PlaybookError(playbook.name.clone(), format!("Action '{}': {}", action.action_type, e)));
}
}
// Record cooldown
self.record_cooldown(playbook.id, &event.source_ip);
// Write audit trail
let actions_json = serde_json::to_string(&action_results).unwrap_or_default();
self.db.insert_soar_execution(
playbook.id,
Some(&event.source_ip),
&event.attack_type,
&actions_json,
)?;
log!(SoarLog::PlaybookExecuted(playbook.name.clone(), event.source_ip.clone(), event.attack_type.clone()));
Ok(())
}
/// Check if the system is in enforce mode (as opposed to monitor mode).
fn is_enforce_mode(&self) -> bool {
self.db.get_setting("enforce_mode")
.ok()
.flatten()
.map(|m| m == "enforce")
.unwrap_or(false)
}
/// Execute a single action.
async fn execute_action(
&self,
action: &PlaybookAction,
event: &ThreatDetectedEvent,
playbook_id: i64,
) -> Result<String, Error> {
match action.action_type.as_str() {
"block_ip" => {
if !self.is_enforce_mode() {
log!(SoarLog::MonitorModeSkipped(action.action_type.clone(), event.source_ip.clone()));
return Ok(format!("[monitor] Would block IP {} — skipped", event.source_ip));
}
self.action_block_ip(action, event, playbook_id).await
}
"adjust_rate_limit" => {
if !self.is_enforce_mode() {
log!(SoarLog::MonitorModeSkipped(action.action_type.clone(), event.source_ip.clone()));
return Ok("[monitor] Would adjust rate limit — skipped".to_string());
}
self.action_adjust_rate_limit(action, event).await
}
"send_telegram" => self.action_send_telegram(event).await,
"send_email" => self.action_send_email(event).await,
"log" => self.action_log(action, event),
other => {
Err(SoarError::ActionFailed {
action_type: other.to_string(),
reason: "Unknown action type".to_string(),
}.into())
}
}
}
/// Block an IP via eBPF ACL with TTL.
async fn action_block_ip(
&self,
action: &PlaybookAction,
event: &ThreatDetectedEvent,
playbook_id: i64,
) -> Result<String, Error> {
let ttl_secs = action.params.get("ttl_secs")
.and_then(|v| v.as_u64())
.unwrap_or(1800);
// Validate TTL
if ttl_secs > MAX_TTL_SECS {
return Err(SoarError::InvalidTtl {
ttl_secs,
max_secs: MAX_TTL_SECS,
}.into());
}
// Atomically check cap and reserve a slot using CAS loop
loop {
let current_count = self.active_block_count.load(Ordering::SeqCst);
if current_count >= MAX_AUTO_BLOCK_CAP {
log!(SoarLog::CapReached(current_count, MAX_AUTO_BLOCK_CAP, event.source_ip.clone()));
return Err(SoarError::CapReached { max_cap: MAX_AUTO_BLOCK_CAP }.into());
}
if self.active_block_count.compare_exchange(
current_count,
current_count + 1,
Ordering::SeqCst,
Ordering::SeqCst,
).is_ok() {
break;
}
}
// Block IP via AccessControlPort (handles IPv4/IPv6 dispatch internally)
if let Err(e) = self.access_control.block_ip(&event.source_ip).await {
self.decrement_block_count();
return Err(e);
}
// Calculate expiry time
let expires_at = chrono::Utc::now()
+ chrono::Duration::seconds(ttl_secs as i64);
let expires_str = expires_at.format("%Y-%m-%d %H:%M:%S").to_string();
// Record in soar_block_rules
if let Err(e) = self.db.insert_soar_block_rule(
&event.source_ip,
playbook_id,
&expires_str,
) {
// Attempt to roll back the eBPF block — log failure to prevent silent orphan blocks
if let Err(unblock_err) = self.access_control.unblock_ip(&event.source_ip).await {
log!(SoarLog::EventHandlingFailed(format!(
"CRITICAL: Failed to unblock IP {} after DB error — orphan eBPF block may exist: {}",
event.source_ip, unblock_err
)));
}
self.decrement_block_count();
return Err(e);
}
// Also persist to acl_rules for consistency
let ip_version = crate::core::playbook_service::ip_version_from_str(&event.source_ip);
self.db.insert_acl_rule(ip_version, "source", "blacklist", &event.source_ip, 0)?;
Ok(format!("Blocked IP {} for {}s", event.source_ip, ttl_secs))
}
/// Temporarily reduce global rate limits by a factor with TTL-based restoration.
/// Params: { "factor": 0.5, "ttl_secs": 600 }
/// factor < 1.0 means stricter (e.g. 0.5 = half the current rate).
async fn action_adjust_rate_limit(
&self,
action: &PlaybookAction,
event: &ThreatDetectedEvent,
) -> Result<String, Error> {
let rate_limit = self.rate_limit.as_ref().ok_or_else(|| {
SoarError::ActionFailed {
action_type: "adjust_rate_limit".to_string(),
reason: "Rate limit config not available".to_string(),
}
})?;
let factor = action.params.get("factor")
.and_then(|v| v.as_f64())
.unwrap_or(0.5);
let ttl_secs = action.params.get("ttl_secs")
.and_then(|v| v.as_u64())
.unwrap_or(600);
if !(0.01..=1.0).contains(&factor) {
return Err(SoarError::ActionFailed {
action_type: "adjust_rate_limit".to_string(),
reason: format!("factor must be 0.01..1.0, got {}", factor),
}.into());
}
if ttl_secs > MAX_TTL_SECS {
return Err(SoarError::InvalidTtl { ttl_secs, max_secs: MAX_TTL_SECS }.into());
}
// Read current rates, save originals, apply reduced rates
let current_packet = rate_limit.get_packet_rate().unwrap_or(10000);
let current_syn = rate_limit.get_syn_rate().unwrap_or(1000);
let current_udp = rate_limit.get_udp_rate().unwrap_or(5000);
let current_dns = rate_limit.get_dns_rate().unwrap_or(2000);
// Store original rates for restoration (only if not already adjusted)
let key = "soar_rate_limit_original";
if self.db.get_setting(key)?.filter(|s| !s.is_empty()).is_none() {
let original = serde_json::json!({
"packet_rate": current_packet,
"syn_rate": current_syn,
"udp_rate": current_udp,
"dns_rate": current_dns,
});
self.db.set_setting(key, &original.to_string())?;
}
// Store TTL for restoration
let expires_at = chrono::Utc::now() + chrono::Duration::seconds(ttl_secs as i64);
self.db.set_setting(
"soar_rate_limit_expires",
&expires_at.format("%Y-%m-%d %H:%M:%S").to_string(),
)?;
// Apply reduced rates
let new_packet = (current_packet as f64 * factor) as u64;
let new_syn = (current_syn as f64 * factor) as u64;
let new_udp = (current_udp as f64 * factor) as u64;
let new_dns = (current_dns as f64 * factor) as u64;
rate_limit.set_packet_rate(new_packet.max(1))?;
rate_limit.set_syn_rate(new_syn.max(1))?;
rate_limit.set_udp_rate(new_udp.max(1))?;
rate_limit.set_dns_rate(new_dns.max(1))?;
log!(SoarLog::RateLimitAdjusted(
format!("{}", factor),
ttl_secs,
event.source_ip.clone(),
event.attack_type.clone(),
format!(
"packet {}→{}, syn {}→{}, udp {}→{}, dns {}→{}",
current_packet, new_packet.max(1),
current_syn, new_syn.max(1),
current_udp, new_udp.max(1),
current_dns, new_dns.max(1),
),
));
Ok(format!(
"Rate limits reduced by factor {} for {}s (triggered by {})",
factor, ttl_secs, event.source_ip
))
}
/// Send Telegram notification.
async fn action_send_telegram(
&self,
event: &ThreatDetectedEvent,
) -> Result<String, Error> {
if let Some(notifier) = &self.alert_notifier {
let country = if let Some(geoip) = &self.geoip {
if let Ok(ip_addr) = event.source_ip.parse::<std::net::IpAddr>() {
match geoip.lookup(ip_addr).await {
Ok(Some(loc)) => loc.country,
_ => None,
}
} else {
None
}
} else {
None
};
let payload = AlertPayload {
source_ip: event.source_ip.clone(),
dest_ip: event.dest_ip.clone(),
country,
threat_type: event.attack_type.clone(),
confidence: event.confidence,
action_description: "SOAR auto-response triggered".to_string(),
timestamp: chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(),
};
notifier.send_alert(&payload).await?;
Ok("Telegram notification sent".to_string())
} else {
log!(SoarLog::TelegramNotConfigured);
Ok("Telegram not configured, skipped".to_string())
}
}
/// Send email alert.
async fn action_send_email(
&self,
event: &ThreatDetectedEvent,
) -> Result<String, Error> {
// Use existing SMTP infrastructure
use crate::core::email::scheduler::SmtpClient;
match SmtpClient::from_database(&*self.db)? {
Some(smtp) => {
let subject = format!("[NetGuardia] Threat Alert: {} from {}", event.attack_type, event.source_ip);
let body = format!(
"<h2>Threat Detected</h2>\
<p><b>Source IP:</b> {}</p>\
<p><b>Threat Type:</b> {}</p>\
<p><b>Confidence:</b> {:.1}%</p>\
<p><b>Time:</b> {}</p>",
event.source_ip,
event.attack_type,
event.confidence * 100.0,
chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC"),
);
if let Some(recipient) = self.db.get_setting("smtp_recipient")? {
tokio::task::spawn_blocking(move || smtp.send(&recipient, &subject, &body)).await
.map_err(|e| SoarError::ActionFailed {
action_type: "send_email".to_string(),
reason: e.to_string(),
})??;
Ok("Email alert sent".to_string())
} else {
Ok("No SMTP recipient configured, skipped".to_string())
}
}
None => Ok("SMTP not configured, skipped".to_string()),
}
}
/// Log action.
fn action_log(
&self,
action: &PlaybookAction,
event: &ThreatDetectedEvent,
) -> Result<String, Error> {
let level = action.params.get("level")
.and_then(|v| v.as_str())
.unwrap_or("warn");
log!(SoarLog::ActionLog(
level.to_string(),
event.source_ip.clone(),
event.attack_type.clone(),
format!("{:.2}", event.confidence),
));
Ok(format!("Logged at level '{}'", level))
}
/// Fallback execution when no playbook matches.
/// Only fires when source_ip is present.
async fn execute_fallback(
&self,
event: &ThreatDetectedEvent,
) -> Result<(), Error> {
// Default fallback: block IP for 30 minutes + log
let fake_action = PlaybookAction {
action_order: 1,
action_type: "block_ip".to_string(),
params: serde_json::json!({"ttl_secs": 1800}),
};
let block_result = self.execute_action(&fake_action, event, -1).await;
let result_json = match &block_result {
Ok(msg) => serde_json::json!({"action": "block_ip", "status": "ok", "message": msg}),
Err(e) => serde_json::json!({"action": "block_ip", "status": "error", "message": e.to_string()}),
};
// Audit trail with playbook_id = -1
self.db.insert_soar_execution(
-1,
Some(&event.source_ip),
&event.attack_type,
&serde_json::to_string(&[result_json]).unwrap_or_default(),
)?;
log!(SoarLog::FallbackExecuted(event.source_ip.clone()));
Ok(())
}
/// Recover active block rules on startup by re-applying to eBPF.
pub async fn recover_active_blocks(&self) -> Result<(), Error> {
let active_blocks = self.db.get_active_soar_blocks()?;
let count = active_blocks.len();
for (_id, source_ip, _playbook_id, _expires_at) in &active_blocks {
// Preserve original error-swallowing behavior during recovery
if let Err(e) = self.access_control.block_ip(source_ip).await {
log!(SoarLog::RecoveryFailed(source_ip.clone(), e.to_string()));
}
}
if count > 0 {
log!(SoarLog::RecoveryComplete(count));
}
Ok(())
}
/// Restore original rate limits if the TTL has expired.
/// Called by TTL scheduler on each sweep.
pub fn check_rate_limit_restoration(&self) -> Result<(), Error> {
let expires_str = match self.db.get_setting("soar_rate_limit_expires")?.filter(|s| !s.is_empty()) {
Some(s) => s,
None => return Ok(()), // No active adjustment
};
let expires = chrono::NaiveDateTime::parse_from_str(&expires_str, "%Y-%m-%d %H:%M:%S")
.map(|dt| dt.and_utc())
.unwrap_or_else(|_| chrono::Utc::now());
if chrono::Utc::now() < expires {
return Ok(()); // Not yet expired
}
// Restore original rates
let original_str = match self.db.get_setting("soar_rate_limit_original")?.filter(|s| !s.is_empty()) {
Some(s) => s,
None => {
// No originals saved, just clean up
self.db.set_setting("soar_rate_limit_expires", "")?;
return Ok(());
}
};
if let (Some(rate_limit), Ok(original)) = (
&self.rate_limit,
serde_json::from_str::<serde_json::Value>(&original_str),
) {
let mut restore_errors = Vec::new();
if let Some(v) = original.get("packet_rate").and_then(|v| v.as_u64())
&& let Err(e) = rate_limit.set_packet_rate(v) {
restore_errors.push(format!("packet_rate: {}", e));
}
if let Some(v) = original.get("syn_rate").and_then(|v| v.as_u64())
&& let Err(e) = rate_limit.set_syn_rate(v) {
restore_errors.push(format!("syn_rate: {}", e));
}
if let Some(v) = original.get("udp_rate").and_then(|v| v.as_u64())
&& let Err(e) = rate_limit.set_udp_rate(v) {
restore_errors.push(format!("udp_rate: {}", e));
}
if let Some(v) = original.get("dns_rate").and_then(|v| v.as_u64())
&& let Err(e) = rate_limit.set_dns_rate(v) {
restore_errors.push(format!("dns_rate: {}", e));
}
if restore_errors.is_empty() {
log!(SoarLog::RateLimitRestored);
} else {
log!(SoarLog::RateLimitRestoreFailed(restore_errors.join(", ")));
}
}
// Clean up settings
self.db.set_setting("soar_rate_limit_original", "")?;
self.db.set_setting("soar_rate_limit_expires", "")?;
Ok(())
}
/// Decrement the active block counter (called by TTL scheduler on unblock).
/// Uses CAS loop to avoid underflow race condition.
pub fn decrement_block_count(&self) {
loop {
let current = self.active_block_count.load(Ordering::SeqCst);
if current == 0 {
return; // Nothing to decrement
}
match self.active_block_count.compare_exchange(
current,
current - 1,
Ordering::SeqCst,
Ordering::SeqCst,
) {
Ok(_) => return,
Err(_) => continue, // Retry on contention
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::AtomicBool;
use parking_lot::Mutex;
use crate::model::error::ebpf::EbpfError;
/// Mock AccessControlPort that records calls.
struct MockAccessControl {
blocked_ips: Mutex<Vec<String>>,
unblocked_ips: Mutex<Vec<String>>,
should_fail: AtomicBool,
}
impl MockAccessControl {
fn new() -> Self {
Self {
blocked_ips: Mutex::new(Vec::new()),
unblocked_ips: Mutex::new(Vec::new()),
should_fail: AtomicBool::new(false),
}
}
}
#[async_trait::async_trait]
impl crate::interface::port::access_control::AccessControlPort for MockAccessControl {
async fn block_ip(&self, ip: &str) -> Result<(), Error> {
if self.should_fail.load(Ordering::SeqCst) {
return Err(EbpfError::UnknownError.into());
}
self.blocked_ips.lock().push(ip.to_string());
Ok(())
}
async fn unblock_ip(&self, ip: &str) -> Result<(), Error> {
if self.should_fail.load(Ordering::SeqCst) {
return Err(EbpfError::UnknownError.into());
}
self.unblocked_ips.lock().push(ip.to_string());
Ok(())
}
}
fn test_db() -> Arc<Database> {
Arc::new(Database::new(":memory:").expect("Failed to create test database"))
}
fn test_engine(ac: Arc<dyn crate::interface::port::access_control::AccessControlPort>) -> SoarEngine {
let db = test_db();
db.seed_default_playbooks().ok();
// Tests expect enforce mode to be active so block_ip actions execute
db.set_setting("enforce_mode", "enforce").ok();
SoarEngine::new(db, ac, None, None, None).expect("Failed to create SOAR engine")
}
#[tokio::test]
async fn block_ip_calls_access_control_port() {
let mock = Arc::new(MockAccessControl::new());
let engine = test_engine(mock.clone());
let event = ThreatDetectedEvent {
source_ip: "1.2.3.4".to_string(),
dest_ip: "10.0.0.1".to_string(),
attack_type: "threat_detected".to_string(),
confidence: 0.95,
};
let action = PlaybookAction {
action_order: 1,
action_type: "block_ip".to_string(),
params: serde_json::json!({"ttl_secs": 600}),
};
let result = engine.execute_action(&action, &event, 1).await;
assert!(result.is_ok(), "block_ip action should succeed");
let blocked = mock.blocked_ips.lock();
assert_eq!(blocked.len(), 1);
assert_eq!(blocked[0], "1.2.3.4");
}
#[tokio::test]
async fn block_ip_ipv6_calls_access_control_port() {
let mock = Arc::new(MockAccessControl::new());
let engine = test_engine(mock.clone());
let event = ThreatDetectedEvent {
source_ip: "::1".to_string(),
dest_ip: "::2".to_string(),
attack_type: "threat_detected".to_string(),
confidence: 0.9,
};
let action = PlaybookAction {
action_order: 1,
action_type: "block_ip".to_string(),
params: serde_json::json!({"ttl_secs": 300}),
};
let result = engine.execute_action(&action, &event, 1).await;
assert!(result.is_ok());
let blocked = mock.blocked_ips.lock();
assert_eq!(blocked.len(), 1);
assert_eq!(blocked[0], "::1");
}
#[tokio::test]
async fn recover_active_blocks_uses_port() {
let mock = Arc::new(MockAccessControl::new());
let db = test_db();
db.seed_default_playbooks().ok();
// Insert a fake active block
let expires = (chrono::Utc::now() + chrono::Duration::hours(1))
.format("%Y-%m-%d %H:%M:%S")
.to_string();
db.insert_soar_block_rule("192.168.1.100", 1, &expires).ok();
let engine = SoarEngine::new(db, mock.clone(), None, None, None)
.expect("Failed to create engine");
engine.recover_active_blocks().await.expect("Recovery should succeed");
let blocked = mock.blocked_ips.lock();
assert_eq!(blocked.len(), 1);
assert_eq!(blocked[0], "192.168.1.100");
}
#[tokio::test]
async fn recover_logs_warning_on_failure() {
let mock = Arc::new(MockAccessControl::new());
mock.should_fail.store(true, Ordering::SeqCst);
let db = test_db();
db.seed_default_playbooks().ok();
let expires = (chrono::Utc::now() + chrono::Duration::hours(1))
.format("%Y-%m-%d %H:%M:%S")
.to_string();
db.insert_soar_block_rule("10.0.0.1", 1, &expires).ok();
let engine = SoarEngine::new(db, mock.clone(), None, None, None)
.expect("Failed to create engine");
// Should not panic — errors are logged, not propagated
let result = engine.recover_active_blocks().await;
assert!(result.is_ok(), "Recovery should succeed even when block_ip fails");
// No IPs should have been blocked (mock fails)
assert!(mock.blocked_ips.lock().is_empty());
}
#[tokio::test]
async fn block_ip_respects_cap() {
let mock = Arc::new(MockAccessControl::new());
let engine = test_engine(mock.clone());
// Set counter to max
engine.active_block_count.store(MAX_AUTO_BLOCK_CAP, Ordering::SeqCst);
let event = ThreatDetectedEvent {
source_ip: "1.2.3.4".to_string(),
dest_ip: "10.0.0.1".to_string(),
attack_type: "threat_detected".to_string(),
confidence: 0.95,
};
let action = PlaybookAction {
action_order: 1,
action_type: "block_ip".to_string(),
params: serde_json::json!({"ttl_secs": 600}),
};
let result = engine.execute_action(&action, &event, 1).await;
assert!(result.is_err(), "Should fail when cap is reached");
assert!(mock.blocked_ips.lock().is_empty(), "Should not call block_ip when cap reached");
}
#[tokio::test]
async fn cooldown_prevents_duplicate_execution() {
let mock = Arc::new(MockAccessControl::new());
let engine = test_engine(mock.clone());
let event = ThreatDetectedEvent {
source_ip: "1.2.3.4".to_string(),
dest_ip: "10.0.0.1".to_string(),
attack_type: "threat_detected".to_string(),
confidence: 0.95,
};
// Find a matching playbook — default "threat_detected" playbook should exist
let playbooks = engine.find_matching_playbooks(&event);
assert!(!playbooks.is_empty(), "Should have matching playbooks");
let pb = &playbooks[0];
// First execution should succeed
let result = engine.execute_playbook(pb, &event).await;
assert!(result.is_ok());
assert!(!mock.blocked_ips.lock().is_empty());
// Second execution with same IP should be skipped (cooldown)
let blocked_before = mock.blocked_ips.lock().len();
let result = engine.execute_playbook(pb, &event).await;
assert!(result.is_ok()); // Cooldown returns Ok, just skips
let blocked_after = mock.blocked_ips.lock().len();
assert_eq!(blocked_before, blocked_after, "Should not block again during cooldown");
}
#[tokio::test]
async fn whitelist_prevents_execution() {
let mock = Arc::new(MockAccessControl::new());
let db = test_db();
db.seed_default_playbooks().ok();
db.insert_admin_whitelist("1.2.3.4").ok();
let engine = SoarEngine::new(db, mock.clone(), None, None, None)
.expect("Failed to create engine");
let event = ThreatDetectedEvent {
source_ip: "1.2.3.4".to_string(),
dest_ip: "10.0.0.1".to_string(),
attack_type: "threat_detected".to_string(),
confidence: 0.95,
};
let playbooks = engine.find_matching_playbooks(&event);
assert!(!playbooks.is_empty());
let result = engine.execute_playbook(&playbooks[0], &event).await;
assert!(result.is_ok());
assert!(mock.blocked_ips.lock().is_empty(), "Whitelisted IP should not be blocked");
}
#[test]
fn decrement_block_count_no_underflow() {
let mock = Arc::new(MockAccessControl::new());
let engine = test_engine(mock);
// Start at 0
assert_eq!(engine.active_block_count.load(Ordering::SeqCst), 0);
// Decrement should not underflow
engine.decrement_block_count();
assert_eq!(engine.active_block_count.load(Ordering::SeqCst), 0);
// Set to 2, decrement twice → should be 0
engine.active_block_count.store(2, Ordering::SeqCst);
engine.decrement_block_count();
assert_eq!(engine.active_block_count.load(Ordering::SeqCst), 1);
engine.decrement_block_count();
assert_eq!(engine.active_block_count.load(Ordering::SeqCst), 0);
// One more decrement should stay at 0
engine.decrement_block_count();
assert_eq!(engine.active_block_count.load(Ordering::SeqCst), 0);
}
#[test]
fn reload_cache_loads_playbooks_via_join() {
let mock = Arc::new(MockAccessControl::new());
let db = test_db();
db.seed_default_playbooks().ok();
let engine = SoarEngine::new(db.clone(), mock, None, None, None)
.expect("Failed to create engine");
// Should have loaded default playbooks
let count = engine.playbooks.read().len();
assert!(count > 0, "Should have loaded default playbooks");
// Add a new playbook directly to DB
db.insert_playbook("test_pb", "port_scan", None, None, None, 60).ok();
// Cache should not have it yet
assert_eq!(engine.playbooks.read().len(), count);
// After reload, should have one more
engine.reload_cache().expect("reload should succeed");
assert_eq!(engine.playbooks.read().len(), count + 1);
}
}

View File

@ -0,0 +1,2 @@
pub mod engine;
pub mod scheduler;

View File

@ -0,0 +1,97 @@
use std::sync::Arc;
use macros::log;
use tokio::time::{self, Duration};
use crate::adapter::persistence::Database;
use crate::core::soar::engine::SoarEngine;
use crate::interface::port::access_control::AccessControlPort;
use crate::model::error::Error;
use crate::model::error::soar::SoarError;
use crate::model::log::soar::SoarLog;
/// TTL expiry scheduler: runs every 60 seconds, removes expired auto-block rules.
/// Before removing from eBPF, checks if a manual ACL rule exists for the same IP.
pub struct TtlScheduler {
db: Arc<Database>,
access_control: Arc<dyn AccessControlPort>,
soar_engine: Arc<SoarEngine>,
}
impl TtlScheduler {
pub fn new(
db: Arc<Database>,
access_control: Arc<dyn AccessControlPort>,
soar_engine: Arc<SoarEngine>,
) -> Self {
Self { db, access_control, soar_engine }
}
/// Spawn a background tokio task that runs the TTL sweep every 60 seconds.
pub fn start(self) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
log!(SoarLog::EngineStarted); // TTL scheduler uses same log channel
let mut interval = time::interval(Duration::from_secs(60));
loop {
interval.tick().await;
if let Err(e) = self.sweep().await {
log!(SoarLog::EventHandlingFailed(format!("TTL sweep failed: {}", e)));
}
}
})
}
/// Sweep expired block rules and remove from eBPF if no manual ACL conflict.
/// Also checks for expired rate limit adjustments.
async fn sweep(&self) -> Result<(), Error> {
// Check rate limit restoration
if let Err(e) = self.soar_engine.check_rate_limit_restoration() {
log!(SoarLog::EventHandlingFailed(format!("Rate limit restoration check failed: {}", e)));
}
let expired = self.db.get_expired_soar_blocks()?;
if expired.is_empty() {
return Ok(());
}
let mut removed = 0u32;
let mut skipped = 0u32;
for (id, source_ip, _playbook_id) in &expired {
// Check if a manual ACL rule exists for this IP
let has_manual_rule = self.db.has_manual_acl_rule(source_ip)?;
if has_manual_rule {
// Only mark as unblocked in SOAR records, don't remove from eBPF
self.db.mark_soar_block_unblocked(*id)?;
self.soar_engine.decrement_block_count();
skipped += 1;
log!(SoarLog::WhitelistSkipped(source_ip.clone(), "TTL expired but manual ACL exists".to_string()));
continue;
}
// Remove from eBPF ACL via AccessControlPort
if let Err(e) = self.access_control.unblock_ip(source_ip).await {
log!(SoarLog::RecoveryFailed(source_ip.clone(), format!("unblock failed: {}", e)));
}
// Also remove from acl_rules DB table (the auto-added entry)
let ip_version = crate::core::playbook_service::ip_version_from_str(source_ip);
if let Err(e) = self.db.delete_acl_rule(ip_version, "source", "blacklist", source_ip, 0) {
log!(SoarError::AclCleanupFailed(e));
}
// Mark as unblocked
self.db.mark_soar_block_unblocked(*id)?;
self.soar_engine.decrement_block_count();
removed += 1;
}
if removed > 0 || skipped > 0 {
log!(SoarLog::TtlSweepComplete(removed, skipped));
}
Ok(())
}
}

View File

@ -0,0 +1,187 @@
use std::sync::Arc;
use tokio::time::{self, Duration};
use tracing::{error, info};
use crate::adapter::persistence::Database;
use crate::model::error::Error;
/// Background service that periodically aggregates statistics from SOAR/ML tables
/// and writes them to the settings table for the Report engine to consume.
pub struct StatsAggregator {
db: Arc<Database>,
}
impl StatsAggregator {
pub fn new(db: Arc<Database>) -> Self {
Self { db }
}
/// Spawn a background task that runs aggregation every hour.
pub fn start(self) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
info!("Stats aggregator started (1h interval)");
// Run immediately on startup
if let Err(e) = self.aggregate() {
error!("Initial stats aggregation failed: {}", e);
}
let mut interval = time::interval(Duration::from_secs(3600));
loop {
interval.tick().await;
if let Err(e) = self.aggregate() {
error!("Stats aggregation failed: {}", e);
}
}
})
}
/// Aggregate all weekly statistics and write to settings.
pub fn aggregate(&self) -> Result<(), Error> {
let days = 7;
// SOAR execution counts
let threats_count = self.db.count_weekly_executions(days)?;
self.db.set_setting("weekly_threats_count", &threats_count.to_string())?;
let blocks_count = self.db.count_weekly_blocks(days)?;
self.db.set_setting("weekly_soar_blocks", &blocks_count.to_string())?;
self.db.set_setting("weekly_soar_triggers", &threats_count.to_string())?;
let unblocks_count = self.db.count_weekly_unblocks(days)?;
self.db.set_setting("weekly_soar_unblocks", &unblocks_count.to_string())?;
self.db.set_setting("weekly_blocked_count", &blocks_count.to_string())?;
// Threat breakdown by type
let breakdown = self.db.weekly_threat_breakdown(days)?;
let breakdown_json: serde_json::Map<String, serde_json::Value> = breakdown
.into_iter()
.map(|(k, v)| (k, serde_json::Value::Number(v.into())))
.collect();
self.db.set_setting(
"weekly_threat_breakdown",
&serde_json::to_string(&breakdown_json).unwrap_or_else(|_| "{}".to_string()),
)?;
// Top blocked IPs
let top_ips = self.db.weekly_top_ips(days, 10)?;
let top_ips_json: Vec<serde_json::Value> = top_ips
.into_iter()
.map(|(ip, count)| {
serde_json::json!({
"ip": ip,
"count": count,
"country": "N/A",
})
})
.collect();
self.db.set_setting(
"weekly_top_ips",
&serde_json::to_string(&top_ips_json).unwrap_or_else(|_| "[]".to_string()),
)?;
// Active rules count
let active_rules = self.db.count_acl_rules()?;
self.db.set_setting("active_rules_count", &active_rules.to_string())?;
// System health snapshot using sysinfo
{
use sysinfo::System;
let mut sys = System::new();
sys.refresh_cpu_all();
sys.refresh_memory();
let cpu_usage = sys.global_cpu_usage() as f64;
let mem_total = sys.total_memory();
let mem_used = sys.used_memory();
let mem_percent = if mem_total > 0 {
(mem_used as f64 / mem_total as f64) * 100.0
} else {
0.0
};
let health_json = serde_json::json!({
"avg_cpu_percent": cpu_usage,
"avg_memory_percent": mem_percent,
"disk_usage_percent": 0.0,
"ebpf_status": "running",
});
self.db.set_setting(
"weekly_system_health",
&serde_json::to_string(&health_json).unwrap_or_else(|_| "{}".to_string()),
)?;
// System uptime
let uptime_secs = System::uptime();
let week_secs = (days as u64) * 86400;
let uptime_percent = if uptime_secs >= week_secs {
100.0
} else {
(uptime_secs as f64 / week_secs as f64) * 100.0
};
self.db.set_setting("system_uptime_percent", &format!("{:.1}", uptime_percent))?;
}
// Geo distribution (initialize if not present)
if self.db.get_setting("weekly_geo_distribution")?.is_none() {
self.db.set_setting("weekly_geo_distribution", "[]")?;
}
info!(
"Stats aggregated: {} threats, {} blocks, {} unblocks, {} active rules",
threats_count, blocks_count, unblocks_count, active_rules
);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn aggregator_writes_weekly_stats() {
let db = Arc::new(Database::new(":memory:").expect("test db"));
// Seed some SOAR executions
db.seed_default_playbooks().ok();
db.insert_soar_execution(1, Some("1.2.3.4"), "threat_detected", "[]").ok();
db.insert_soar_execution(1, Some("5.6.7.8"), "brute_force", "[]").ok();
db.insert_soar_block_rule("1.2.3.4", 1, "2099-01-01 00:00:00").ok();
let aggregator = StatsAggregator::new(db.clone());
aggregator.aggregate().expect("aggregation should succeed");
// Verify settings were written
let threats = db.get_setting("weekly_threats_count").unwrap().unwrap();
assert_eq!(threats, "2");
let blocks = db.get_setting("weekly_soar_blocks").unwrap().unwrap();
assert_eq!(blocks, "1");
let breakdown = db.get_setting("weekly_threat_breakdown").unwrap().unwrap();
let parsed: serde_json::Value = serde_json::from_str(&breakdown).unwrap();
assert_eq!(parsed["threat_detected"], 1);
assert_eq!(parsed["brute_force"], 1);
let active_rules = db.get_setting("active_rules_count").unwrap().unwrap();
assert_eq!(active_rules, "0");
let uptime = db.get_setting("system_uptime_percent").unwrap().unwrap();
assert!(!uptime.is_empty());
let health = db.get_setting("weekly_system_health").unwrap().unwrap();
let health_val: serde_json::Value = serde_json::from_str(&health).unwrap();
assert!(health_val["ebpf_status"].as_str() == Some("running"));
}
#[test]
fn aggregator_handles_empty_db() {
let db = Arc::new(Database::new(":memory:").expect("test db"));
let aggregator = StatsAggregator::new(db.clone());
aggregator.aggregate().expect("aggregation should succeed with empty data");
let threats = db.get_setting("weekly_threats_count").unwrap().unwrap();
assert_eq!(threats, "0");
}
}

View File

@ -4,26 +4,34 @@ use aya::maps::{MapData, ProgramArray};
use aya::Ebpf;
use macros::log;
use crate::core::acl_service::AclService;
use crate::core::auth::jwt::JwtService;
use crate::core::config_service::ConfigService;
use crate::core::dns_filter_service::DnsFilterService;
use crate::core::notification_service::NotificationService;
use crate::core::playbook_service::PlaybookService;
use crate::core::rate_limit_service::RateLimitService;
use crate::adapter::persistence::Database;
use crate::core::ebpf::EbpfServices;
use crate::infrastructure::app_config::AppConfig;
use crate::infrastructure::app_services::AppServices;
use crate::infrastructure::communication_manager::CommunicationManager;
use crate::core::ml::config_loader::InferenceConfig;
#[cfg(feature = "license")]
use crate::core::license::LicenseInfo;
use crate::infrastructure::http_server::HttpServerParams;
use crate::infrastructure::service_factory::ServiceFactory;
use crate::core::email::scheduler::ReportScheduler;
use crate::core::soar::engine::SoarEngine;
use crate::core::soar::scheduler::TtlScheduler;
use crate::interface::communication::event_types::ThreatDetectedEvent;
use crate::model::error::Error;
use crate::model::error::system::SystemError;
use crate::model::log::ml::MLLog;
use crate::model::log::system::SystemLog;
use crate::utils::logging::Logging;
use crate::model::ml_detection::AlertMessage;
/// Thin wrapper around infrastructure services.
/// Delegates construction to `ServiceFactory::build()` and HTTP to
/// `infrastructure::http_server::run()`.
/// Will be removed in a later refactoring phase.
/// Orchestrates system lifecycle: startup ordering and shutdown.
/// Construction is delegated to `ServiceFactory::build()`.
/// Setup mode is handled by main.rs — System only runs when setup is complete.
pub struct System {
pub app_config: Arc<AppConfig>,
pub inference_config: Arc<InferenceConfig>,
@ -32,17 +40,24 @@ pub struct System {
pub db: Arc<Database>,
pub jwt_service: Arc<JwtService>,
pub comm: Arc<CommunicationManager>,
#[cfg(feature = "license")]
pub license_info: Arc<LicenseInfo>,
pub soar_engine: Arc<SoarEngine>,
pub ttl_scheduler: Option<TtlScheduler>,
pub report_scheduler: Option<ReportScheduler>,
pub ingress_ebpf: Ebpf,
pub egress_ebpf: Ebpf,
#[allow(dead_code)]
ingress_program_array: ProgramArray<MapData>,
pub acl_service: Arc<AclService>,
pub config_service: Arc<ConfigService>,
pub dns_filter_service: Arc<DnsFilterService>,
pub notification_service: Arc<NotificationService>,
pub playbook_service: Arc<PlaybookService>,
pub rate_limit_service: Arc<RateLimitService>,
_ingress_program_array: ProgramArray<MapData>,
}
impl System {
pub async fn new() -> Result<Self, Error> {
let state = ServiceFactory::build().await?;
/// Build System from DB. Only called after setup is confirmed complete.
pub async fn new(db: Arc<Database>) -> Result<Self, Error> {
let state = ServiceFactory::build(db).await?;
Ok(System {
app_config: state.app_config,
inference_config: state.inference_config,
@ -51,18 +66,23 @@ impl System {
db: state.db,
jwt_service: state.jwt_service,
comm: state.comm,
#[cfg(feature = "license")]
license_info: state.license_info,
soar_engine: state.soar_engine,
ttl_scheduler: Some(state.ttl_scheduler),
report_scheduler: Some(state.report_scheduler),
ingress_ebpf: state.ingress_ebpf,
egress_ebpf: state.egress_ebpf,
ingress_program_array: state.ingress_program_array,
acl_service: state.acl_service,
config_service: state.config_service,
dns_filter_service: state.dns_filter_service,
notification_service: state.notification_service,
playbook_service: state.playbook_service,
rate_limit_service: state.rate_limit_service,
_ingress_program_array: state._ingress_program_array,
})
}
/// Start all services and HTTP server. Setup is already complete at this point.
pub async fn run(&mut self) -> Result<(), Error> {
let ebpf_services = self.ebpf_services.clone();
let app_services = self.app_services.clone();
Logging::initialize()?;
log!(SystemLog::Initializing);
log!(MLLog::ModelsLoaded(
@ -71,7 +91,6 @@ impl System {
log!(MLLog::ModelsLoaded(
self.app_services.ml_models.get_model_info("classifier")
));
log!(MLLog::ConfigLoaded {
features: self.inference_config.num_ae_features(),
attacks: self.inference_config.num_attack_types()
@ -81,9 +100,95 @@ impl System {
log!(SystemLog::InitializeComplete);
self.attach_ebpf()?;
// Subscribe to ML alerts BEFORE starting services to avoid race condition
let ml_alert_rx = self.app_services.ml_alert.subscribe_to_alerts();
let ebpf_services = self.ebpf_services.clone();
let app_services = self.app_services.clone();
ebpf_services.run(app_services.ml_engine.clone()).await?;
app_services.run().await?;
self.run_http_server().await?;
// Start SOAR engine
self.soar_engine.recover_active_blocks().await?;
self.soar_engine.clone().start(self.comm.clone())?;
// Start TTL scheduler
if let Some(ttl) = self.ttl_scheduler.take() {
ttl.start();
}
// Start Report scheduler
if let Some(report) = self.report_scheduler.take() {
report.run();
}
// Start stats aggregator (writes weekly_* settings for Report engine)
let stats_aggregator = crate::core::stats_aggregator::StatsAggregator::new(self.db.clone());
stats_aggregator.start();
// Bridge ML alerts → SOAR
let comm_for_bridge = self.comm.clone();
tokio::spawn(async move {
Self::bridge_ml_to_soar(ml_alert_rx, comm_for_bridge).await;
});
// Start HTTP server in background (!Send, use actix::spawn)
let setup_flag = Arc::new(std::sync::atomic::AtomicBool::new(true));
let ready_flag = Arc::new(std::sync::atomic::AtomicBool::new(false));
let ready_flag_for_set = ready_flag.clone();
let params = HttpServerParams {
app_config: self.app_config.clone(),
inference_config: self.inference_config.clone(),
ebpf_services: self.ebpf_services.clone(),
app_services: self.app_services.clone(),
db: self.db.clone(),
jwt_service: self.jwt_service.clone(),
comm: self.comm.clone(),
setup_complete: setup_flag,
ready: ready_flag,
acl_service: self.acl_service.clone(),
config_service: self.config_service.clone(),
dns_filter_service: self.dns_filter_service.clone(),
notification_service: self.notification_service.clone(),
playbook_service: self.playbook_service.clone(),
rate_limit_service: self.rate_limit_service.clone(),
};
let ready_for_http = ready_flag_for_set.clone();
actix::spawn(async move {
if let Err(e) = crate::infrastructure::http_server::run(params).await {
// HTTP server failed — mark system as NOT ready so health checks fail
ready_for_http.store(false, std::sync::atomic::Ordering::SeqCst);
log!(SystemError::HttpServerError(e));
}
});
// Brief delay to catch immediate bind failures before reporting ready
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
// Mark system as ready — /api/health/ready will now return {"ready": true}
ready_flag_for_set.store(true, std::sync::atomic::Ordering::SeqCst);
// Notify systemd that we are ready (Type=notify)
let _ = sd_notify::notify(true, &[sd_notify::NotifyState::Ready]);
log!(SystemLog::FullInitComplete);
// Start systemd watchdog keepalive task
{
let mut usec: u64 = 0;
if sd_notify::watchdog_enabled(false, &mut usec) && usec > 0 {
let notify_interval = std::time::Duration::from_micros(usec / 2);
tokio::spawn(async move {
let mut tick = tokio::time::interval(notify_interval);
loop {
tick.tick().await;
let _ = sd_notify::notify(false, &[sd_notify::NotifyState::Watchdog]);
}
});
}
}
// Wait for shutdown signal
tokio::signal::ctrl_c().await.ok();
Ok(())
}
@ -98,6 +203,51 @@ impl System {
Ok(())
}
/// Normalize ML model attack type names to SOAR playbook event names.
fn normalize_attack_type(raw: &str) -> String {
match raw {
"Brute Force" => "brute_force".to_string(),
"DDoS" | "DoS" => "threat_detected".to_string(),
"Exploitation" => "threat_detected".to_string(),
"Reconnaissance" => "port_scan".to_string(),
other => {
log!(SystemLog::UnknownMlAttackType(other.to_string()));
"threat_detected".to_string()
}
}
}
async fn bridge_ml_to_soar(
mut rx: tokio::sync::broadcast::Receiver<AlertMessage>,
comm: Arc<CommunicationManager>,
) {
log!(SystemLog::MlSoarBridgeStarted);
loop {
match rx.recv().await {
Ok(alert) => {
let event = ThreatDetectedEvent {
attack_type: Self::normalize_attack_type(
&alert.attack_type.unwrap_or_else(|| "unknown".into()),
),
confidence: alert.confidence,
source_ip: alert.src_ip,
dest_ip: alert.dst_ip,
};
if let Err(e) = comm.publish_event(event).await {
log!(SystemError::MlSoarBridgeFailed(e));
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
log!(SystemLog::MlSoarBridgeLagged(n));
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
log!(SystemLog::MlAlertChannelClosed);
break;
}
}
}
}
fn attach_ebpf(&mut self) -> Result<(), Error> {
let ingress_ifname = self.app_config.network.ingress_ifname.clone();
let egress_ifname = self.app_config.network.egress_ifname.clone();
@ -106,29 +256,50 @@ impl System {
let ingress_mode = ServiceFactory::attach_xdp(&mut self.ingress_ebpf, &ingress_ifname, true)?;
let egress_mode = ServiceFactory::attach_xdp(&mut self.egress_ebpf, &egress_ifname, false)?;
// Store XDP mode in settings for health API reporting
if let Err(e) = self.db.set_setting("xdp_ingress_mode", &ingress_mode) {
tracing::warn!("Failed to store XDP ingress mode: {}", e);
log!(SystemError::XdpModeStoreFailed(e));
}
if let Err(e) = self.db.set_setting("xdp_egress_mode", &egress_mode) {
tracing::warn!("Failed to store XDP egress mode: {}", e);
log!(SystemError::XdpModeStoreFailed(e));
}
Ok(())
}
}
async fn run_http_server(&self) -> Result<(), Error> {
let params = HttpServerParams {
app_config: self.app_config.clone(),
inference_config: self.inference_config.clone(),
ebpf_services: self.ebpf_services.clone(),
app_services: self.app_services.clone(),
db: self.db.clone(),
jwt_service: self.jwt_service.clone(),
comm: self.comm.clone(),
#[cfg(feature = "license")]
license_info: self.license_info.clone(),
};
crate::infrastructure::http_server::run(params).await
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_brute_force() {
assert_eq!(System::normalize_attack_type("Brute Force"), "brute_force");
}
#[test]
fn normalize_ddos() {
assert_eq!(System::normalize_attack_type("DDoS"), "threat_detected");
}
#[test]
fn normalize_dos() {
assert_eq!(System::normalize_attack_type("DoS"), "threat_detected");
}
#[test]
fn normalize_exploitation() {
assert_eq!(System::normalize_attack_type("Exploitation"), "threat_detected");
}
#[test]
fn normalize_reconnaissance() {
assert_eq!(System::normalize_attack_type("Reconnaissance"), "port_scan");
}
#[test]
fn normalize_unknown_falls_back_to_threat_detected() {
assert_eq!(System::normalize_attack_type("SomethingNew"), "threat_detected");
assert_eq!(System::normalize_attack_type("unknown"), "threat_detected");
assert_eq!(System::normalize_attack_type(""), "threat_detected");
}
}

View File

@ -1,7 +1,6 @@
use std::fs;
use crate::adapter::persistence::Database;
use crate::model::config::{
AppConfigTable, HttpConfig, InferenceConfig as InfConfig, MiscConfig, NetworkConfig, PipelineConfig,
HttpConfig, InferenceConfig as InfConfig, MiscConfig, NetworkConfig, PipelineConfig,
};
use crate::model::error::system::SystemError;
use crate::model::error::Error;
@ -15,25 +14,214 @@ pub struct AppConfig {
}
impl AppConfig {
pub fn new() -> Result<Self, Error> {
let toml_string = fs::read_to_string("./config.toml").map_err(SystemError::ConfigNotFound)?;
let table = toml::from_str::<AppConfigTable>(&toml_string).map_err(|_| SystemError::InvalidConfig)?;
if !Self::validate(&table) {
Err(SystemError::InvalidConfig)?
}
Ok(Self {
http: table.http,
network: table.network,
inference: table.inference,
misc: table.misc,
pipeline: table.pipeline,
})
/// Build AppConfig from DB settings with hardcoded defaults.
/// DB is the single source of truth — config.toml is not read.
pub fn new(db: &Database) -> Result<Self, Error> {
let mut config = Self::defaults();
Self::apply_db_overrides(&mut config, db);
Self::validate_config(&config)?;
Ok(config)
}
fn validate(table: &AppConfigTable) -> bool {
let net = &table.network;
let inf = &table.inference;
net.refresh_interval <= 3600
/// Seed all default values into the settings table.
/// Uses INSERT OR IGNORE so existing user-set values are never overwritten.
/// Call this before `new()` so the DB always has a complete set of keys.
pub fn seed_defaults(db: &Database) -> Result<(), Error> {
let defaults: &[(&str, String)] = &[
// Network
("ingress_interface", "eth0".into()),
("egress_interface", "eth1".into()),
("combined_queue_count", "1".into()),
("channel_size", "4096".into()),
("fill_queue_size", "4096".into()),
("comp_queue_size", "4096".into()),
("tx_queue_size", "4096".into()),
("rx_queue_size", "4096".into()),
("frame_size", "4096".into()),
("frame_count", "4096".into()),
("refresh_interval", "5".into()),
("packet_buffer_size", "2048".into()),
("buffer_pool_capacity", "1024".into()),
// HTTP
("http_port", "8080".into()),
("jwt_expiry_hours", "24".into()),
("cors_allowed_origins", "".into()),
// Inference
("deep_autoencoder_name", "deep_autoencoder.onnx".into()),
("classifier_name", "classifier.onnx".into()),
("models_config_name", "inference_config.json".into()),
("max_concurrent_flows", "10000".into()),
("min_packets_for_inference", "5".into()),
("inference_interval_secs", "5".into()),
("aggregator_window_secs", "30".into()),
("inference_batch_size", "200".into()),
("traffic_logging_mode", "true".into()),
("traffic_log_csv_path", "traffic_log.csv".into()),
// Misc
("geoip_db_name", "net-guardia/static/geo/dbip-city-lite.mmdb".into()),
// Pipeline
("pipeline_ingress", "access_control,rate_limit,service".into()),
("pipeline_egress", "".into()),
];
for (key, value) in defaults {
if db.get_setting(key)?.is_none() {
db.set_setting(key, value)?;
}
}
Ok(())
}
/// Hardcoded defaults for all configuration values.
/// These match the original config.toml values and serve as the baseline
/// when DB has no overrides (e.g., first boot before setup wizard).
fn defaults() -> Self {
Self {
http: HttpConfig {
http_server_bind_port: 8080,
jwt_expiry_hours: 24,
cors_allowed_origins: vec![],
},
network: NetworkConfig {
ingress_ifname: "eth0".into(),
egress_ifname: "eth1".into(),
combined_queue_count: 1,
channel_size: 4096,
fill_queue_size: 4096,
comp_queue_size: 4096,
tx_queue_size: 4096,
rx_queue_size: 4096,
frame_size: 4096,
frame_count: 4096,
refresh_interval: 5,
packet_buffer_size: 2048,
buffer_pool_capacity: 1024,
},
inference: InfConfig {
deep_autoencoder_name: "deep_autoencoder.onnx".into(),
classifier_name: "classifier.onnx".into(),
models_config_name: "inference_config.json".into(),
max_concurrent_flows: 10000,
min_packets_for_inference: 5,
inference_interval_secs: 5,
aggregator_window_secs: 30,
inference_batch_size: 200,
traffic_logging_mode: true,
traffic_log_csv_path: "traffic_log.csv".into(),
},
misc: MiscConfig {
geoip_db_name: "net-guardia/static/geo/dbip-city-lite.mmdb".into(),
database_path: "net-guardia.db".into(),
},
pipeline: PipelineConfig {
ingress: vec!["access_control".into(), "rate_limit".into(), "service".into()],
egress: vec![],
},
}
}
/// Override defaults with DB settings. Each setting is optional —
/// missing keys simply keep the default value.
fn apply_db_overrides(config: &mut Self, db: &Database) {
// Network interfaces (set by setup wizard)
if let Ok(Some(v)) = db.get_setting("ingress_interface") {
config.network.ingress_ifname = v;
}
if let Ok(Some(v)) = db.get_setting("egress_interface") {
config.network.egress_ifname = v;
}
// HTTP
if let Ok(Some(v)) = db.get_setting("http_port")
&& let Ok(port) = v.parse::<u16>() {
config.http.http_server_bind_port = port;
}
if let Ok(Some(v)) = db.get_setting("jwt_expiry_hours")
&& let Ok(hours) = v.parse::<u64>() {
config.http.jwt_expiry_hours = hours;
}
if let Ok(Some(v)) = db.get_setting("cors_allowed_origins") {
config.http.cors_allowed_origins = if v.is_empty() {
vec![]
} else {
v.split(',').map(|s| s.trim().to_string()).collect()
};
}
// XDP tuning
if let Ok(Some(v)) = db.get_setting("combined_queue_count")
&& let Ok(n) = v.parse::<u32>() { config.network.combined_queue_count = n; }
if let Ok(Some(v)) = db.get_setting("fill_queue_size")
&& let Ok(n) = v.parse::<u32>() { config.network.fill_queue_size = n; }
if let Ok(Some(v)) = db.get_setting("comp_queue_size")
&& let Ok(n) = v.parse::<u32>() { config.network.comp_queue_size = n; }
if let Ok(Some(v)) = db.get_setting("tx_queue_size")
&& let Ok(n) = v.parse::<u32>() { config.network.tx_queue_size = n; }
if let Ok(Some(v)) = db.get_setting("rx_queue_size")
&& let Ok(n) = v.parse::<u32>() { config.network.rx_queue_size = n; }
if let Ok(Some(v)) = db.get_setting("frame_size")
&& let Ok(n) = v.parse::<u32>() { config.network.frame_size = n; }
if let Ok(Some(v)) = db.get_setting("frame_count")
&& let Ok(n) = v.parse::<u32>() { config.network.frame_count = n; }
// Inference tuning
if let Ok(Some(v)) = db.get_setting("max_concurrent_flows")
&& let Ok(n) = v.parse::<usize>() { config.inference.max_concurrent_flows = n; }
if let Ok(Some(v)) = db.get_setting("min_packets_for_inference")
&& let Ok(n) = v.parse::<usize>() { config.inference.min_packets_for_inference = n; }
if let Ok(Some(v)) = db.get_setting("inference_interval_secs")
&& let Ok(n) = v.parse::<u64>() { config.inference.inference_interval_secs = n; }
if let Ok(Some(v)) = db.get_setting("aggregator_window_secs")
&& let Ok(n) = v.parse::<u64>() { config.inference.aggregator_window_secs = n; }
if let Ok(Some(v)) = db.get_setting("inference_batch_size")
&& let Ok(n) = v.parse::<usize>() { config.inference.inference_batch_size = n; }
if let Ok(Some(v)) = db.get_setting("refresh_interval")
&& let Ok(n) = v.parse::<u64>() { config.network.refresh_interval = n; }
if let Ok(Some(v)) = db.get_setting("channel_size")
&& let Ok(n) = v.parse::<usize>() { config.network.channel_size = n; }
if let Ok(Some(v)) = db.get_setting("packet_buffer_size")
&& let Ok(n) = v.parse::<usize>() { config.network.packet_buffer_size = n; }
if let Ok(Some(v)) = db.get_setting("buffer_pool_capacity")
&& let Ok(n) = v.parse::<usize>() { config.network.buffer_pool_capacity = n; }
// Bool settings
if let Ok(Some(v)) = db.get_setting("traffic_logging_mode") {
config.inference.traffic_logging_mode = v == "true" || v == "1";
}
// File path settings
if let Ok(Some(v)) = db.get_setting("deep_autoencoder_name")
&& !v.is_empty() { config.inference.deep_autoencoder_name = v; }
if let Ok(Some(v)) = db.get_setting("classifier_name")
&& !v.is_empty() { config.inference.classifier_name = v; }
if let Ok(Some(v)) = db.get_setting("models_config_name")
&& !v.is_empty() { config.inference.models_config_name = v; }
if let Ok(Some(v)) = db.get_setting("traffic_log_csv_path")
&& !v.is_empty() { config.inference.traffic_log_csv_path = v; }
if let Ok(Some(v)) = db.get_setting("geoip_db_name")
&& !v.is_empty() { config.misc.geoip_db_name = v; }
// Pipeline (stored as comma-separated)
if let Ok(Some(v)) = db.get_setting("pipeline_ingress") {
config.pipeline.ingress = if v.is_empty() {
vec![]
} else {
v.split(',').map(|s| s.trim().to_string()).collect()
};
}
if let Ok(Some(v)) = db.get_setting("pipeline_egress") {
config.pipeline.egress = if v.is_empty() {
vec![]
} else {
v.split(',').map(|s| s.trim().to_string()).collect()
};
}
}
fn validate_config(config: &Self) -> Result<(), Error> {
let net = &config.network;
let inf = &config.inference;
let valid = net.refresh_interval <= 3600
&& net.combined_queue_count > 0
&& net.fill_queue_size > 0
&& net.comp_queue_size > 0
@ -41,10 +229,115 @@ impl AppConfig {
&& net.rx_queue_size > 0
&& net.frame_size > 0
&& net.frame_count > 0
&& table.http.http_server_bind_port > 0
&& config.http.http_server_bind_port > 0
&& inf.max_concurrent_flows > 0
&& inf.min_packets_for_inference > 0
&& inf.inference_interval_secs > 0
&& inf.inference_batch_size > 0
&& inf.inference_batch_size > 0;
if !valid {
Err(SystemError::InvalidConfig)?
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_db() -> Database {
Database::new(":memory:").expect("in-memory DB")
}
#[test]
fn defaults_are_valid() {
let db = test_db();
let config = AppConfig::new(&db).expect("defaults should be valid");
assert_eq!(config.http.http_server_bind_port, 8080);
assert_eq!(config.network.ingress_ifname, "eth0");
assert_eq!(config.network.egress_ifname, "eth1");
assert_eq!(config.network.combined_queue_count, 1);
assert_eq!(config.network.frame_size, 4096);
}
#[test]
fn db_overrides_interface_names() {
let db = test_db();
db.set_setting("ingress_interface", "ens33").unwrap();
db.set_setting("egress_interface", "ens34").unwrap();
let config = AppConfig::new(&db).unwrap();
assert_eq!(config.network.ingress_ifname, "ens33");
assert_eq!(config.network.egress_ifname, "ens34");
}
#[test]
fn db_overrides_http_port() {
let db = test_db();
db.set_setting("http_port", "9090").unwrap();
let config = AppConfig::new(&db).unwrap();
assert_eq!(config.http.http_server_bind_port, 9090);
}
#[test]
fn db_overrides_xdp_tuning() {
let db = test_db();
db.set_setting("frame_size", "8192").unwrap();
db.set_setting("combined_queue_count", "4").unwrap();
let config = AppConfig::new(&db).unwrap();
assert_eq!(config.network.frame_size, 8192);
assert_eq!(config.network.combined_queue_count, 4);
}
#[test]
fn invalid_db_values_ignored() {
let db = test_db();
db.set_setting("http_port", "not_a_number").unwrap();
let config = AppConfig::new(&db).unwrap();
// Should keep default since parse fails
assert_eq!(config.http.http_server_bind_port, 8080);
}
#[test]
fn empty_db_uses_all_defaults() {
let db = test_db();
let config = AppConfig::new(&db).unwrap();
assert_eq!(config.inference.inference_interval_secs, 5);
assert_eq!(config.inference.inference_batch_size, 200);
assert_eq!(config.misc.database_path, "net-guardia.db");
}
#[test]
fn seed_defaults_populates_empty_db() {
let db = test_db();
AppConfig::seed_defaults(&db).expect("seed should succeed");
assert_eq!(db.get_setting("http_port").unwrap(), Some("8080".to_string()));
assert_eq!(db.get_setting("traffic_logging_mode").unwrap(), Some("true".to_string()));
assert_eq!(db.get_setting("pipeline_ingress").unwrap(), Some("access_control,rate_limit,service".to_string()));
assert_eq!(db.get_setting("geoip_db_name").unwrap(), Some("net-guardia/static/geo/dbip-city-lite.mmdb".to_string()));
}
#[test]
fn seed_defaults_does_not_overwrite_existing() {
let db = test_db();
db.set_setting("http_port", "9090").unwrap();
AppConfig::seed_defaults(&db).expect("seed should succeed");
assert_eq!(db.get_setting("http_port").unwrap(), Some("9090".to_string()));
}
#[test]
fn db_overrides_traffic_logging_mode() {
let db = test_db();
db.set_setting("traffic_logging_mode", "false").unwrap();
let config = AppConfig::new(&db).unwrap();
assert!(!config.inference.traffic_logging_mode);
}
#[test]
fn db_overrides_pipeline() {
let db = test_db();
db.set_setting("pipeline_ingress", "access_control,service").unwrap();
let config = AppConfig::new(&db).unwrap();
assert_eq!(config.pipeline.ingress, vec!["access_control", "service"]);
}
}

View File

@ -57,7 +57,6 @@ impl AppServices {
batch_size: app_config.inference.inference_batch_size,
inference_interval_secs: app_config.inference.inference_interval_secs,
aggregator_window_secs: app_config.inference.aggregator_window_secs,
flow_timeout_us: 60_000_000,
};
let ml_engine = Arc::new(Engine::new(

View File

@ -48,15 +48,6 @@ impl CommunicationManager {
}
}
pub fn with_capacity(channel_capacity: usize) -> Self {
Self {
command_handlers: DashMap::new(),
query_handlers: DashMap::new(),
event_broadcasters: DashMap::new(),
channel_capacity,
}
}
pub fn with_service<S: Send + Sync + 'static>(
self: Arc<Self>,
service: Arc<S>,
@ -149,12 +140,6 @@ impl CommunicationManager {
.ok_or(MiscError::TypeNotRegistered)?;
broadcaster.broadcast_event(Box::new(event))
}
pub fn clear_handlers(&self) {
self.command_handlers.clear();
self.query_handlers.clear();
self.event_broadcasters.clear();
}
}
/// Fluent builder for registering a service's command/query/event handlers.
@ -186,10 +171,7 @@ impl<S: Send + Sync + 'static> ServiceRegistrar<S> {
self
}
pub fn event<E: Event + 'static>(self) -> Self {
self.comm.register_event_type::<E>();
self
}
pub fn build(self) -> Arc<CommunicationManager> {
self.comm
@ -348,13 +330,4 @@ mod tests {
assert_eq!(msgs[0], "via_registrar");
}
#[test]
fn test_clear_handlers() {
let comm = CommunicationManager::new();
comm.register_event_type::<TestEvent>();
assert!(comm.subscribe_event::<TestEvent>().is_ok());
comm.clear_handlers();
assert!(comm.subscribe_event::<TestEvent>().is_err());
}
}

View File

@ -1,12 +1,15 @@
use async_trait::async_trait;
use std::sync::Arc;
use macros::log;
use crate::interface::communication::command::CommandHandler;
use crate::interface::communication::command_types::ChangeEnforceModeCommand;
use crate::interface::communication::query::QueryHandler;
use crate::interface::communication::query_types::GetEnforceModeQuery;
use crate::interface::port::repository::RepositoryPort;
use crate::model::error::Error;
use crate::model::log::system::SystemLog;
/// Handles enforce-mode commands and queries by delegating to the repository.
pub struct EnforceModeHandler {
@ -23,7 +26,7 @@ impl EnforceModeHandler {
impl CommandHandler<ChangeEnforceModeCommand> for EnforceModeHandler {
async fn handle_command(&self, command: ChangeEnforceModeCommand) -> Result<(), Error> {
self.db.set_setting("enforce_mode", &command.mode)?;
tracing::info!("Enforce mode changed to: {}", command.mode);
log!(SystemLog::EnforceModeChanged(command.mode));
Ok(())
}
}

View File

@ -11,7 +11,6 @@ use tokio::task;
use crate::utils::ip_address;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[allow(dead_code)]
pub struct GeoLocation {
pub country: Option<String>,
pub country_code: Option<String>,
@ -21,13 +20,11 @@ pub struct GeoLocation {
pub timezone: Option<String>,
}
#[allow(dead_code)]
pub struct GeoIpService {
reader: Arc<Reader<Vec<u8>>>,
cache: Arc<RwLock<LruCache<IpAddr, Option<GeoLocation>>>>,
}
#[allow(dead_code)]
impl GeoIpService {
pub fn new(db_name: &str) -> Result<Self, MaxMindDbError> {
let db_path = PathBuf::from("net-guardia/static/geo").join(db_name);
@ -116,9 +113,4 @@ impl GeoIpService {
}
}))
}
pub async fn cache_stats(&self) -> (usize, usize) {
let cache = self.cache.read().await;
(cache.len(), cache.cap().get())
}
}

View File

@ -3,21 +3,31 @@ use std::sync::Arc;
use actix_web::web::route;
use actix_web::{web, App, HttpServer};
use crate::core::acl_service::AclService;
use crate::core::auth::jwt::JwtService;
use crate::core::config_service::ConfigService;
use crate::core::dns_filter_service::DnsFilterService;
use crate::core::notification_service::NotificationService;
use crate::core::playbook_service::PlaybookService;
use crate::core::rate_limit_service::RateLimitService;
use crate::adapter::persistence::Database;
use crate::core::ebpf::EbpfServices;
use crate::infrastructure::app_config::AppConfig;
use crate::infrastructure::app_services::AppServices;
use crate::infrastructure::communication_manager::CommunicationManager;
use crate::core::ml::config_loader::InferenceConfig;
#[cfg(feature = "license")]
use crate::core::license::LicenseInfo;
use macros::log;
use crate::model::error::http::HttpError;
use crate::model::error::Error;
use crate::adapter::http::{acl, auth, default, filter, health as health_api, ml, rate_limit as rate_limit_api, stats, system as system_api};
use crate::model::log::http::HttpLog;
use crate::adapter::http::{acl, auth, default, filter, health as health_api, mcp_keys, ml, notification as notification_api, rate_limit as rate_limit_api, report as report_api, setup as setup_api, soar, stats, system as system_api};
use crate::core::auth::setup_guard::{SetupCompleteFlag, SetupGuard};
use crate::adapter::websocket::routes as ws;
use crate::interface::port::repository::RepositoryPort;
/// Shared flag: true when all services (eBPF, ML, SOAR) are fully initialized.
pub type ReadyFlag = Arc<std::sync::atomic::AtomicBool>;
/// Parameters for starting the HTTP server, avoiding `#[cfg]` on function params.
pub struct HttpServerParams {
pub app_config: Arc<AppConfig>,
@ -27,11 +37,122 @@ pub struct HttpServerParams {
pub db: Arc<Database>,
pub jwt_service: Arc<JwtService>,
pub comm: Arc<CommunicationManager>,
#[cfg(feature = "license")]
pub license_info: Arc<LicenseInfo>,
pub setup_complete: SetupCompleteFlag,
pub ready: ReadyFlag,
pub acl_service: Arc<AclService>,
pub config_service: Arc<ConfigService>,
pub dns_filter_service: Arc<DnsFilterService>,
pub notification_service: Arc<NotificationService>,
pub playbook_service: Arc<PlaybookService>,
pub rate_limit_service: Arc<RateLimitService>,
}
/// Run the HTTP server with the given parameters.
/// CORS configuration shared by both full and setup servers.
///
/// When `allowed_origins` is non-empty, only those exact origins are permitted.
/// When empty, RFC 1918 private-network origins (localhost, 127.0.0.1,
/// 192.168.x.x, 10.x.x.x, 172.16-31.x.x) are allowed.
fn cors(allowed_origins: Vec<String>) -> actix_cors::Cors {
actix_cors::Cors::default()
.allowed_origin_fn(move |origin, _req_head| {
let origin_str = origin.to_str().unwrap_or("");
if !allowed_origins.is_empty() {
return allowed_origins.iter().any(|o| o == origin_str);
}
// Default: RFC 1918 private networks only
let bytes = origin.as_bytes();
bytes.starts_with(b"http://localhost:")
|| bytes.starts_with(b"http://127.0.0.1:")
|| bytes.starts_with(b"https://localhost:")
|| bytes.starts_with(b"https://127.0.0.1:")
|| bytes.starts_with(b"http://192.168.")
|| bytes.starts_with(b"https://192.168.")
|| bytes.starts_with(b"http://10.")
|| bytes.starts_with(b"https://10.")
|| is_rfc1918_172(bytes)
})
.allow_any_method()
.allow_any_header()
.max_age(3600)
}
/// Check if origin is from RFC 1918 172.16-31.x.x range.
fn is_rfc1918_172(origin: &[u8]) -> bool {
for prefix in [b"http://172." as &[u8], b"https://172." as &[u8]] {
if origin.starts_with(prefix) {
let rest = &origin[prefix.len()..];
if let Some(dot_pos) = rest.iter().position(|&b| b == b'.')
&& let Some(second_octet) = std::str::from_utf8(&rest[..dot_pos])
.ok()
.and_then(|s| s.parse::<u8>().ok())
{
return (16..=31).contains(&second_octet);
}
}
}
false
}
/// Default fallback port when the configured port is unavailable.
const FALLBACK_PORT: u16 = 8080;
/// Minimal HTTP server for setup wizard mode.
/// Only serves setup, auth, and health routes — no eBPF/ML dependencies.
/// Returns a ServerHandle so the caller can stop it after setup completes.
pub fn start_setup_server(
db: Arc<Database>,
jwt_service: Arc<JwtService>,
setup_complete: SetupCompleteFlag,
port: u16,
) -> Result<actix_web::dev::ServerHandle, Error> {
let make_app = move || {
App::new()
.wrap(cors(vec![]))
.app_data(web::Data::from(db.clone() as Arc<dyn RepositoryPort>))
.app_data(web::Data::from(db.clone()))
.app_data(web::Data::from(jwt_service.clone()))
.app_data(web::Data::new(setup_complete.clone()))
.service(
web::scope("/api")
.wrap(crate::core::auth::middleware::AuthMiddleware)
.service(auth::initialize())
.service(setup_api::initialize())
.service(health_api::initialize())
)
.default_service(route().to(default::default_route))
};
let server = match HttpServer::new(make_app.clone())
.workers(1)
.shutdown_timeout(1) // Fast shutdown — no long-lived connections to drain
.bind(format!("0.0.0.0:{}", port))
{
Ok(s) => s,
Err(e) if port != FALLBACK_PORT => {
log!(HttpLog::SetupBindFallback(port, e.to_string(), FALLBACK_PORT));
HttpServer::new(make_app)
.workers(1)
.shutdown_timeout(1)
.bind(format!("0.0.0.0:{}", FALLBACK_PORT))
.map_err(HttpError::BindPortError)?
}
Err(e) => return Err(HttpError::BindPortError(e).into()),
}
.run();
let handle = server.handle();
// Spawn the server in background (!Send future, use actix::spawn)
actix::spawn(async move {
if let Err(e) = server.await {
log!(HttpLog::SetupServerError(e.to_string()));
}
});
Ok(handle)
}
/// Run the full HTTP server with all services.
pub async fn run(params: HttpServerParams) -> Result<(), Error> {
let access_control = params.ebpf_services.access_control.clone();
let protocol_filter = params.ebpf_services.protocol_filter.clone();
@ -48,18 +169,19 @@ pub async fn run(params: HttpServerParams) -> Result<(), Error> {
let db = params.db;
let jwt_service = params.jwt_service;
let comm = params.comm;
#[cfg(feature = "license")]
let license_info = params.license_info;
let setup_complete = params.setup_complete;
let ready = params.ready;
let acl_service = params.acl_service;
let config_service = params.config_service;
let dns_filter_service = params.dns_filter_service;
let notification_service = params.notification_service;
let playbook_service = params.playbook_service;
let rate_limit_service = params.rate_limit_service;
let port = app_config.http.http_server_bind_port;
HttpServer::new(move || {
let cors = actix_cors::Cors::default()
.allow_any_origin()
.allow_any_method()
.allow_any_header()
.max_age(3600);
let app = App::new()
.wrap(cors)
.wrap(cors(app_config.http.cors_allowed_origins.clone()))
.app_data(web::Data::from(app_config.clone()))
.app_data(web::Data::from(inference_config.clone()))
.app_data(web::Data::from(access_control.clone()))
@ -73,11 +195,19 @@ pub async fn run(params: HttpServerParams) -> Result<(), Error> {
.app_data(web::Data::from(flow_statistics.clone()))
.app_data(web::Data::from(drop_monitor.clone()))
.app_data(web::Data::from(db.clone() as Arc<dyn RepositoryPort>))
.app_data(web::Data::from(db.clone()))
.app_data(web::Data::from(jwt_service.clone()))
.app_data(web::Data::from(comm.clone()));
#[cfg(feature = "license")]
let app = app.app_data(web::Data::from(license_info.clone()));
app.service(
.app_data(web::Data::from(comm.clone()))
.app_data(web::Data::new(setup_complete.clone()))
.app_data(web::Data::new(ready.clone()))
.app_data(web::Data::from(acl_service.clone()))
.app_data(web::Data::from(config_service.clone()))
.app_data(web::Data::from(dns_filter_service.clone()))
.app_data(web::Data::from(notification_service.clone()))
.app_data(web::Data::from(playbook_service.clone()))
.app_data(web::Data::from(rate_limit_service.clone()));
app.wrap(SetupGuard)
.service(
web::scope("/api")
.wrap(crate::core::auth::middleware::AuthMiddleware)
.service(auth::initialize())
@ -88,8 +218,16 @@ pub async fn run(params: HttpServerParams) -> Result<(), Error> {
.service(health_api::initialize())
.service(ml::initialize())
.service(system_api::initialize())
.service(soar::initialize())
.service(notification_api::initialize())
.service(report_api::initialize())
.service(mcp_keys::initialize())
.service(setup_api::initialize())
)
.service(ws::initialize())
// Health-ready endpoint outside /api scope — no auth, no SetupGuard.
// Path intentionally NOT under /api/ to avoid AuthMiddleware.
.route("/health/ready", web::get().to(health_ready))
.default_service(route().to(default::default_route))
})
.bind(format!("0.0.0.0:{}", port))
@ -99,3 +237,8 @@ pub async fn run(params: HttpServerParams) -> Result<(), Error> {
.map_err(HttpError::ServerPanic)?;
Ok(())
}
async fn health_ready(ready: web::Data<ReadyFlag>) -> actix_web::HttpResponse {
let is_ready = ready.load(std::sync::atomic::Ordering::SeqCst);
actix_web::HttpResponse::Ok().json(serde_json::json!({"ready": is_ready}))
}

View File

@ -9,7 +9,7 @@ use aya_log::EbpfLogger;
use common::define::pipeline::*;
use crate::core::auth::jwt::JwtService;
use crate::core::auth::password;
use crate::adapter::persistence::Database;
use crate::core::ebpf::EbpfServices;
use crate::infrastructure::app_config::AppConfig;
@ -19,16 +19,29 @@ use crate::infrastructure::enforce_mode_handler::EnforceModeHandler;
use crate::interface::communication::command_types::ChangeEnforceModeCommand;
use crate::interface::communication::query_types::GetEnforceModeQuery;
use crate::interface::port::repository::RepositoryPort;
#[cfg(feature = "license")]
use crate::core::license::LicenseInfo;
#[cfg(feature = "license")]
use crate::core::license::validator::validate_license;
use crate::core::acl_service::AclService;
use crate::core::config_service::ConfigService;
use crate::core::dns_filter_service::DnsFilterService;
use crate::core::notification_service::NotificationService;
use crate::core::playbook_service::PlaybookService;
use crate::core::rate_limit_service::RateLimitService;
use crate::interface::port::access_control::AccessControlPort;
use crate::interface::port::notification::AlertNotifier;
use crate::adapter::access_control_adapter::EbpfAccessControlAdapter;
use crate::adapter::telegram::TelegramAdapter;
use crate::core::soar::engine::SoarEngine;
use crate::core::soar::scheduler::TtlScheduler;
use crate::core::email::scheduler::ReportScheduler;
use crate::infrastructure::geoip::GeoIpService;
use crate::core::ml::config_loader::InferenceConfig;
use crate::model::direction::FlowDirection;
use crate::model::error::ebpf::EbpfError;
use crate::model::error::misc::MiscError;
use crate::model::error::Error;
use crate::model::list_type::ListType;
use crate::model::log::ebpf::EbpfLog;
use crate::model::log::system::SystemLog;
use macros::log;
/// Holds all Arc-wrapped services that make up the running application.
pub struct AppState {
@ -39,12 +52,19 @@ pub struct AppState {
pub db: Arc<Database>,
pub jwt_service: Arc<JwtService>,
pub comm: Arc<CommunicationManager>,
#[cfg(feature = "license")]
pub license_info: Arc<LicenseInfo>,
pub soar_engine: Arc<SoarEngine>,
pub ttl_scheduler: TtlScheduler,
pub report_scheduler: ReportScheduler,
pub acl_service: Arc<AclService>,
pub config_service: Arc<ConfigService>,
pub dns_filter_service: Arc<DnsFilterService>,
pub notification_service: Arc<NotificationService>,
pub playbook_service: Arc<PlaybookService>,
pub rate_limit_service: Arc<RateLimitService>,
pub ingress_ebpf: Ebpf,
pub egress_ebpf: Ebpf,
#[allow(dead_code)]
pub ingress_program_array: ProgramArray<MapData>,
/// Held to keep the eBPF program array map FD alive.
pub _ingress_program_array: ProgramArray<MapData>,
}
/// Maps stage name (from config.toml) to (function_name, stage_id).
@ -60,18 +80,15 @@ fn stage_registry() -> HashMap<&'static str, (&'static str, u32)> {
pub struct ServiceFactory;
impl ServiceFactory {
/// Build all services and return the complete application state.
pub async fn build() -> Result<AppState, Error> {
/// Build all services. DB is passed in (already created by main.rs).
/// Only called when setup is complete — all config values are in DB.
pub async fn build(db: Arc<Database>) -> Result<AppState, Error> {
// Ensure DB has all default config keys (INSERT OR IGNORE — never overwrites)
AppConfig::seed_defaults(&db)?;
let app_config = Arc::new(AppConfig::new(&db)?);
let mut ingress_ebpf = Self::load_ebpf("ingress")?;
let mut egress_ebpf = Self::load_ebpf("egress")?;
let app_config = Arc::new(AppConfig::new()?);
#[cfg(feature = "license")]
let license_info = Arc::new(validate_license(
&app_config.misc.license_file,
&app_config.network.ingress_ifname,
&app_config.network.egress_ifname,
)?);
let ingress_program_array = Self::configure_ingress_pipeline(
&mut ingress_ebpf,
@ -85,21 +102,6 @@ impl ServiceFactory {
Self::write_num_queues(&mut ingress_ebpf, num_queues)?;
Self::write_num_queues(&mut egress_ebpf, num_queues)?;
let db = Arc::new(Database::new(&app_config.misc.database_path)?);
// Create default admin user if no users exist
if db.user_count().unwrap_or(0) == 0 {
let hash = password::hash_password("admin")?;
let admin_user_id = db.insert_user("admin", &hash, "admin", true)?;
// Assign to Administrator group
if let Ok(groups) = db.list_user_groups()
&& let Some((group_id, _, _, _, _)) = groups.into_iter().find(|(_, name, _, _, _)| name == "Administrator")
{
let _ = db.set_user_groups(admin_user_id, &[group_id]);
}
tracing::warn!("Default admin user created with password 'admin' — you must change it on first login");
}
// Ensure enforce_mode setting exists (default: monitor)
if db.get_setting("enforce_mode")?.is_none() {
db.set_setting("enforce_mode", "monitor")?;
@ -124,12 +126,85 @@ impl ServiceFactory {
.query::<GetEnforceModeQuery>()
.build();
// Register ThreatDetectedEvent channel for SOAR
comm.register_event_type::<crate::interface::communication::event_types::ThreatDetectedEvent>();
// Seed default SOAR playbooks if empty
db.seed_default_playbooks()?;
// Restore persisted state from database
Self::restore_dns_blacklist(&db, &ebpf_services);
Self::restore_geo_countries(&db, &ebpf_services);
Self::restore_rate_limits(&db, &ebpf_services);
Self::restore_acl_rules(&db, &ebpf_services).await;
// Create TelegramAdapter as alert notifier (may fail if not configured yet)
let alert_notifier: Option<Arc<dyn AlertNotifier>> = match TelegramAdapter::new(db.clone()) {
Ok(adapter) => Some(Arc::new(adapter)),
Err(e) => {
log!(SystemLog::TelegramUnavailable(e.to_string()));
None
}
};
// Try to initialize GeoIP service
let geoip: Option<Arc<GeoIpService>> = match GeoIpService::new(&app_config.misc.geoip_db_name) {
Ok(svc) => {
log!(SystemLog::GeoIpInitialized);
Some(Arc::new(svc))
}
Err(e) => {
log!(SystemLog::GeoIpUnavailable(e.to_string()));
None
}
};
// Create AccessControlPort adapter for SOAR/TTL (decoupled from eBPF)
let access_control_port: Arc<dyn AccessControlPort> = Arc::new(
EbpfAccessControlAdapter::new(ebpf_services.access_control.clone())
);
// Create SOAR engine
let soar_engine = Arc::new(SoarEngine::new(
db.clone(),
access_control_port.clone(),
alert_notifier.clone(),
geoip.clone(),
Some(ebpf_services.rate_limit.clone()),
)?);
// Create TTL scheduler
let ttl_scheduler = TtlScheduler::new(
db.clone(),
access_control_port.clone(),
soar_engine.clone(),
);
// Create Report scheduler
let report_scheduler = ReportScheduler::new(db.clone() as Arc<dyn RepositoryPort>);
// Create domain services (Phase 2B)
let acl_service = Arc::new(AclService::new(
db.clone() as Arc<dyn RepositoryPort>,
ebpf_services.access_control.clone(),
ebpf_services.geo_block.clone(),
));
let dns_filter_service = Arc::new(DnsFilterService::new(
db.clone() as Arc<dyn RepositoryPort>,
ebpf_services.dns_filter.clone(),
));
let rate_limit_service = Arc::new(RateLimitService::new(
db.clone() as Arc<dyn RepositoryPort>,
ebpf_services.rate_limit.clone(),
));
let playbook_service = Arc::new(PlaybookService::new(
db.clone(),
soar_engine.clone(),
access_control_port,
));
let config_service = Arc::new(ConfigService::new(db.clone() as Arc<dyn RepositoryPort>));
let notification_service = Arc::new(NotificationService::new(db.clone()));
Ok(AppState {
app_config,
inference_config,
@ -138,11 +213,18 @@ impl ServiceFactory {
db,
jwt_service,
comm,
#[cfg(feature = "license")]
license_info,
soar_engine,
ttl_scheduler,
report_scheduler,
acl_service,
config_service,
dns_filter_service,
notification_service,
playbook_service,
rate_limit_service,
ingress_ebpf,
egress_ebpf,
ingress_program_array,
_ingress_program_array: ingress_program_array,
})
}
@ -265,35 +347,22 @@ impl ServiceFactory {
// Try DRV_MODE first (native XDP, best performance)
match xdp.attach(ifname, XdpFlags::DRV_MODE) {
Ok(_) => {
tracing::info!("XDP attached to {} in native DRV_MODE", ifname);
log!(EbpfLog::XdpAttachedNative(ifname.to_string()));
return Ok("drv".to_string());
}
Err(drv_err) => {
tracing::warn!(
"XDP DRV_MODE failed on {}: {}. Falling back to SKB_MODE.",
ifname, drv_err
);
log!(EbpfLog::XdpDrvModeFailed(ifname.to_string(), drv_err.to_string()));
}
}
// Fallback to SKB_MODE (generic XDP, reduced performance)
match xdp.attach(ifname, XdpFlags::SKB_MODE) {
Ok(_) => {
tracing::warn!(
"XDP attached to {} in generic SKB_MODE (reduced performance). \
For best performance, use a NIC with native XDP support (e.g., virtio-net, Intel i40e/ice).",
ifname
);
log!(EbpfLog::XdpAttachedSkb(ifname.to_string()));
Ok("skb".to_string())
}
Err(skb_err) => {
tracing::error!(
"XDP attach failed on {} with both DRV_MODE and SKB_MODE. \
Ensure the interface exists and supports XDP. \
Supported NICs: virtio-net, Intel i40e/ice/i350, Mellanox mlx5. \
SKB error: {}",
ifname, skb_err
);
log!(EbpfLog::XdpAttachFailed(ifname.to_string(), skb_err.to_string()));
Err(EbpfError::AttachProgramFailed(skb_err).into())
}
}
@ -311,11 +380,11 @@ impl ServiceFactory {
if let Ok(domains) = db.load_dns_domains() {
for domain in &domains {
if let Err(e) = ebpf_services.dns_filter.add_domain(domain) {
tracing::warn!("Failed to restore DNS domain '{}': {}", domain, e);
log!(SystemLog::DnsRestoreFailed(domain.clone(), e.to_string()));
}
}
if !domains.is_empty() {
tracing::info!("Restored {} DNS blacklist domains from database", domains.len());
log!(SystemLog::DnsBlacklistRestored(domains.len()));
}
}
}
@ -324,9 +393,9 @@ impl ServiceFactory {
if let Ok(countries) = db.load_geo_countries()
&& !countries.is_empty() {
if let Err(e) = ebpf_services.geo_block.block_countries(&countries) {
tracing::warn!("Failed to restore geo-blocked countries: {}", e);
log!(SystemLog::GeoRestoreFailed(e.to_string()));
} else {
tracing::info!("Restored {} geo-blocked countries from database", countries.len());
log!(SystemLog::GeoCountriesRestored(countries.len()));
}
}
}
@ -343,11 +412,11 @@ impl ServiceFactory {
_ => Ok(()),
};
if let Err(e) = result {
tracing::warn!("Failed to restore rate limit '{}': {}", key, e);
log!(SystemLog::RateLimitRestoreFailed(key.clone(), e.to_string()));
}
}
if !configs.is_empty() {
tracing::info!("Restored {} rate limit settings from database", configs.len());
log!(SystemLog::RateLimitsRestored(configs.len()));
}
}
}
@ -360,7 +429,7 @@ impl ServiceFactory {
"source" => FlowDirection::Source,
"destination" => FlowDirection::Destination,
other => {
tracing::warn!("Unknown ACL direction '{}', skipping", other);
log!(SystemLog::AclUnknownDirection(other.to_string()));
continue;
}
};
@ -368,7 +437,7 @@ impl ServiceFactory {
"whitelist" => ListType::White,
"blacklist" => ListType::Black,
other => {
tracing::warn!("Unknown ACL list type '{}', skipping", other);
log!(SystemLog::AclUnknownListType(other.to_string()));
continue;
}
};
@ -377,7 +446,7 @@ impl ServiceFactory {
match ip_address.parse::<Ipv4Addr>() {
Ok(addr) => ebpf_services.access_control.add_ipv4_list(dir, lt, SocketAddrV4::new(addr, *port)).await,
Err(e) => {
tracing::warn!("Failed to parse IPv4 address '{}': {}", ip_address, e);
log!(SystemLog::AclIpv4ParseFailed(ip_address.clone(), e.to_string()));
continue;
}
}
@ -386,24 +455,24 @@ impl ServiceFactory {
match ip_address.parse::<Ipv6Addr>() {
Ok(addr) => ebpf_services.access_control.add_ipv6_list(dir, lt, SocketAddrV6::new(addr, *port, 0, 0)).await,
Err(e) => {
tracing::warn!("Failed to parse IPv6 address '{}': {}", ip_address, e);
log!(SystemLog::AclIpv6ParseFailed(ip_address.clone(), e.to_string()));
continue;
}
}
}
other => {
tracing::warn!("Unknown IP version {}, skipping", other);
log!(SystemLog::AclUnknownIpVersion(*other));
continue;
}
};
if let Err(e) = result {
tracing::warn!("Failed to restore ACL rule ({} {} {}:{}): {}", direction, list_type, ip_address, port, e);
log!(SystemLog::AclRuleRestoreFailed(direction.clone(), list_type.clone(), ip_address.clone(), *port, e.to_string()));
} else {
restored += 1;
}
}
if restored > 0 {
tracing::info!("Restored {} ACL rules from database", restored);
log!(SystemLog::AclRulesRestored(restored as usize));
}
}
}

View File

@ -3,7 +3,8 @@ use std::time;
use crate::core::ml::engine::Engine;
use crate::core::ml::flow_tracker::FlowData;
use crate::model::flow_stats::{FlowStatsEntry, FlowSubscription, StatsSummary};
use crate::model::direction::Direction;
use crate::model::flow_stats::{FlowPushPayload, FlowStatsEntry, FlowSubscription, FlowSummary, StatsSummary};
/// Conversion from core::ml::FlowData to model::FlowStatsEntry.
/// Placed here (core layer) to maintain dependency rule: model/ must not import core/.
@ -11,6 +12,7 @@ impl From<&FlowData> for FlowStatsEntry {
fn from(flow: &FlowData) -> Self {
Self {
direction: flow.direction,
ip_version: flow.flow_key.ip_version,
src_ip: flow.flow_key.src_ip_string(),
dst_ip: flow.flow_key.dst_ip_string(),
src_port: flow.flow_key.src_port,
@ -81,6 +83,43 @@ impl FlowStatistics {
})
}
pub fn get_flow_payload(&self, sub: &FlowSubscription) -> FlowPushPayload {
let flows = self.get_filtered_flows(sub);
let window_secs = sub.window_secs.unwrap_or(60).max(1);
let mut ingress_bytes_v4: u64 = 0;
let mut egress_bytes_v4: u64 = 0;
let mut ingress_bytes_v6: u64 = 0;
let mut egress_bytes_v6: u64 = 0;
for f in &flows {
let bytes = f.fwd_bytes + f.bwd_bytes;
match (f.direction, f.ip_version) {
(Direction::Ingress, 6) => ingress_bytes_v6 += bytes,
(Direction::Ingress, _) => ingress_bytes_v4 += bytes,
(Direction::Egress, 6) => egress_bytes_v6 += bytes,
(Direction::Egress, _) => egress_bytes_v4 += bytes,
}
}
let now_ms = time::SystemTime::now()
.duration_since(time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
let summary = FlowSummary {
ingress_bps_v4: ingress_bytes_v4 / window_secs,
egress_bps_v4: egress_bytes_v4 / window_secs,
ingress_bps_v6: ingress_bytes_v6 / window_secs,
egress_bps_v6: egress_bytes_v6 / window_secs,
total_flows: flows.len(),
window_secs,
timestamp_ms: now_ms,
};
FlowPushPayload { summary, flows }
}
pub fn get_summary(&self) -> StatsSummary {
let flows = self.get_all_flows();
let total_flows = flows.len();

View File

@ -1,86 +1,6 @@
use crate::interface::communication::command::Command;
use crate::interface::communication::message::Message;
// ── ACL Commands ─────────────────────────────────────────────────────
pub struct AddAclRuleCommand {
pub ip_version: u8,
pub direction: String,
pub list_type: String,
pub ip_address: String,
pub port: u16,
}
impl Message for AddAclRuleCommand {
type Response = ();
}
impl Command for AddAclRuleCommand {}
pub struct RemoveAclRuleCommand {
pub ip_version: u8,
pub direction: String,
pub list_type: String,
pub ip_address: String,
pub port: u16,
}
impl Message for RemoveAclRuleCommand {
type Response = ();
}
impl Command for RemoveAclRuleCommand {}
// ── Geo Commands ─────────────────────────────────────────────────────
pub struct BlockGeoCountriesCommand {
pub country_codes: Vec<String>,
}
impl Message for BlockGeoCountriesCommand {
type Response = ();
}
impl Command for BlockGeoCountriesCommand {}
pub struct UnblockGeoCountriesCommand {
pub country_codes: Vec<String>,
}
impl Message for UnblockGeoCountriesCommand {
type Response = ();
}
impl Command for UnblockGeoCountriesCommand {}
// ── DNS Commands ─────────────────────────────────────────────────────
pub struct AddDnsDomainCommand {
pub domain: String,
}
impl Message for AddDnsDomainCommand {
type Response = ();
}
impl Command for AddDnsDomainCommand {}
pub struct RemoveDnsDomainCommand {
pub domain: String,
}
impl Message for RemoveDnsDomainCommand {
type Response = ();
}
impl Command for RemoveDnsDomainCommand {}
// ── Rate Limit Commands ──────────────────────────────────────────────
pub struct SetRateLimitCommand {
pub key: String,
pub value: u64,
}
impl Message for SetRateLimitCommand {
type Response = ();
}
impl Command for SetRateLimitCommand {}
// ── System Commands ──────────────────────────────────────────────────
pub struct ChangeEnforceModeCommand {
@ -91,26 +11,3 @@ impl Message for ChangeEnforceModeCommand {
type Response = ();
}
impl Command for ChangeEnforceModeCommand {}
// ── Auth Commands ────────────────────────────────────────────────────
pub struct ChangePasswordCommand {
pub user_id: i64,
pub new_password_hash: String,
}
impl Message for ChangePasswordCommand {
type Response = ();
}
impl Command for ChangePasswordCommand {}
pub struct RegisterUserCommand {
pub username: String,
pub password_hash: String,
pub role: String,
}
impl Message for RegisterUserCommand {
type Response = ();
}
impl Command for RegisterUserCommand {}

View File

@ -1,83 +1,17 @@
use crate::interface::communication::event::Event;
use crate::model::direction::Direction;
// ── ML Events ────────────────────────────────────────────────────────
/// Fired when the ML engine detects a potential threat.
/// Consumed by the SOAR engine to trigger automated responses.
#[derive(Debug, Clone)]
pub struct ThreatDetectedEvent {
pub flow_key: String,
pub direction: Direction,
pub attack_type: String,
pub confidence: f32,
pub ae_score: f32,
/// Source IP address (e.g. "192.168.1.100")
pub source_ip: String,
/// Destination IP address (e.g. "10.0.0.1")
pub dest_ip: String,
}
impl Event for ThreatDetectedEvent {}
/// Fired after each ML inference tick with summary stats.
#[derive(Debug, Clone)]
pub struct InferenceCompletedEvent {
pub total_flows: usize,
pub malicious_flows: usize,
pub benign_flows: usize,
pub elapsed_ms: u32,
}
impl Event for InferenceCompletedEvent {}
// ── System Events ────────────────────────────────────────────────────
/// Fired when enforce mode changes (monitor ↔ enforce).
#[derive(Debug, Clone)]
pub struct EnforceModeChangedEvent {
pub old_mode: String,
pub new_mode: String,
}
impl Event for EnforceModeChangedEvent {}
/// Fired when XDP attachment completes (or falls back).
#[derive(Debug, Clone)]
pub struct XdpAttachedEvent {
pub interface: String,
pub mode: String, // "drv" or "skb"
}
impl Event for XdpAttachedEvent {}
// ── ACL Events ───────────────────────────────────────────────────────
/// Fired when an ACL rule is added or removed.
#[derive(Debug, Clone)]
pub struct AclRuleChangedEvent {
pub action: String, // "added" or "removed"
pub ip_version: u8,
pub direction: String,
pub list_type: String,
pub ip_address: String,
pub port: u16,
}
impl Event for AclRuleChangedEvent {}
// ── Auth Events ──────────────────────────────────────────────────────
/// Fired when a login attempt fails (for auditing).
#[derive(Debug, Clone)]
pub struct LoginFailedEvent {
pub username: String,
pub failure_count: u32,
pub locked: bool,
}
impl Event for LoginFailedEvent {}
/// Fired when a user changes their password.
#[derive(Debug, Clone)]
pub struct PasswordChangedEvent {
pub user_id: i64,
pub username: String,
}
impl Event for PasswordChangedEvent {}

View File

@ -1,6 +1,5 @@
use crate::interface::communication::message::Message;
use crate::interface::communication::query::Query;
use crate::model::health::{SystemHealthMetrics, SystemHealthStatus};
// ── System Queries ───────────────────────────────────────────────────
@ -10,79 +9,3 @@ impl Message for GetEnforceModeQuery {
type Response = String;
}
impl Query for GetEnforceModeQuery {}
pub struct GetXdpModeQuery;
impl Message for GetXdpModeQuery {
type Response = XdpModeResponse;
}
impl Query for GetXdpModeQuery {}
#[derive(Debug, Clone)]
pub struct XdpModeResponse {
pub ingress_mode: String,
pub egress_mode: String,
}
// ── Health Queries ───────────────────────────────────────────────────
pub struct GetHealthMetricsQuery;
impl Message for GetHealthMetricsQuery {
type Response = SystemHealthMetrics;
}
impl Query for GetHealthMetricsQuery {}
pub struct GetHealthStatusQuery;
impl Message for GetHealthStatusQuery {
type Response = SystemHealthStatus;
}
impl Query for GetHealthStatusQuery {}
// ── ACL Queries ──────────────────────────────────────────────────────
pub struct GetAclRulesQuery;
impl Message for GetAclRulesQuery {
type Response = Vec<(u8, String, String, String, u16)>;
}
impl Query for GetAclRulesQuery {}
// ── Settings Queries ─────────────────────────────────────────────────
pub struct GetSettingQuery {
pub key: String,
}
impl Message for GetSettingQuery {
type Response = Option<String>;
}
impl Query for GetSettingQuery {}
// ── Rate Limit Queries ───────────────────────────────────────────────
pub struct GetRateLimitConfigQuery;
impl Message for GetRateLimitConfigQuery {
type Response = Vec<(String, u64)>;
}
impl Query for GetRateLimitConfigQuery {}
// ── DNS Queries ──────────────────────────────────────────────────────
pub struct GetDnsDomainsQuery;
impl Message for GetDnsDomainsQuery {
type Response = Vec<String>;
}
impl Query for GetDnsDomainsQuery {}
// ── Geo Queries ──────────────────────────────────────────────────────
pub struct GetGeoBlockedCountriesQuery;
impl Message for GetGeoBlockedCountriesQuery {
type Response = Vec<String>;
}
impl Query for GetGeoBlockedCountriesQuery {}

View File

@ -0,0 +1,16 @@
use async_trait::async_trait;
use crate::model::error::Error;
/// Port for blocking/unblocking IP addresses in the network data plane.
/// Adapters: EbpfAccessControlAdapter (wraps eBPF AccessControl)
#[async_trait]
pub trait AccessControlPort: Send + Sync {
/// Block an IP address (adds to source blacklist in the data plane).
/// Accepts both IPv4 ("1.2.3.4") and IPv6 ("::1") strings.
async fn block_ip(&self, ip: &str) -> Result<(), Error>;
/// Unblock an IP address (removes from source blacklist in the data plane).
/// Accepts both IPv4 and IPv6 strings. No-op if IP was not blocked.
async fn unblock_ip(&self, ip: &str) -> Result<(), Error>;
}

View File

@ -1,20 +0,0 @@
use crate::model::error::Error;
/// Claims extracted from a validated JWT token.
#[derive(Debug, Clone)]
pub struct TokenClaims {
pub sub: i64,
pub username: String,
pub role: String,
pub permissions: Vec<String>,
pub exp: usize,
}
/// Port for authentication operations.
/// Adapters: JWT (current), could be OAuth, etc.
pub trait AuthPort: Send + Sync {
fn create_token(&self, user_id: i64, username: &str, role: &str, permissions: Vec<String>) -> Result<String, Error>;
fn validate_token(&self, token: &str) -> Result<TokenClaims, Error>;
fn hash_password(&self, password: &str) -> Result<String, Error>;
fn verify_password(&self, password: &str, hash: &str) -> Result<bool, Error>;
}

View File

@ -1,10 +0,0 @@
use crate::model::health::{SystemHealthMetrics, SystemHealthStatus};
use async_trait::async_trait;
/// Port for system health monitoring.
/// Adapters: sysinfo-based (current)
#[async_trait]
pub trait HealthPort: Send + Sync {
async fn get_metrics(&self) -> SystemHealthMetrics;
async fn is_healthy(&self) -> SystemHealthStatus;
}

View File

@ -1,4 +1,3 @@
pub mod repository;
pub mod auth;
pub mod access_control;
pub mod notification;
pub mod health;
pub mod repository;

View File

@ -1,9 +1,22 @@
use crate::model::error::Error;
use async_trait::async_trait;
/// Port for outbound notifications (alerts, reports).
/// Adapters: WebSocket (alerts), SMTP (weekly report)
#[async_trait]
pub trait NotificationPort: Send + Sync {
async fn send_weekly_report(&self) -> Result<(), Error>;
/// Alert notification data sent by SOAR engine.
#[derive(Debug, Clone)]
pub struct AlertPayload {
pub source_ip: String,
pub dest_ip: String,
pub country: Option<String>,
pub threat_type: String,
pub confidence: f32,
pub action_description: String,
pub timestamp: String,
}
/// Port for sending instant alert notifications (Telegram, future channels).
/// Adapters: TelegramAdapter
#[async_trait]
pub trait AlertNotifier: Send + Sync {
async fn send_alert(&self, payload: &AlertPayload) -> Result<(), Error>;
async fn send_test_message(&self) -> Result<(), Error>;
}

View File

@ -9,11 +9,17 @@ pub type UserTuple = (i64, String, String, String, bool);
/// Type alias for user list items: (id, username, role, force_password_change, created_at)
pub type UserListItem = (i64, String, String, bool, String);
/// Type alias for user-with-groups: (id, username, role, force_password_change, created_at, groups: Vec<(group_id, group_name)>)
pub type UserWithGroups = (i64, String, String, bool, String, Vec<(i64, String)>);
/// Type alias for user group tuples: (id, name, description, permissions, created_at)
pub type UserGroupTuple = (i64, String, String, String, String);
/// Port for persistent storage operations.
/// Adapters: SQLite (current), could be Postgres, etc.
/// All methods are used via the concrete Database adapter; the trait
/// defines the hexagonal-architecture boundary.
#[allow(dead_code)]
pub trait RepositoryPort: Send + Sync {
// --- ACL ---
fn insert_acl_rule(&self, ip_version: u8, direction: &str, list_type: &str, ip_address: &str, port: u16) -> Result<(), Error>;
@ -46,6 +52,7 @@ pub trait RepositoryPort: Send + Sync {
// --- User Management ---
fn list_users(&self) -> Result<Vec<UserListItem>, Error>;
fn list_users_with_groups(&self) -> Result<Vec<UserWithGroups>, Error>;
fn delete_user(&self, user_id: i64) -> Result<bool, Error>;
fn update_user_role(&self, user_id: i64, role: &str) -> Result<(), Error>;
fn reset_user_password(&self, user_id: i64, password_hash: &str) -> Result<(), Error>;
@ -64,6 +71,7 @@ pub trait RepositoryPort: Send + Sync {
fn get_user_permissions(&self, user_id: i64) -> Result<Vec<String>, Error>;
fn cleanup_user_memberships(&self, user_id: i64) -> Result<(), Error>;
fn get_group_member_ids(&self, group_id: i64) -> Result<Vec<i64>, Error>;
fn get_group_members(&self, group_id: i64) -> Result<Vec<(i64, String)>, Error>;
// --- Login Rate Limiting ---
fn record_login_failure(&self, username: &str) -> Result<(u32, Option<u64>), Error>;

View File

@ -5,12 +5,103 @@ mod interface;
mod model;
mod utils;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use macros::log;
use crate::adapter::persistence::Database;
use crate::core::auth::jwt::JwtService;
use crate::core::auth::password;
use crate::core::system::System;
use crate::model::error::Error;
use crate::model::error::system::SystemError;
use crate::model::log::system::SystemLog;
use crate::utils::logging::Logging;
/// Two-phase startup:
///
/// ```text
/// ┌────────────┐ ┌──────────────────┐ ┌──────────────────┐
/// │ Create DB │────→│ Setup complete? │─no─→│ Setup HTTP server│
/// │ (fast) │ │ │ │ (instant start) │
/// └────────────┘ └────────┬─────────┘ └────────┬─────────┘
/// │yes │done
/// ▼ ▼
/// ┌──────────────────┐ ┌──────────────────┐
/// │ Full build │←────│ Stop setup server│
/// │ (eBPF, ML, SOAR) │ │ + reload config │
/// └────────┬─────────┘ └──────────────────┘
/// ▼
/// ┌──────────────────┐
/// │ Full HTTP server │
/// └──────────────────┘
/// ```
#[actix_web::main]
async fn main() -> Result<(), Error> {
let mut system = System::new().await?;
Logging::initialize()?;
// Phase 1: Create DB (fast — needed for setup check and setup server)
let db_path = std::env::var("NETGUARDIA_DB_PATH")
.unwrap_or_else(|_| "net-guardia.db".to_string());
let db = Arc::new(Database::new(&db_path)?);
// Seed default admin user if no users exist
if db.user_count().unwrap_or(0) == 0 {
let hash = password::hash_password("admin")?;
let admin_user_id = db.insert_user("admin", &hash, "admin", false)?;
if let Ok(groups) = db.list_user_groups()
&& let Some((group_id, _, _, _, _)) = groups.into_iter().find(|(_, name, _, _, _)| name == "Administrator")
&& let Err(e) = db.set_user_groups(admin_user_id, &[group_id])
{
log!(SystemError::SetUserGroupsFailed(e));
}
log!(SystemLog::DefaultAdminCreated);
}
let setup_complete = db.get_setting("setup_complete")?
.map(|v| v == "true")
.unwrap_or(false);
// Phase 2: If setup not complete, run lightweight setup server immediately
if !setup_complete {
log!(SystemLog::SetupMode);
let jwt_service = Arc::new(JwtService::new(db.as_ref(), 24)?);
let setup_flag = Arc::new(AtomicBool::new(false));
// Start setup server — returns handle for graceful shutdown
let handle = infrastructure::http_server::start_setup_server(
db.clone(), jwt_service, setup_flag.clone(), 8080,
)?;
// Wait for setup completion or shutdown signal
let flag = setup_flag.clone();
let setup_done = async move {
loop {
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
if flag.load(Ordering::SeqCst) { return; }
}
};
tokio::select! {
_ = setup_done => {
log!(SystemLog::SetupCompleted);
}
_ = tokio::signal::ctrl_c() => {
log!(SystemLog::ShutdownDuringSetup);
handle.stop(true).await;
return Ok(());
}
}
// Stop setup server to free the port for full server
handle.stop(true).await;
log!(SystemLog::SetupServerStopped);
}
// Phase 3: Full system build and run (setup is complete, DB has config)
let mut system = System::new(db).await?;
system.run().await?;
system.terminate().await?;
Ok(())

View File

@ -0,0 +1,2 @@
pub mod ip_address;
pub mod list_type;

View File

@ -14,7 +14,6 @@ pub struct EngineConfig {
pub batch_size: usize,
pub inference_interval_secs: u64,
pub aggregator_window_secs: u64,
pub flow_timeout_us: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -113,8 +112,6 @@ pub struct InferenceStats {
pub total_flows: usize,
pub malicious_flows: usize,
pub benign_flows: usize,
#[allow(dead_code)]
pub inference_time_us: u64,
pub flows_per_second: f32,
}
@ -134,7 +131,6 @@ impl InferenceStats {
total_flows: total,
malicious_flows: malicious,
benign_flows: benign,
inference_time_us: elapsed_us,
flows_per_second: fps,
}
}

View File

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

View File

@ -21,5 +21,14 @@ traceable! {
#[no_source]
#[error("Missing authorization header")]
MissingAuthHeader => tracing::Level::WARN,
#[error("Failed to record login failure: {err}")]
LoginFailureTrackingError => tracing::Level::ERROR,
#[error("Failed to clear login failures: {err}")]
LoginClearError => tracing::Level::ERROR,
#[error("Failed to assign user to group: {err}")]
GroupAssignmentFailed => tracing::Level::ERROR,
}
}

View File

@ -53,6 +53,10 @@ traceable! {
#[error("Fill queue initialization failed")]
FillQueueInitFailed => tracing::Level::ERROR,
#[no_source]
#[error("Invalid IP address: {ip}")]
InvalidIpAddress { ip: String } => tracing::Level::ERROR,
#[no_source]
#[error("Unknown eBPF error")]
UnknownError => tracing::Level::ERROR,
@ -68,5 +72,8 @@ traceable! {
#[error("TX queue processing failed")]
TXQueueError => tracing::Level::ERROR,
#[error("eBPF rollback failed during ACL update: {err}")]
RollbackFailed => tracing::Level::ERROR,
}
}

View File

@ -1,19 +0,0 @@
use macros::traceable;
traceable! {
LicenseError {
#[no_source]
#[error("License file not found: {path}")]
FileNotFound { path: String } => tracing::Level::WARN,
#[error("Invalid license signature")]
InvalidSignature => tracing::Level::ERROR,
#[error("License has expired")]
Expired => tracing::Level::WARN,
#[no_source]
#[error("License validation failed: {reason}")]
ValidationFailed { reason: String } => tracing::Level::ERROR,
}
}

View File

@ -0,0 +1,25 @@
use macros::traceable;
traceable! {
McpError {
#[no_source]
#[error("MCP tool not found: {tool_name}")]
ToolNotFound { tool_name: String } => tracing::Level::WARN,
#[no_source]
#[error("MCP parameter validation failed: {reason}")]
InvalidParams { reason: String } => tracing::Level::WARN,
#[no_source]
#[error("MCP permission denied: key has '{key_level}' but tool requires '{required_level}'")]
PermissionDenied { key_level: String, required_level: String } => tracing::Level::WARN,
#[no_source]
#[error("MCP API key invalid or revoked")]
InvalidApiKey => tracing::Level::WARN,
#[no_source]
#[error("MCP proxy error: {reason}")]
ProxyError { reason: String } => tracing::Level::ERROR,
}
}

View File

@ -42,5 +42,9 @@ traceable! {
#[no_source]
#[error("Event type not registered with communication manager")]
TypeNotRegistered => tracing::Level::ERROR,
#[no_source]
#[error("Validation error: {message}")]
ValidationError { message: String } => tracing::Level::WARN,
}
}

View File

@ -19,5 +19,8 @@ traceable! {
#[no_source]
#[error("Failed to parse inference configuration: {reason}")]
ConfigParseFailed { reason: String } => tracing::Level::ERROR,
#[error("Failed to flush traffic log: {err}")]
TrafficLogFlushFailed => tracing::Level::ERROR,
}
}

View File

@ -3,10 +3,11 @@ pub mod database;
pub mod ebpf;
pub mod http;
pub mod io;
#[cfg(feature = "license")]
pub mod license;
pub mod mcp;
pub mod misc;
pub mod ml;
pub mod notification;
pub mod soar;
pub mod system;
use serde::{Deserialize, Serialize};
@ -16,10 +17,11 @@ use crate::model::error::database::DatabaseError;
use crate::model::error::ebpf::EbpfError;
use crate::model::error::http::HttpError;
use crate::model::error::io::IOError;
#[cfg(feature = "license")]
use crate::model::error::license::LicenseError;
use crate::model::error::mcp::McpError;
use crate::model::error::misc::MiscError;
use crate::model::error::ml::MLError;
use crate::model::error::notification::NotificationError;
use crate::model::error::soar::SoarError;
use crate::model::error::system::SystemError;
#[derive(Clone, Debug, thiserror::Error, Serialize, Deserialize)]
@ -36,12 +38,15 @@ pub enum Error {
ML(MLError),
#[error("{0}")]
IO(IOError),
#[cfg(feature = "license")]
#[error("{0}")]
License(LicenseError),
Mcp(McpError),
#[error("{0}")]
Misc(MiscError),
#[error("{0}")]
Notification(NotificationError),
#[error("{0}")]
Soar(SoarError),
#[error("{0}")]
System(SystemError),
}
@ -75,13 +80,6 @@ impl From<IOError> for Error {
}
}
#[cfg(feature = "license")]
impl From<LicenseError> for Error {
fn from(error: LicenseError) -> Self {
Self::License(error)
}
}
impl From<MiscError> for Error {
fn from(error: MiscError) -> Self {
Self::Misc(error)
@ -99,3 +97,21 @@ impl From<MLError> for Error {
Self::ML(error)
}
}
impl From<NotificationError> for Error {
fn from(error: NotificationError) -> Self {
Self::Notification(error)
}
}
impl From<SoarError> for Error {
fn from(error: SoarError) -> Self {
Self::Soar(error)
}
}
impl From<McpError> for Error {
fn from(error: McpError) -> Self {
Self::Mcp(error)
}
}

View File

@ -0,0 +1,49 @@
use macros::traceable;
traceable! {
NotificationError {
#[no_source]
#[error("SMTP connection failed: {reason}")]
SmtpConnectionFailed { reason: String } => tracing::Level::ERROR,
#[no_source]
#[error("SMTP authentication failed: {reason}")]
SmtpAuthFailed { reason: String } => tracing::Level::ERROR,
#[no_source]
#[error("Failed to send email: {reason}")]
SmtpSendFailed { reason: String } => tracing::Level::ERROR,
#[no_source]
#[error("Invalid email address: {reason}")]
InvalidAddress { reason: String } => tracing::Level::WARN,
#[no_source]
#[error("Failed to build email message: {reason}")]
MessageBuildFailed { reason: String } => tracing::Level::ERROR,
#[no_source]
#[error("Telegram API error: {reason}")]
TelegramApiError { reason: String } => tracing::Level::ERROR,
#[no_source]
#[error("Telegram authentication failed (invalid bot token)")]
TelegramAuthError => tracing::Level::ERROR,
#[no_source]
#[error("Telegram chat not found: {chat_id}")]
TelegramChatNotFound { chat_id: String } => tracing::Level::ERROR,
#[no_source]
#[error("Telegram rate limited, retry after {retry_after_secs}s")]
TelegramRateLimited { retry_after_secs: u64 } => tracing::Level::WARN,
#[no_source]
#[error("Notification send timed out")]
Timeout => tracing::Level::ERROR,
#[no_source]
#[error("Notification not configured: {channel}")]
NotConfigured { channel: String } => tracing::Level::WARN,
}
}

View File

@ -0,0 +1,36 @@
use macros::traceable;
traceable! {
SoarError {
#[no_source]
#[error("Playbook not found: id={playbook_id}")]
PlaybookNotFound { playbook_id: i64 } => tracing::Level::WARN,
#[no_source]
#[error("Auto-block cap reached (max {max_cap} concurrent blocks)")]
CapReached { max_cap: u32 } => tracing::Level::WARN,
#[no_source]
#[error("Cooldown active for playbook {playbook_id} and IP {source_ip}")]
CooldownActive { playbook_id: i64, source_ip: String } => tracing::Level::DEBUG,
#[no_source]
#[error("IP {ip} is in admin whitelist, skipping auto-block")]
AdminWhitelisted { ip: String } => tracing::Level::INFO,
#[no_source]
#[error("Invalid TTL: {ttl_secs}s exceeds maximum of {max_secs}s")]
InvalidTtl { ttl_secs: u64, max_secs: u64 } => tracing::Level::WARN,
#[no_source]
#[error("SOAR action failed: {action_type} — {reason}")]
ActionFailed { action_type: String, reason: String } => tracing::Level::ERROR,
#[no_source]
#[error("Duplicate block rule for IP {ip}")]
DuplicateBlockRule { ip: String } => tracing::Level::DEBUG,
#[error("Failed to clean up ACL rule after unblock: {err}")]
AclCleanupFailed => tracing::Level::WARN,
}
}

View File

@ -25,5 +25,26 @@ traceable! {
#[error("Unexpected error")]
UnexpectedError => tracing::Level::ERROR,
#[error("Failed to reload config after setup")]
ConfigReloadFailed => tracing::Level::ERROR,
#[error("HTTP server error")]
HttpServerError => tracing::Level::ERROR,
#[error("Failed to set user groups")]
SetUserGroupsFailed => tracing::Level::WARN,
#[error("Failed to bridge ML alert to SOAR")]
MlSoarBridgeFailed => tracing::Level::WARN,
#[error("Failed to store XDP mode")]
XdpModeStoreFailed => tracing::Level::WARN,
#[error("Failed to update admin password during setup: {err}")]
SetupPasswordUpdateFailed => tracing::Level::ERROR,
#[error("Failed to mark setup as complete: {err}")]
SetupCompleteFlagFailed => tracing::Level::ERROR,
}
}

View File

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

View File

@ -1,26 +0,0 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LicensePayload {
pub ingress_mac: String,
pub egress_mac: String,
pub expires: String,
pub features: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LicenseInfo {
pub payload: Option<LicensePayload>,
pub valid: bool,
pub days_remaining: i64,
}
impl LicenseInfo {
pub fn unlicensed() -> Self {
Self {
payload: None,
valid: false,
days_remaining: 0,
}
}
}

View File

@ -53,5 +53,17 @@ loggable! {
#[error("Invalid packet length exceeds buffer")]
InvalidPacketLength => tracing::Level::WARN,
#[error("XDP attached to {interface} in native DRV_MODE")]
XdpAttachedNative { interface: String } => tracing::Level::INFO,
#[error("XDP DRV_MODE failed on {interface}: {error}. Falling back to SKB_MODE.")]
XdpDrvModeFailed { interface: String, error: String } => tracing::Level::WARN,
#[error("XDP attached to {interface} in generic SKB_MODE (reduced performance). For best performance, use a NIC with native XDP support (e.g., virtio-net, Intel i40e/ice).")]
XdpAttachedSkb { interface: String } => tracing::Level::WARN,
#[error("XDP attach failed on {interface} with both DRV_MODE and SKB_MODE. Ensure the interface exists and supports XDP. Supported NICs: virtio-net, Intel i40e/ice/i350, Mellanox mlx5. SKB error: {error}")]
XdpAttachFailed { interface: String, error: String } => tracing::Level::ERROR,
}
}

View File

@ -5,5 +5,11 @@ loggable! {
HttpLog {
#[error("Health WebSocket lagged, skipped {skipped} messages")]
WebSocketLagged { skipped: u64 } => tracing::Level::WARN,
#[error("Failed to bind setup server to port {port}: {error}. Falling back to port {fallback_port}.")]
SetupBindFallback { port: u16, error: String, fallback_port: u16 } => tracing::Level::WARN,
#[error("Setup HTTP server error: {error}")]
SetupServerError { error: String } => tracing::Level::ERROR,
}
}

View File

@ -1,6 +1,7 @@
pub mod ebpf;
pub mod http;
pub mod ml;
pub mod soar;
pub mod system;
pub mod misc;
pub mod health;

View File

@ -0,0 +1,78 @@
use macros::loggable;
use tracing;
loggable! {
SoarLog {
#[error("SOAR engine started, listening for threat events")]
EngineStarted => tracing::Level::INFO,
#[error("SOAR event channel closed, shutting down")]
ChannelClosed => tracing::Level::INFO,
#[error("SOAR event receiver lagged by {count} events")]
ReceiverLagged { count: u64 } => tracing::Level::WARN,
#[error("SOAR cache loaded: {playbooks} playbooks, {whitelisted} whitelisted IPs, {active_blocks} active blocks")]
CacheLoaded { playbooks: usize, whitelisted: usize, active_blocks: u32 } => tracing::Level::INFO,
#[error("Playbook '{name}' executed for IP {source_ip} (threat: {attack_type})")]
PlaybookExecuted { name: String, source_ip: String, attack_type: String } => tracing::Level::INFO,
#[error("Playbook '{name}' execution error: {error}")]
PlaybookError { name: String, error: String } => tracing::Level::WARN,
#[error("No matching playbook for event type '{attack_type}', executing fallback")]
FallbackTriggered { attack_type: String } => tracing::Level::DEBUG,
#[error("Cooldown active for playbook '{name}' and IP {source_ip}")]
CooldownActive { name: String, source_ip: String } => tracing::Level::DEBUG,
#[error("IP {ip} is in admin whitelist, skipping playbook '{name}'")]
WhitelistSkipped { ip: String, name: String } => tracing::Level::INFO,
#[error("Blocked IP {ip} for {ttl_secs}s")]
IpBlocked { ip: String, ttl_secs: u64 } => tracing::Level::INFO,
#[error("Auto-block cap reached ({current}/{max}), skipping block for IP {ip}")]
CapReached { current: u32, max: u32, ip: String } => tracing::Level::WARN,
#[error("Telegram notification sent")]
TelegramSent => tracing::Level::INFO,
#[error("Telegram not configured, skipping send_telegram action")]
TelegramNotConfigured => tracing::Level::DEBUG,
#[error("Logged at level '{level}'")]
ActionLogged { level: String } => tracing::Level::INFO,
#[error("SOAR fallback executed for IP {ip} (no matching playbook)")]
FallbackExecuted { ip: String } => tracing::Level::WARN,
#[error("SOAR recovery: re-applied {count} active block rules to eBPF")]
RecoveryComplete { count: usize } => tracing::Level::INFO,
#[error("Failed to recover block for IP {ip} during startup: {error}")]
RecoveryFailed { ip: String, error: String } => tracing::Level::WARN,
#[error("SOAR adjust_rate_limit: factor={factor}, ttl={ttl_secs}s, trigger=IP {ip} ({attack_type}). {details}")]
RateLimitAdjusted { factor: String, ttl_secs: u64, ip: String, attack_type: String, details: String } => tracing::Level::INFO,
#[error("SOAR rate limit adjustment expired — original rates restored")]
RateLimitRestored => tracing::Level::INFO,
#[error("SOAR rate limit restoration partially failed: {errors}")]
RateLimitRestoreFailed { errors: String } => tracing::Level::ERROR,
#[error("[monitor] Action '{action_type}' skipped for IP {source_ip} — enforce mode is not active")]
MonitorModeSkipped { action_type: String, source_ip: String } => tracing::Level::INFO,
#[error("SOAR event handling failed: {error}")]
EventHandlingFailed { error: String } => tracing::Level::ERROR,
#[error("TTL sweep: {removed} blocks removed, {skipped} kept (manual ACL conflict)")]
TtlSweepComplete { removed: u32, skipped: u32 } => tracing::Level::INFO,
#[error("SOAR log action [{level}]: threat from {source_ip} — {attack_type} (confidence: {confidence})")]
ActionLog { level: String, source_ip: String, attack_type: String, confidence: String } => tracing::Level::WARN,
}
}

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