mirror of
https://github.com/DaLaw2/NetGuardia.git
synced 2026-08-24 14:10:28 +09:00
feat: GeoIP replacement, Vue 3 frontend, setup wizard, DDD architecture, UI/UX fixes, i18n, security hardening (#17)
This commit is contained in:
parent
d47d08d79e
commit
12e0ff70cc
1
.gitattributes
vendored
1
.gitattributes
vendored
@ -1 +1,2 @@
|
||||
* text=auto eol=lf
|
||||
*.mmdb filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@ -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
837
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -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
16
cli/Cargo.toml
Normal 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
376
cli/src/main.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
|
||||
@ -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"]
|
||||
@ -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))]
|
||||
|
||||
@ -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
17
mcp-server/Cargo.toml
Normal 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
280
mcp-server/src/main.rs
Normal 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
|
||||
@ -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 }
|
||||
|
||||
@ -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?;
|
||||
|
||||
64
net-guardia/src/adapter/access_control_adapter.rs
Normal file
64
net-guardia/src/adapter/access_control_adapter.rs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
|
||||
@ -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(®.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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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 {
|
||||
|
||||
88
net-guardia/src/adapter/http/mcp_keys.rs
Normal file
88
net-guardia/src/adapter/http/mcp_keys.rs
Normal 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()})),
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
60
net-guardia/src/adapter/http/notification.rs
Normal file
60
net-guardia/src/adapter/http/notification.rs
Normal 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()})),
|
||||
}
|
||||
}
|
||||
@ -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"}))
|
||||
}
|
||||
|
||||
53
net-guardia/src/adapter/http/report.rs
Normal file
53
net-guardia/src/adapter/http/report.rs
Normal 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()})),
|
||||
}
|
||||
}
|
||||
251
net-guardia/src/adapter/http/setup.rs
Normal file
251
net-guardia/src/adapter/http/setup.rs
Normal 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(())
|
||||
}
|
||||
198
net-guardia/src/adapter/http/soar.rs
Normal file
198
net-guardia/src/adapter/http/soar.rs
Normal 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()})),
|
||||
}
|
||||
}
|
||||
@ -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()})),
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
pub mod access_control_adapter;
|
||||
pub mod http;
|
||||
pub mod persistence;
|
||||
pub mod telegram;
|
||||
pub mod websocket;
|
||||
|
||||
@ -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) }
|
||||
|
||||
213
net-guardia/src/adapter/telegram/mod.rs
Normal file
213
net-guardia/src/adapter/telegram/mod.rs
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
158
net-guardia/src/core/acl_service.rs
Normal file
158
net-guardia/src/core/acl_service.rs
Normal 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",
|
||||
}
|
||||
}
|
||||
41
net-guardia/src/core/auth/extractor.rs
Normal file
41
net-guardia/src/core/auth/extractor.rs
Normal 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"}),
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
pub mod extractor;
|
||||
pub mod jwt;
|
||||
pub mod middleware;
|
||||
pub mod password;
|
||||
pub mod setup_guard;
|
||||
|
||||
98
net-guardia/src/core/auth/setup_guard.rs
Normal file
98
net-guardia/src/core/auth/setup_guard.rs
Normal 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())
|
||||
})
|
||||
}
|
||||
}
|
||||
147
net-guardia/src/core/config_service.rs
Normal file
147
net-guardia/src/core/config_service.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
54
net-guardia/src/core/dns_filter_service.rs
Normal file
54
net-guardia/src/core/dns_filter_service.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
});
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -1,3 +0,0 @@
|
||||
pub mod validator;
|
||||
|
||||
pub use crate::model::license::LicenseInfo;
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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(),
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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 })
|
||||
|
||||
@ -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;
|
||||
|
||||
84
net-guardia/src/core/notification_service.rs
Normal file
84
net-guardia/src/core/notification_service.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
243
net-guardia/src/core/playbook_service.rs
Normal file
243
net-guardia/src/core/playbook_service.rs
Normal 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(¶ms_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);
|
||||
}
|
||||
}
|
||||
56
net-guardia/src/core/rate_limit_service.rs
Normal file
56
net-guardia/src/core/rate_limit_service.rs
Normal 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>,
|
||||
}
|
||||
161
net-guardia/src/core/report/data.rs
Normal file
161
net-guardia/src/core/report/data.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
209
net-guardia/src/core/report/engine.rs
Normal file
209
net-guardia/src/core/report/engine.rs
Normal 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} — 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 — 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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
}
|
||||
|
||||
/// 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()
|
||||
})
|
||||
}
|
||||
2
net-guardia/src/core/report/mod.rs
Normal file
2
net-guardia/src/core/report/mod.rs
Normal file
@ -0,0 +1,2 @@
|
||||
pub mod engine;
|
||||
pub mod data;
|
||||
988
net-guardia/src/core/soar/engine.rs
Normal file
988
net-guardia/src/core/soar/engine.rs
Normal 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(¶ms_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);
|
||||
}
|
||||
}
|
||||
2
net-guardia/src/core/soar/mod.rs
Normal file
2
net-guardia/src/core/soar/mod.rs
Normal file
@ -0,0 +1,2 @@
|
||||
pub mod engine;
|
||||
pub mod scheduler;
|
||||
97
net-guardia/src/core/soar/scheduler.rs
Normal file
97
net-guardia/src/core/soar/scheduler.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
187
net-guardia/src/core/stats_aggregator.rs
Normal file
187
net-guardia/src/core/stats_aggregator.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
@ -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}))
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -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 {}
|
||||
|
||||
16
net-guardia/src/interface/port/access_control.rs
Normal file
16
net-guardia/src/interface/port/access_control.rs
Normal 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>;
|
||||
}
|
||||
@ -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>;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -1,4 +1,3 @@
|
||||
pub mod repository;
|
||||
pub mod auth;
|
||||
pub mod access_control;
|
||||
pub mod notification;
|
||||
pub mod health;
|
||||
pub mod repository;
|
||||
|
||||
@ -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>;
|
||||
}
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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(())
|
||||
|
||||
2
net-guardia/src/model/access_control/mod.rs
Normal file
2
net-guardia/src/model/access_control/mod.rs
Normal file
@ -0,0 +1,2 @@
|
||||
pub mod ip_address;
|
||||
pub mod list_type;
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
1
net-guardia/src/model/detection/mod.rs
Normal file
1
net-guardia/src/model/detection/mod.rs
Normal file
@ -0,0 +1 @@
|
||||
pub mod ml_detection;
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
25
net-guardia/src/model/error/mcp.rs
Normal file
25
net-guardia/src/model/error/mcp.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
49
net-guardia/src/model/error/notification.rs
Normal file
49
net-guardia/src/model/error/notification.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
36
net-guardia/src/model/error/soar.rs
Normal file
36
net-guardia/src/model/error/soar.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
1
net-guardia/src/model/identity/mod.rs
Normal file
1
net-guardia/src/model/identity/mod.rs
Normal file
@ -0,0 +1 @@
|
||||
pub mod auth;
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
78
net-guardia/src/model/log/soar.rs
Normal file
78
net-guardia/src/model/log/soar.rs
Normal 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
Loading…
x
Reference in New Issue
Block a user