Add multi-source detection consensus, custom model uploads, and live log streaming (#19)

This commit is contained in:
DaLaw2 2026-04-19 20:01:32 +08:00 committed by GitHub
parent fbaef94082
commit 67dcae2e40
209 changed files with 14092 additions and 4571 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 194 KiB

BIN
.github/images/architecture.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

105
.github/images/architecture.svg vendored Normal file
View File

@ -0,0 +1,105 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 900" width="1200" height="900">
<defs>
<style>
text { font-family: "Inter", "Segoe UI", "Noto Sans", sans-serif; fill: #e5e7eb; }
.title { font-size: 26px; font-weight: 700; fill: #f9fafb; }
.band-label { font-size: 11px; font-weight: 600; letter-spacing: 1.5px; fill: #9ca3af; }
.box-title { font-size: 14px; font-weight: 600; fill: #f3f4f6; }
.box-title-lg { font-size: 16px; font-weight: 700; fill: #f3f4f6; }
.box-sub { font-size: 12px; fill: #cbd5e1; }
.box-bullet { font-size: 12px; fill: #cbd5e1; }
.arrow-label { font-size: 11px; font-weight: 500; fill: #d1d5db; }
.arrow-label-bg { fill: #0f0f1a; }
.legend-label { font-size: 11px; fill: #9ca3af; }
</style>
<marker id="arrow-blue" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 Z" fill="#60a5fa"/>
</marker>
<marker id="arrow-green" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 Z" fill="#34d399"/>
</marker>
<marker id="arrow-orange" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 Z" fill="#fb923c"/>
</marker>
<marker id="arrow-gray" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 Z" fill="#9ca3af"/>
</marker>
<marker id="arrow-purple" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 Z" fill="#a78bfa"/>
</marker>
</defs>
<rect x="0" y="0" width="1200" height="900" fill="#0f0f1a"/>
<text class="title" x="600" y="46" text-anchor="middle">NetGuardia architecture</text>
<rect x="40" y="70" width="1120" height="80" rx="10" fill="#161627" stroke="#2a2a4a" stroke-width="1"/>
<text class="band-label" x="60" y="92">CONTROL PLANE</text>
<rect x="400" y="100" width="400" height="40" rx="8" fill="#1f1f36" stroke="#60a5fa" stroke-width="1.5"/>
<text class="box-title" x="600" y="126" text-anchor="middle">Web UI + REST / WebSocket API</text>
<rect x="40" y="170" width="1120" height="500" rx="10" fill="#161627" stroke="#2a2a4a" stroke-width="1"/>
<text class="band-label" x="60" y="192">USER SPACE — DETECTION &amp; RESPONSE</text>
<rect x="115" y="210" width="220" height="80" rx="8" fill="#1f1f36" stroke="#a78bfa" stroke-width="1.5"/>
<text class="box-title" x="225" y="244" text-anchor="middle">ML Inference</text>
<text class="box-sub" x="225" y="266" text-anchor="middle">tract-onnx · BYO model</text>
<rect x="365" y="210" width="220" height="80" rx="8" fill="#1f1f36" stroke="#a78bfa" stroke-width="1.5"/>
<text class="box-title" x="475" y="244" text-anchor="middle">Beaconing</text>
<text class="box-sub" x="475" y="266" text-anchor="middle">temporal CV</text>
<rect x="615" y="210" width="220" height="80" rx="8" fill="#1f1f36" stroke="#a78bfa" stroke-width="1.5"/>
<text class="box-title" x="725" y="244" text-anchor="middle">Correlation</text>
<text class="box-sub" x="725" y="266" text-anchor="middle">graph topology</text>
<rect x="865" y="210" width="220" height="80" rx="8" fill="#1f1f36" stroke="#a78bfa" stroke-width="1.5"/>
<text class="box-title" x="975" y="244" text-anchor="middle">Suricata</text>
<text class="box-sub" x="975" y="266" text-anchor="middle">eve.json ingest</text>
<rect x="300" y="350" width="600" height="130" rx="10" fill="#1f1f36" stroke="#60a5fa" stroke-width="2"/>
<text class="box-title-lg" x="600" y="378" text-anchor="middle">Detection Orchestrator</text>
<text class="box-bullet" x="340" y="410">• canonicalize attack type</text>
<text class="box-bullet" x="340" y="432">• fuse: 1 ∏(1 c_i)</text>
<text class="box-bullet" x="340" y="454">• WORM audit (SHA-256 chained)</text>
<rect x="300" y="520" width="600" height="130" rx="10" fill="#1f1f36" stroke="#fb923c" stroke-width="2"/>
<text class="box-title-lg" x="600" y="548" text-anchor="middle">SOAR Engine</text>
<text class="box-bullet" x="340" y="580">• playbook match</text>
<text class="box-bullet" x="340" y="602">• cooldown + dry-run</text>
<text class="box-bullet" x="340" y="624">• actions: block / rate-limit / webhook / email / telegram</text>
<rect x="40" y="690" width="1120" height="140" rx="10" fill="#161627" stroke="#2a2a4a" stroke-width="1"/>
<text class="band-label" x="60" y="712">KERNEL SPACE — eBPF / XDP</text>
<rect x="80" y="750" width="180" height="60" rx="8" fill="#1f1f36" stroke="#34d399" stroke-width="1.5"/>
<text class="box-title" x="170" y="787" text-anchor="middle">ACL</text>
<rect x="295" y="750" width="180" height="60" rx="8" fill="#1f1f36" stroke="#34d399" stroke-width="1.5"/>
<text class="box-title" x="385" y="787" text-anchor="middle">Rate Limit</text>
<rect x="510" y="750" width="180" height="60" rx="8" fill="#1f1f36" stroke="#34d399" stroke-width="1.5"/>
<text class="box-title" x="600" y="787" text-anchor="middle">Protocol Filter</text>
<rect x="725" y="750" width="180" height="60" rx="8" fill="#1f1f36" stroke="#34d399" stroke-width="1.5"/>
<text class="box-title" x="815" y="787" text-anchor="middle">Geo Block</text>
<rect x="940" y="750" width="180" height="60" rx="8" fill="#1f1f36" stroke="#34d399" stroke-width="1.5"/>
<text class="box-title" x="1030" y="787" text-anchor="middle">DNS Filter</text>
<line x1="262" y1="780" x2="293" y2="780" stroke="#34d399" stroke-width="1.8" marker-end="url(#arrow-green)"/>
<line x1="477" y1="780" x2="508" y2="780" stroke="#34d399" stroke-width="1.8" marker-end="url(#arrow-green)"/>
<line x1="692" y1="780" x2="723" y2="780" stroke="#34d399" stroke-width="1.8" marker-end="url(#arrow-green)"/>
<line x1="907" y1="780" x2="938" y2="780" stroke="#34d399" stroke-width="1.8" marker-end="url(#arrow-green)"/>
<line x1="590" y1="140" x2="590" y2="348" stroke="#9ca3af" stroke-width="1.5" stroke-dasharray="4,2" marker-end="url(#arrow-gray)"/>
<line x1="610" y1="348" x2="610" y2="142" stroke="#9ca3af" stroke-width="1.5" stroke-dasharray="4,2" marker-end="url(#arrow-gray)"/>
<rect class="arrow-label-bg" x="535" y="156" width="130" height="18" rx="2"/>
<text class="arrow-label" x="600" y="169" text-anchor="middle">admin &amp; live events</text>
<path d="M 225,290 L 225,320 L 420,340 L 420,350" fill="none" stroke="#60a5fa" stroke-width="1.8" marker-end="url(#arrow-blue)"/>
<path d="M 475,290 L 475,320 L 540,340 L 540,350" fill="none" stroke="#60a5fa" stroke-width="1.8" marker-end="url(#arrow-blue)"/>
<path d="M 725,290 L 725,320 L 660,340 L 660,350" fill="none" stroke="#60a5fa" stroke-width="1.8" marker-end="url(#arrow-blue)"/>
<path d="M 975,290 L 975,320 L 780,340 L 780,350" fill="none" stroke="#60a5fa" stroke-width="1.8" marker-end="url(#arrow-blue)"/>
<line x1="600" y1="480" x2="600" y2="520" stroke="#34d399" stroke-width="2" marker-end="url(#arrow-green)"/>
<rect class="arrow-label-bg" x="612" y="488" width="135" height="18" rx="2"/>
<text class="arrow-label" x="680" y="501" text-anchor="middle">ThreatDetectedEvent</text>
<path d="M 320,650 L 320,680 L 115,680 L 115,745 L 170,745 L 170,750" fill="none" stroke="#fb923c" stroke-width="2" marker-end="url(#arrow-orange)"/>
<rect class="arrow-label-bg" x="130" y="670" width="170" height="18" rx="2"/>
<text class="arrow-label" x="215" y="683" text-anchor="middle">enforce (block / rate-limit)</text>
<path d="M 1095,750 L 1095,310 L 1005,310 L 1005,290" fill="none" stroke="#a78bfa" stroke-width="2" marker-end="url(#arrow-purple)"/>
<rect class="arrow-label-bg" x="1030" y="468" width="100" height="18" rx="2"/>
<text class="arrow-label" x="1080" y="481" text-anchor="middle">AF_XDP mirror</text>
<line x1="60" y1="870" x2="84" y2="870" stroke="#60a5fa" stroke-width="2"/>
<text class="legend-label" x="90" y="874">detection events</text>
<line x1="216" y1="870" x2="240" y2="870" stroke="#34d399" stroke-width="2"/>
<text class="legend-label" x="246" y="874">fused event / kernel pipeline</text>
<line x1="450" y1="870" x2="474" y2="870" stroke="#fb923c" stroke-width="2"/>
<text class="legend-label" x="480" y="874">enforcement action</text>
<line x1="618" y1="870" x2="642" y2="870" stroke="#a78bfa" stroke-width="2"/>
<text class="legend-label" x="648" y="874">AF_XDP mirror</text>
<line x1="756" y1="870" x2="780" y2="870" stroke="#9ca3af" stroke-width="2"/>
<text class="legend-label" x="786" y="874">admin / control</text>
</svg>

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

BIN
.github/images/map.png vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

BIN
.github/images/ui/access-control.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

BIN
.github/images/ui/account-management.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

BIN
.github/images/ui/api-keys.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

BIN
.github/images/ui/audit-log.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

BIN
.github/images/ui/auto-response.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

BIN
.github/images/ui/detection.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

BIN
.github/images/ui/dns-filter.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

BIN
.github/images/ui/drop-monitor.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

BIN
.github/images/ui/flow-trace.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

BIN
.github/images/ui/geoip-block.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

BIN
.github/images/ui/logs.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 KiB

BIN
.github/images/ui/map.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

BIN
.github/images/ui/protocol-filter.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

BIN
.github/images/ui/rate-limit.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

BIN
.github/images/ui/security-report.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

BIN
.github/images/ui/statistics.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

BIN
.github/images/ui/system-settings.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

BIN
.github/images/ui/system-status.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

8
.gitignore vendored
View File

@ -41,7 +41,9 @@ interfaces.txt
traffic_log.csv
# Project docs (local only)
CLAUDE.md
# CLAUDE.md — tracked on dev branches; MUST be untracked before PR to master
# (see CLAUDE.md "Branch discipline" section)
# CLAUDE.md
DESIGN.md
TODOS.md
VERSION
@ -51,7 +53,9 @@ CHANGELOG.md
benchmark/
# Generated docs
docs/
# docs/ — tracked on dev branches; MUST be untracked before PR to master
# (see CLAUDE.md "Branch discipline" section)
# docs/
# SQLite database files
*.db

650
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -20,9 +20,10 @@ libxdp-sys = { version = "0.2.4", features = ["use_cc_build", "use_precompiled_b
# Serialization
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
serde_yaml_ng = "0.10.0"
# Async runtime
tokio = { version = "1.50.0", features = ["rt-multi-thread", "macros", "sync", "time"] }
tokio = { version = "1.50.0", features = ["rt-multi-thread", "macros", "sync", "time", "process", "io-util", "fs", "signal"] }
# Web framework
actix = "0.13.5"

106
README.md
View File

@ -1,60 +1,76 @@
# NetGuardia
## Project Overview
Inline network security platform built on eBPF/XDP. Runs four independent detectors (per-packet ML, temporal beaconing, graph correlation, Suricata) over the same data plane, fuses their verdicts, drives SOAR playbooks, and writes every decision into a WORM audit chain.
**NetGuardia** is a high-performance network security solution that combines eBPF XDP technology with deep learning models to provide advanced network protection. The system operates as a standalone network appliance that can run on any Ubuntu-based system with compatible network hardware.
## Stack
## Core Technologies
- **Data plane** — eBPF / XDP / AF_XDP (aya, xsk-rs)
- **Detection** — Rust + tract-onnx for ML, custom temporal / graph engines, Suricata `eve.json` ingest
- **Control plane** — actix-web REST + WebSocket, SQLite + SQLCipher, argon2 / JWT / CSRF, per-playbook SOAR
- **Frontend** — Vue 3 + Pinia + Vue-i18n (en / zh-TW / zh-CN / ja)
- **Architecture** — hexagonal: `adapter/` · `core/` · `infrastructure/` · `interface/` · `model/`
- **eBPF XDP Technology** - Provides high-performance packet processing directly at the data link layer
- **Deep Learning Models** - Identifies and predicts potential network attacks with intelligent threat detection
- **Hardware Integration** - Designed to work with Intel i350 T2 and similar enterprise-grade network interface cards
## Screens
## Functional Modules
<table>
<tr>
<td><img src=".github/images/ui/statistics.png" alt="Traffic statistics"/><br><sub>Traffic statistics (per-IP bytes/packets)</sub></td>
<td><img src=".github/images/ui/map.png" alt="Geo map"/><br><sub>Live geographic flow map</sub></td>
<td><img src=".github/images/ui/drop-monitor.png" alt="Drop monitor"/><br><sub>Real-time drop monitor</sub></td>
</tr>
<tr>
<td><img src=".github/images/ui/detection.png" alt="Threat detection"/><br><sub>Fused threat detection + ML status</sub></td>
<td><img src=".github/images/ui/access-control.png" alt="Access control"/><br><sub>IPv4/IPv6 allow + block lists</sub></td>
<td><img src=".github/images/ui/geoip-block.png" alt="GeoIP block"/><br><sub>GeoIP country block</sub></td>
</tr>
<tr>
<td><img src=".github/images/ui/dns-filter.png" alt="DNS filter"/><br><sub>DNS blacklist</sub></td>
<td><img src=".github/images/ui/rate-limit.png" alt="Rate limit"/><br><sub>Per-class DDoS rate limits</sub></td>
<td><img src=".github/images/ui/protocol-filter.png" alt="Protocol filter"/><br><sub>HTTP / SSH service rules</sub></td>
</tr>
<tr>
<td><img src=".github/images/ui/auto-response.png" alt="SOAR"/><br><sub>SOAR playbooks + dry-run</sub></td>
<td><img src=".github/images/ui/security-report.png" alt="Security report"/><br><sub>Security report (PDF / email)</sub></td>
<td><img src=".github/images/ui/audit-log.png" alt="Audit log"/><br><sub>WORM-chained audit log</sub></td>
</tr>
<tr>
<td><img src=".github/images/ui/account-management.png" alt="Accounts"/><br><sub>Users + groups + RBAC</sub></td>
<td><img src=".github/images/ui/api-keys.png" alt="API keys"/><br><sub>API keys</sub></td>
<td><img src=".github/images/ui/flow-trace.png" alt="Flow trace"/><br><sub>Rotated flow recording</sub></td>
</tr>
<tr>
<td><img src=".github/images/ui/logs.png" alt="Logs"/><br><sub>Live + archived logs</sub></td>
<td><img src=".github/images/ui/system-status.png" alt="System status"/><br><sub>CPU / memory / NIC counters</sub></td>
<td><img src=".github/images/ui/system-settings.png" alt="System settings"/><br><sub>Mode / theme / HTTP / engine</sub></td>
</tr>
</table>
### Resource Overview
![Home](.github/images/index.png)
- Real-time control system occupancy rate
## Architecture
### Dashboard Overview
![Dashboard](.github/images/dashboard.png)
- Real-time network traffic monitoring and visualization
- Recent traffic statistics and trend analysis
![NetGuardia architecture](.github/images/architecture.png)
### Detailed Traffic Statistics
![Statistics](.github/images/statistics.png)
- Detailed traffic usage information per IP address
## Requirements
### Network Access Control
![accessControl](.github/images/accessControl.png)
- IPv4/IPv6 whitelist and blacklist management
- Precise port-level access control
Linux kernel with eBPF **and** a NIC driver that implements AF_XDP on that kernel. No single "minimum kernel" — it depends on the NIC.
[//]: # (### AI Attack Detection)
| Driver | NIC family | Min kernel for AF_XDP |
|---|---|---|
| `mlx5` | Mellanox ConnectX-4/5/6/7 | 5.x |
| `ixgbe` | Intel 82599, X520, X540, X550 | 5.x |
| `i40e` | Intel X710, XL710, XXV710 | 5.x |
| `ice` | Intel E810 | 5.5+ |
| `igb` | Intel i350 T2 (reference HW) | **6.17** |
| `igc` | Intel I225/I226 | 6.x |
| `virtio_net` | QEMU/KVM | varies |
[//]: # (![AI 攻擊偵測介面]&#40;.github/images/aiDetection.png&#41;)
Check with `ethtool -i <iface>` before deploying. 8 GB RAM minimum, 16 GB+ for high-traffic.
[//]: # (- AI-based attack detection engine)
## Build
## System Features
- **High Performance** - Low-latency packet processing with minimal network performance impact
- **User-Friendly** - Cross-platform web management interface with intuitive operation
- **Reliability** - Hardware-accelerated processing ensures stable operation
- **Scalability** - Modular design supports functional expansion
## System Requirements
- Ubuntu-based operating system (Ubuntu 24.04 LTS or newer recommended)
- Dual-port network interface card (Intel i350 T2 or compatible XDP-capable NIC)
- Root/sudo access for eBPF program loading
## Hardware Compatibility
NetGuardia is designed to work on any Ubuntu-based system meeting the following requirements:
- Network Interface: Any dual-port NIC supporting XDP native or offload mode (Intel i350 T2 recommended)
- CPU: Multi-core processor recommended for optimal performance
- Memory: 8GB RAM minimum, 16GB or more for high-traffic environments
The system is not limited to embedded platforms and can be deployed on standard server hardware, virtual machines, or dedicated appliances running Ubuntu.
```sh
cargo build --release --package net-guardia
sudo ./target/release/net-guardia
# open http://<host>:8080 — setup wizard issues the admin password on first boot
```
Systemd unit: [`deploy/netguardia.service`](deploy/netguardia.service).

View File

@ -1,13 +0,0 @@
[package]
name = "license-generator"
version = "0.1.0"
edition = "2024"
[dependencies]
ed25519-dalek = { version = "2", features = ["std", "rand_core"] }
base64 = "0.22"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rand = "0.9"
clap = { version = "4", features = ["derive"] }
pnet = "0.36"

View File

@ -1,217 +0,0 @@
use std::fs;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64;
use clap::{Parser, Subcommand};
use ed25519_dalek::{Signer, SigningKey, Verifier, VerifyingKey, Signature};
use pnet::datalink;
use rand::rngs::OsRng;
use serde::{Deserialize, Serialize};
#[derive(Parser)]
#[command(name = "license-generator", about = "NetGuardia license generator")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Generate a new Ed25519 keypair
Keygen {
#[arg(short, long, default_value = "license")]
prefix: String,
},
/// Issue a signed license bound to NIC MACs
Issue {
#[arg(short = 'k', long)]
private_key: String,
/// Ingress interface name (e.g. ng-ext)
#[arg(long)]
ingress: String,
/// Egress interface name (e.g. ng-int)
#[arg(long)]
egress: String,
/// Expiry date (YYYY-MM-DD)
#[arg(short, long)]
expires: String,
/// Comma-separated list of features
#[arg(short, long, default_value = "")]
features: String,
/// Output license file path
#[arg(short, long, default_value = "license.key")]
output: String,
},
/// Verify a license file
Verify {
#[arg(short = 'k', long)]
public_key: String,
#[arg(short, long)]
license: String,
},
}
#[derive(Serialize, Deserialize, Debug)]
struct LicensePayload {
ingress_mac: String,
egress_mac: String,
expires: String,
features: Vec<String>,
}
fn get_mac(ifname: &str) -> String {
for iface in datalink::interfaces() {
if iface.name == ifname {
if let Some(mac) = iface.mac {
return format!(
"{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
mac.0, mac.1, mac.2, mac.3, mac.4, mac.5
);
}
}
}
eprintln!("Interface '{}' not found or has no MAC address", ifname);
eprintln!("Available interfaces:");
for iface in datalink::interfaces() {
if let Some(mac) = iface.mac {
eprintln!(" {}{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
iface.name, mac.0, mac.1, mac.2, mac.3, mac.4, mac.5);
}
}
std::process::exit(1);
}
fn main() {
let cli = Cli::parse();
match cli.command {
Commands::Keygen { prefix } => keygen(&prefix),
Commands::Issue { private_key, ingress, egress, expires, features, output } => {
issue(&private_key, &ingress, &egress, &expires, &features, &output)
}
Commands::Verify { public_key, license } => verify(&public_key, &license),
}
}
fn keygen(prefix: &str) {
let mut csprng = OsRng;
let signing_key = SigningKey::generate(&mut csprng);
let verifying_key = signing_key.verifying_key();
let priv_hex = hex_encode(signing_key.as_bytes());
let pub_hex = hex_encode(verifying_key.as_bytes());
let priv_path = format!("{}_priv.key", prefix);
let pub_path = format!("{}_pub.key", prefix);
fs::write(&priv_path, &priv_hex).expect("Failed to write private key");
fs::write(&pub_path, &pub_hex).expect("Failed to write public key");
println!("Keypair generated:");
println!(" Private key: {}", priv_path);
println!(" Public key: {}", pub_path);
println!();
println!("Public key hex (embed in validator.rs):");
println!(" {}", pub_hex);
}
fn issue(private_key_path: &str, ingress: &str, egress: &str, expires: &str, features: &str, output: &str) {
let ingress_mac = get_mac(ingress);
let egress_mac = get_mac(egress);
println!("Detected MACs:");
println!(" {}{}", ingress, ingress_mac);
println!(" {}{}", egress, egress_mac);
let priv_hex = fs::read_to_string(private_key_path)
.expect("Failed to read private key")
.trim()
.to_string();
let priv_bytes = hex_decode(&priv_hex).expect("Invalid hex");
let priv_array: [u8; 32] = priv_bytes.try_into().expect("Key must be 32 bytes");
let signing_key = SigningKey::from_bytes(&priv_array);
let feature_list: Vec<String> = if features.is_empty() {
vec![]
} else {
features.split(',').map(|s| s.trim().to_string()).collect()
};
let payload = LicensePayload {
ingress_mac: ingress_mac.clone(),
egress_mac: egress_mac.clone(),
expires: expires.to_string(),
features: feature_list,
};
let payload_json = serde_json::to_string(&payload).expect("Failed to serialize");
let payload_b64 = BASE64.encode(payload_json.as_bytes());
let signature: Signature = signing_key.sign(payload_b64.as_bytes());
let sig_b64 = BASE64.encode(signature.to_bytes());
let license_content = format!("{}.{}", payload_b64, sig_b64);
fs::write(output, &license_content).expect("Failed to write license");
println!();
println!("License issued:");
println!(" Ingress MAC: {}", ingress_mac);
println!(" Egress MAC: {}", egress_mac);
println!(" Expires: {}", expires);
println!(" Features: {:?}", payload.features);
println!(" Output: {}", output);
}
fn verify(public_key_path: &str, license_path: &str) {
let pub_hex = fs::read_to_string(public_key_path)
.expect("Failed to read public key")
.trim()
.to_string();
let pub_bytes = hex_decode(&pub_hex).expect("Invalid hex");
let pub_array: [u8; 32] = pub_bytes.try_into().expect("Key must be 32 bytes");
let verifying_key = VerifyingKey::from_bytes(&pub_array).expect("Invalid public key");
let contents = fs::read_to_string(license_path)
.expect("Failed to read license")
.trim()
.to_string();
let parts: Vec<&str> = contents.splitn(2, '.').collect();
if parts.len() != 2 {
eprintln!("Invalid license format");
std::process::exit(1);
}
let sig_bytes = BASE64.decode(parts[1]).expect("Invalid signature");
let sig_array: [u8; 64] = sig_bytes.try_into().expect("Signature must be 64 bytes");
let signature = Signature::from_bytes(&sig_array);
match verifying_key.verify(parts[0].as_bytes(), &signature) {
Ok(()) => {
let payload_bytes = BASE64.decode(parts[0]).expect("Invalid payload");
let payload: LicensePayload = serde_json::from_slice(&payload_bytes).expect("Invalid JSON");
println!("License VALID:");
println!(" Ingress MAC: {}", payload.ingress_mac);
println!(" Egress MAC: {}", payload.egress_mac);
println!(" Expires: {}", payload.expires);
println!(" Features: {:?}", payload.features);
}
Err(e) => {
eprintln!("License INVALID: {}", e);
std::process::exit(1);
}
}
}
fn hex_encode(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{:02x}", b)).collect()
}
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()
}

Binary file not shown.

Binary file not shown.

View File

@ -1,11 +1,12 @@
{
"created_at": "2026-03-05T14:04:08.736531",
"created_at": "2026-04-07T02:27:17.281069",
"framework": "PyTorch",
"model_type": "MultiTaskModel",
"model": {
"deep_autoencoder": {
"file": "deep_autoencoder.onnx",
"input_dim": 27,
"encoding_dim": 16,
"input_dim": 31,
"encoding_dim": 12,
"ae_feature_names": [
"flow_duration",
"fwd_packets",
@ -33,15 +34,24 @@
"fwd_pkt_len_std",
"bwd_pkt_len_std",
"fwd_seg_size_min",
"fwd_act_data_pkts"
"fwd_act_data_pkts",
"fwd_iat_std",
"bwd_iat_std",
"fwd_bwd_bytes_ratio",
"iat_cv"
],
"ae_threshold": 0.17166012525558472
"ae_threshold": 0.23011694848537445
},
"classifier": {
"file": "classifier.onnx",
"type": "ResNet MLP",
"n_features": 28,
"n_classes": 5,
"type": "MultiTaskModel",
"n_features": 32,
"n_classes": 10,
"outputs": [
"anomaly",
"class_probs",
"c2_score"
],
"classifier_feature_names": [
"flow_duration",
"fwd_packets",
@ -70,6 +80,10 @@
"bwd_pkt_len_std",
"fwd_seg_size_min",
"fwd_act_data_pkts",
"fwd_iat_std",
"bwd_iat_std",
"fwd_bwd_bytes_ratio",
"iat_cv",
"ae_anomaly_score"
]
}
@ -77,68 +91,68 @@
"preprocessing": {
"ae_clip_params": {
"flow_duration": {
"lower": 1.0,
"upper": 118485085.19000001
"lower": 0.0,
"upper": 115669365.2
},
"fwd_packets": {
"lower": 0.0,
"upper": 54.0
"upper": 120.0
},
"bwd_packets": {
"lower": 0.0,
"upper": 82.0
"upper": 126.0
},
"fwd_bytes": {
"lower": 0.0,
"upper": 6960.0
"upper": 19557.400390625
},
"bwd_bytes": {
"lower": 0.0,
"upper": 89558.6950000003
"upper": 85164.0
},
"flow_bytes_per_sec": {
"lower": 0.0,
"upper": 12500000.0
"upper": 1627586.8125000005
},
"flow_pkts_per_sec": {
"lower": 0.0226829350855,
"upper": 2000000.0
"lower": 0.0,
"upper": 23809.5234375
},
"fwd_win_bytes": {
"lower": -1.0,
"upper": 65535.0
"lower": 0.0,
"upper": 65280.0
},
"bwd_win_bytes": {
"lower": -1.0,
"upper": 64000.0
"lower": 0.0,
"upper": 65535.0
},
"fwd_pkt_len_mean": {
"lower": 0.0,
"upper": 744.7272727272729
"upper": 1500.0
},
"bwd_pkt_len_mean": {
"lower": 0.0,
"upper": 1393.9601153594992
"upper": 822.0007794189461
},
"fwd_iat_mean": {
"lower": 0.0,
"upper": 86366722.035
"upper": 54051113.24
},
"bwd_iat_mean": {
"lower": 0.0,
"upper": 21700000.0
"upper": 6912790.715000001
},
"flow_iat_mean": {
"lower": 1.0,
"upper": 86353400.64500001
"lower": 0.0,
"upper": 166521472.0
},
"pkt_len_mean": {
"lower": 0.0,
"upper": 945.0294113974604
"upper": 957.2035284423835
},
"dst_port": {
"lower": 0.0,
"upper": 64499.0
"upper": 63005.0
},
"protocol": {
"lower": 0.0,
@ -146,11 +160,11 @@
},
"psh_flag_cnt": {
"lower": 0.0,
"upper": 240.0
"upper": 52.0
},
"ack_flag_cnt": {
"lower": 0.0,
"upper": 655.0
"upper": 107.0
},
"syn_flag_cnt": {
"lower": 0.0,
@ -158,91 +172,115 @@
},
"fin_flag_cnt": {
"lower": 0.0,
"upper": 2.0
"upper": 1.0
},
"rst_flag_cnt": {
"lower": 0.0,
"upper": 1.0
"upper": 0.0
},
"pkt_len_std": {
"lower": 0.0,
"upper": 943.9221842600019
"upper": 818.4579974365238
},
"fwd_pkt_len_std": {
"lower": 0.0,
"upper": 721.4199648708689
"upper": 256.8401712036142
},
"bwd_pkt_len_std": {
"lower": 0.0,
"upper": 911.0272448420104
"upper": 676.0667114257812
},
"fwd_seg_size_min": {
"lower": 0.0,
"upper": 40.0
"upper": 1026743.0693750025
},
"fwd_act_data_pkts": {
"lower": 0.0,
"upper": 58.0
"upper": 12.0
},
"fwd_iat_std": {
"lower": 0.0,
"upper": 6691987.085000001
},
"bwd_iat_std": {
"lower": 0.0,
"upper": 5136363.065000001
},
"fwd_bwd_bytes_ratio": {
"lower": 0.0,
"upper": 1.0
},
"iat_cv": {
"lower": 0.0,
"upper": 0.0
}
},
"ae_scaler": {
"mean": [
9783470.902880548,
3.6532605393470496,
3.2219949045990472,
311.35281195367173,
1407.508354811673,
298997.28705051675,
32378.36735569276,
6158.3256759444475,
6125.023325143747,
58.1683929498305,
149.72168204849987,
2991407.86700246,
543337.1970583026,
2727511.340181723,
105.03829359866097,
13463.473246959382,
8.920585011866685,
5.457814320208867,
12.214631683524651,
0.4885976379312313,
0.246598391240437,
0.08478201266637703,
151.71809799252043,
76.89649795414182,
143.21014925828936,
21.447338538177014,
3.2607463735155715
1775639.8280735926,
5.542641564702501,
4.873551306800804,
673.3628917399571,
1776.7162625946232,
22595.98254433581,
457.7205079100132,
5370.204067202503,
9979.409366405764,
310.21111530262294,
64.0225296706552,
421007.8046985764,
74954.24060464761,
1927814.4699678936,
18.27810422291583,
10009.108501605231,
9.275900254913996,
0.6270530398000678,
1.4141691028300247,
0.08438195832759936,
0.0413846397252831,
0.0,
20.458150398533718,
3.321724142251631,
13.033036407393814,
8569.843223681366,
0.21485243990919378,
94146.99057411935,
68775.38334652747,
0.47595050130443944,
0.0
],
"std": [
27895851.239715174,
6.4263677035863696,
8.326275195918974,
739.2548064174216,
7898.711708881367,
1187804.3239195002,
190280.1548511611,
15556.664474188405,
17012.235322987635,
98.89458775902833,
242.79522892109904,
13146157.964503227,
2242915.166631539,
12878527.145353919,
151.95316919115754,
21264.915159327567,
4.943209145195101,
23.02750953458606,
57.0633472601567,
1.2662749497077417,
0.4553262784517569,
0.278557037237646,
211.1373807979287,
143.47790203327,
228.24531573376805,
9.323458649060111,
7.228913366949205
12196313.175317517,
13.15004546194127,
13.245688945052057,
1969.9160973703263,
8250.018585629565,
161540.2407042193,
2518.5714986775442,
12103.605209582642,
20308.80085611352,
476.8868336260097,
95.72217224515282,
4199427.656867863,
616719.9756782106,
16437665.614047276,
97.4688620675193,
18194.33523865822,
5.182633726939228,
4.994559084102198,
10.431975160428792,
0.558462828085695,
0.19916731632770637,
1.0,
106.06236469581468,
24.362268530572912,
85.37603561474889,
84340.18813365103,
1.4442281045462682,
646928.7442307192,
525550.9737726098,
0.3431291415218137,
1.0
],
"feature_names": [
"flow_duration",
@ -271,7 +309,11 @@
"fwd_pkt_len_std",
"bwd_pkt_len_std",
"fwd_seg_size_min",
"fwd_act_data_pkts"
"fwd_act_data_pkts",
"fwd_iat_std",
"bwd_iat_std",
"fwd_bwd_bytes_ratio",
"iat_cv"
]
},
"post_scaling_clip": {
@ -280,10 +322,15 @@
}
},
"attack_labels": {
"0": "Brute Force",
"1": "DDoS",
"2": "DoS",
"3": "Exploitation",
"4": "Reconnaissance"
"0": "Bot",
"1": "Brute Force",
"2": "C2 Communication",
"3": "DNS Tunneling",
"4": "DoS\/DDoS",
"5": "Exploitation",
"6": "Malware",
"7": "Normal",
"8": "Reconnaissance",
"9": "Web Attack"
}
}

View File

@ -26,72 +26,76 @@
"fwd_pkt_len_std",
"bwd_pkt_len_std",
"fwd_seg_size_min",
"fwd_act_data_pkts"
"fwd_act_data_pkts",
"fwd_iat_std",
"bwd_iat_std",
"fwd_bwd_bytes_ratio",
"iat_cv"
],
"ae_clip_params": {
"flow_duration": {
"lower": 1.0,
"upper": 118485085.19000001
"lower": 0.0,
"upper": 115669365.2
},
"fwd_packets": {
"lower": 0.0,
"upper": 54.0
"upper": 120.0
},
"bwd_packets": {
"lower": 0.0,
"upper": 82.0
"upper": 126.0
},
"fwd_bytes": {
"lower": 0.0,
"upper": 6960.0
"upper": 19557.400390625
},
"bwd_bytes": {
"lower": 0.0,
"upper": 89558.6950000003
"upper": 85164.0
},
"flow_bytes_per_sec": {
"lower": 0.0,
"upper": 12500000.0
"upper": 1627586.8125000005
},
"flow_pkts_per_sec": {
"lower": 0.0226829350855,
"upper": 2000000.0
"lower": 0.0,
"upper": 23809.5234375
},
"fwd_win_bytes": {
"lower": -1.0,
"upper": 65535.0
"lower": 0.0,
"upper": 65280.0
},
"bwd_win_bytes": {
"lower": -1.0,
"upper": 64000.0
"lower": 0.0,
"upper": 65535.0
},
"fwd_pkt_len_mean": {
"lower": 0.0,
"upper": 744.7272727272729
"upper": 1500.0
},
"bwd_pkt_len_mean": {
"lower": 0.0,
"upper": 1393.9601153594992
"upper": 822.0007794189461
},
"fwd_iat_mean": {
"lower": 0.0,
"upper": 86366722.035
"upper": 54051113.24
},
"bwd_iat_mean": {
"lower": 0.0,
"upper": 21700000.0
"upper": 6912790.715000001
},
"flow_iat_mean": {
"lower": 1.0,
"upper": 86353400.64500001
"lower": 0.0,
"upper": 166521472.0
},
"pkt_len_mean": {
"lower": 0.0,
"upper": 945.0294113974604
"upper": 957.2035284423835
},
"dst_port": {
"lower": 0.0,
"upper": 64499.0
"upper": 63005.0
},
"protocol": {
"lower": 0.0,
@ -99,11 +103,11 @@
},
"psh_flag_cnt": {
"lower": 0.0,
"upper": 240.0
"upper": 52.0
},
"ack_flag_cnt": {
"lower": 0.0,
"upper": 655.0
"upper": 107.0
},
"syn_flag_cnt": {
"lower": 0.0,
@ -111,94 +115,118 @@
},
"fin_flag_cnt": {
"lower": 0.0,
"upper": 2.0
"upper": 1.0
},
"rst_flag_cnt": {
"lower": 0.0,
"upper": 1.0
"upper": 0.0
},
"pkt_len_std": {
"lower": 0.0,
"upper": 943.9221842600019
"upper": 818.4579974365238
},
"fwd_pkt_len_std": {
"lower": 0.0,
"upper": 721.4199648708689
"upper": 256.8401712036142
},
"bwd_pkt_len_std": {
"lower": 0.0,
"upper": 911.0272448420104
"upper": 676.0667114257812
},
"fwd_seg_size_min": {
"lower": 0.0,
"upper": 40.0
"upper": 1026743.0693750025
},
"fwd_act_data_pkts": {
"lower": 0.0,
"upper": 58.0
"upper": 12.0
},
"fwd_iat_std": {
"lower": 0.0,
"upper": 6691987.085000001
},
"bwd_iat_std": {
"lower": 0.0,
"upper": 5136363.065000001
},
"fwd_bwd_bytes_ratio": {
"lower": 0.0,
"upper": 1.0
},
"iat_cv": {
"lower": 0.0,
"upper": 0.0
}
},
"ae_scaler_mean": [
9783470.902880548,
3.6532605393470496,
3.2219949045990472,
311.35281195367173,
1407.508354811673,
298997.28705051675,
32378.36735569276,
6158.3256759444475,
6125.023325143747,
58.1683929498305,
149.72168204849987,
2991407.86700246,
543337.1970583026,
2727511.340181723,
105.03829359866097,
13463.473246959382,
8.920585011866685,
5.457814320208867,
12.214631683524651,
0.4885976379312313,
0.246598391240437,
0.08478201266637703,
151.71809799252043,
76.89649795414182,
143.21014925828936,
21.447338538177014,
3.2607463735155715
1775639.8280735926,
5.542641564702501,
4.873551306800804,
673.3628917399571,
1776.7162625946232,
22595.98254433581,
457.7205079100132,
5370.204067202503,
9979.409366405764,
310.21111530262294,
64.0225296706552,
421007.8046985764,
74954.24060464761,
1927814.4699678936,
18.27810422291583,
10009.108501605231,
9.275900254913996,
0.6270530398000678,
1.4141691028300247,
0.08438195832759936,
0.0413846397252831,
0.0,
20.458150398533718,
3.321724142251631,
13.033036407393814,
8569.843223681366,
0.21485243990919378,
94146.99057411935,
68775.38334652747,
0.47595050130443944,
0.0
],
"ae_scaler_std": [
27895851.239715174,
6.4263677035863696,
8.326275195918974,
739.2548064174216,
7898.711708881367,
1187804.3239195002,
190280.1548511611,
15556.664474188405,
17012.235322987635,
98.89458775902833,
242.79522892109904,
13146157.964503227,
2242915.166631539,
12878527.145353919,
151.95316919115754,
21264.915159327567,
4.943209145195101,
23.02750953458606,
57.0633472601567,
1.2662749497077417,
0.4553262784517569,
0.278557037237646,
211.1373807979287,
143.47790203327,
228.24531573376805,
9.323458649060111,
7.228913366949205
12196313.175317517,
13.15004546194127,
13.245688945052057,
1969.9160973703263,
8250.018585629565,
161540.2407042193,
2518.5714986775442,
12103.605209582642,
20308.80085611352,
476.8868336260097,
95.72217224515282,
4199427.656867863,
616719.9756782106,
16437665.614047276,
97.4688620675193,
18194.33523865822,
5.182633726939228,
4.994559084102198,
10.431975160428792,
0.558462828085695,
0.19916731632770637,
1.0,
106.06236469581468,
24.362268530572912,
85.37603561474889,
84340.18813365103,
1.4442281045462682,
646928.7442307192,
525550.9737726098,
0.3431291415218137,
1.0
],
"ae_post_clip_min": -5.0,
"ae_post_clip_max": 5.0,
"ae_threshold": 0.17166012525558472,
"ae_threshold": 0.23011694848537445,
"classifier_feature_names": [
"flow_duration",
"fwd_packets",
@ -227,13 +255,64 @@
"bwd_pkt_len_std",
"fwd_seg_size_min",
"fwd_act_data_pkts",
"fwd_iat_std",
"bwd_iat_std",
"fwd_bwd_bytes_ratio",
"iat_cv",
"ae_anomaly_score"
],
"attack_labels": {
"0": "Brute Force",
"1": "DDoS",
"2": "DoS",
"3": "Exploitation",
"4": "Reconnaissance"
}
"0": "Bot",
"1": "Brute Force",
"2": "C2 Communication",
"3": "DNS Tunneling",
"4": "DoS/DDoS",
"5": "Exploitation",
"6": "Malware",
"7": "Normal",
"8": "Reconnaissance",
"9": "Web Attack"
},
"anomaly_threshold": 0.9179317355155945,
"c2_threshold": 0.9085615873336792,
"model_type": "MultiTaskModel",
"output_names": [
"anomaly",
"class_probs",
"c2_score"
],
"ae_feature_weights": {
"flow_duration": 1.0,
"fwd_packets": 1.0,
"bwd_packets": 1.0,
"fwd_bytes": 1.0,
"bwd_bytes": 1.0,
"flow_bytes_per_sec": 1.0,
"flow_pkts_per_sec": 1.0,
"fwd_win_bytes": 4.0,
"bwd_win_bytes": 4.0,
"fwd_pkt_len_mean": 1.0,
"bwd_pkt_len_mean": 1.0,
"fwd_iat_mean": 1.0,
"bwd_iat_mean": 1.0,
"flow_iat_mean": 1.0,
"pkt_len_mean": 1.0,
"dst_port": 1.0,
"protocol": 1.0,
"psh_flag_cnt": 2.0,
"ack_flag_cnt": 1.0,
"syn_flag_cnt": 2.0,
"fin_flag_cnt": 2.0,
"rst_flag_cnt": 2.0,
"pkt_len_std": 1.0,
"fwd_pkt_len_std": 1.0,
"bwd_pkt_len_std": 1.0,
"fwd_seg_size_min": 1.0,
"fwd_act_data_pkts": 1.0,
"fwd_iat_std": 1.5,
"bwd_iat_std": 1.5,
"fwd_bwd_bytes_ratio": 2.0,
"iat_cv": 2.0
},
"class_min_confidence": 0.4
}

74
models/manifest.yaml Normal file
View File

@ -0,0 +1,74 @@
# NetGuardia model manifest. Structural/semantic fields live here;
# preprocessing arrays (scaler mean/std, clip params, feature weights) stay
# in the JSON sidecar referenced by `preprocessing.scaler_sidecar`.
name: netguardia-v10
adapter: multi_task
models:
autoencoder: deep_autoencoder.onnx
classifier: classifier.onnx
# 31 AE-input features. Order matters — must match ONNX input column order
# and inference_config.json `ae_feature_names`. The classifier takes these
# plus `ae_anomaly_score` appended as the 32nd input (handled in code).
features:
- flow_duration
- fwd_packets
- bwd_packets
- fwd_bytes
- bwd_bytes
- flow_bytes_per_sec
- flow_pkts_per_sec
- fwd_win_bytes
- bwd_win_bytes
- fwd_pkt_len_mean
- bwd_pkt_len_mean
- fwd_iat_mean
- bwd_iat_mean
- flow_iat_mean
- pkt_len_mean
- dst_port
- protocol
- psh_flag_cnt
- ack_flag_cnt
- syn_flag_cnt
- fin_flag_cnt
- rst_flag_cnt
- pkt_len_std
- fwd_pkt_len_std
- bwd_pkt_len_std
- fwd_seg_size_min
- fwd_act_data_pkts
- fwd_iat_std
- bwd_iat_std
- fwd_bwd_bytes_ratio
- iat_cv
# `confirmations` sets the per-class aggregator firing threshold. Classes
# with single-shot semantics (C2 / Bot / DNS tunneling / exploit) use 1 so
# the aggregator alerts on the first detection; noisier classes can raise
# it (DoS/DDoS: 2). Absent entries fall back to the engine default.
labels:
"0": { name: Bot, confirmations: 1 }
"1": { name: Brute Force }
"2": { name: C2 Communication, confirmations: 1 }
"3": { name: DNS Tunneling, confirmations: 1 }
"4": { name: DoS/DDoS, confirmations: 2 }
"5": { name: Exploitation, confirmations: 1 }
"6": { name: Malware }
"7": { name: Normal }
"8": { name: Reconnaissance }
"9": { name: Web Attack }
thresholds:
anomaly: 0.9179317355155945
c2: 0.9085615873336792
class_min_confidence: 0.4
ae: 0.23011694848537445
# Average score must exceed `class_min_confidence * alert_multiplier`
# before the aggregator fires. Raising this suppresses borderline hits.
alert_multiplier: 1.2
preprocessing:
scaler_sidecar: inference_config.json

@ -1 +1 @@
Subproject commit 71d2d7f2d53f4afe6510b3018227aa5e28d97476
Subproject commit 00d347c5eae3ed32f595b0a3553601f16bedfa7e

@ -1 +1 @@
Subproject commit 1f5cbb8b9ba69a5bd16cc15055c230715d6bb9ae
Subproject commit dea59f289635445fe63e69db8345df2f444fb9f1

View File

@ -20,6 +20,9 @@ actix = { workspace = true }
actix-web = { workspace = true }
actix-cors = { workspace = true }
actix-ws = { workspace = true }
actix-multipart = "0.7"
actix-files = "0.6"
uuid = { version = "1", features = ["v4"] }
rust-embed = "8.11.0"
mime_guess = "2.0.5"
url = "2.5.8"
@ -28,6 +31,7 @@ tokio-tungstenite = "0.28.0"
# Serialization
serde = { workspace = true }
serde_json = { workspace = true }
serde_yaml_ng = { workspace = true }
toml = "1.0.7"
# Async
@ -53,6 +57,9 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus
# Architecture
async-trait = "0.1"
dashmap = "6"
arc-swap = "1"
moka = { version = "0.12", features = ["sync"] }
notify = "7"
# Utilities
parking_lot = { workspace = true }
@ -67,6 +74,7 @@ r2d2_sqlite = "0.27"
jsonwebtoken = { workspace = true }
argon2 = { workspace = true }
sha2 = "0.10"
hmac = "0.12"
aes-gcm = "0.10"
hkdf = "0.12"
base64 = { workspace = true }

View File

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

View File

@ -5,13 +5,14 @@ use aya::maps::{HashMap as AyaHashMap, MapData};
use aya::{Ebpf, Pod};
use common::model::ip_address::{IPv4, IPv6, Port};
use common::model::port_rule::PortRule;
use tokio::sync::RwLock;
use parking_lot::RwLock;
use crate::model::direction::FlowDirection;
use crate::interface::port::access_control_admin::AccessControlAdminPort;
use crate::model::access_control::ip_address::NativeConvert;
use crate::model::access_control::list_type::ListType;
use crate::model::error::Error;
use crate::model::error::ebpf::EbpfError;
use crate::model::ip_address::NativeConvert;
use crate::model::list_type::ListType;
use crate::model::monitoring::direction::FlowDirection;
pub struct AccessControl {
ipv4_src_whitelist: RwLock<MapWrapper<IPv4>>,
@ -39,27 +40,43 @@ impl AccessControl {
Ok(access_control)
}
pub async fn get_ipv4_list(&self, direction: FlowDirection, list_type: ListType) -> HashMap<Ipv4Addr, Vec<Port>> {
/// Construct an AccessControl backed by no eBPF maps. Used when eBPF
/// failed to load at startup; every mutating call returns `EbpfError::NotLoaded`,
/// and list queries return empty maps.
pub fn unavailable() -> Self {
Self {
ipv4_src_whitelist: RwLock::new(MapWrapper::unavailable()),
ipv4_src_blacklist: RwLock::new(MapWrapper::unavailable()),
ipv4_dst_whitelist: RwLock::new(MapWrapper::unavailable()),
ipv4_dst_blacklist: RwLock::new(MapWrapper::unavailable()),
ipv6_src_whitelist: RwLock::new(MapWrapper::unavailable()),
ipv6_src_blacklist: RwLock::new(MapWrapper::unavailable()),
ipv6_dst_whitelist: RwLock::new(MapWrapper::unavailable()),
ipv6_dst_blacklist: RwLock::new(MapWrapper::unavailable()),
}
}
pub fn get_ipv4_list(&self, direction: FlowDirection, list_type: ListType) -> HashMap<Ipv4Addr, Vec<Port>> {
let map_wrapper = match (direction, list_type) {
(FlowDirection::Source, ListType::White) => self.ipv4_src_whitelist.read().await,
(FlowDirection::Source, ListType::Black) => self.ipv4_src_blacklist.read().await,
(FlowDirection::Destination, ListType::White) => self.ipv4_dst_whitelist.read().await,
(FlowDirection::Destination, ListType::Black) => self.ipv4_dst_blacklist.read().await,
(FlowDirection::Source, ListType::White) => self.ipv4_src_whitelist.read(),
(FlowDirection::Source, ListType::Black) => self.ipv4_src_blacklist.read(),
(FlowDirection::Destination, ListType::White) => self.ipv4_dst_whitelist.read(),
(FlowDirection::Destination, ListType::Black) => self.ipv4_dst_blacklist.read(),
};
map_wrapper.get_list()
}
pub async fn get_ipv6_list(&self, direction: FlowDirection, list_type: ListType) -> HashMap<Ipv6Addr, Vec<Port>> {
pub fn get_ipv6_list(&self, direction: FlowDirection, list_type: ListType) -> HashMap<Ipv6Addr, Vec<Port>> {
let map_wrapper = match (direction, list_type) {
(FlowDirection::Source, ListType::White) => self.ipv6_src_whitelist.read().await,
(FlowDirection::Source, ListType::Black) => self.ipv6_src_blacklist.read().await,
(FlowDirection::Destination, ListType::White) => self.ipv6_dst_whitelist.read().await,
(FlowDirection::Destination, ListType::Black) => self.ipv6_dst_blacklist.read().await,
(FlowDirection::Source, ListType::White) => self.ipv6_src_whitelist.read(),
(FlowDirection::Source, ListType::Black) => self.ipv6_src_blacklist.read(),
(FlowDirection::Destination, ListType::White) => self.ipv6_dst_whitelist.read(),
(FlowDirection::Destination, ListType::Black) => self.ipv6_dst_blacklist.read(),
};
map_wrapper.get_list()
}
pub async fn add_ipv4_list(
pub fn add_ipv4_list(
&self,
direction: FlowDirection,
list_type: ListType,
@ -68,15 +85,15 @@ impl AccessControl {
let ip: u32 = (*address.ip()).to_bits().to_be();
let port = address.port();
let mut map_wrapper = match (direction, list_type) {
(FlowDirection::Source, ListType::White) => self.ipv4_src_whitelist.write().await,
(FlowDirection::Source, ListType::Black) => self.ipv4_src_blacklist.write().await,
(FlowDirection::Destination, ListType::White) => self.ipv4_dst_whitelist.write().await,
(FlowDirection::Destination, ListType::Black) => self.ipv4_dst_blacklist.write().await,
(FlowDirection::Source, ListType::White) => self.ipv4_src_whitelist.write(),
(FlowDirection::Source, ListType::Black) => self.ipv4_src_blacklist.write(),
(FlowDirection::Destination, ListType::White) => self.ipv4_dst_whitelist.write(),
(FlowDirection::Destination, ListType::Black) => self.ipv4_dst_blacklist.write(),
};
map_wrapper.add(ip, port)
}
pub async fn add_ipv6_list(
pub fn add_ipv6_list(
&self,
direction: FlowDirection,
list_type: ListType,
@ -85,15 +102,15 @@ impl AccessControl {
let ip: u128 = (*address.ip()).to_bits().to_be();
let port = address.port();
let mut map_wrapper = match (direction, list_type) {
(FlowDirection::Source, ListType::White) => self.ipv6_src_whitelist.write().await,
(FlowDirection::Source, ListType::Black) => self.ipv6_src_blacklist.write().await,
(FlowDirection::Destination, ListType::White) => self.ipv6_dst_whitelist.write().await,
(FlowDirection::Destination, ListType::Black) => self.ipv6_dst_blacklist.write().await,
(FlowDirection::Source, ListType::White) => self.ipv6_src_whitelist.write(),
(FlowDirection::Source, ListType::Black) => self.ipv6_src_blacklist.write(),
(FlowDirection::Destination, ListType::White) => self.ipv6_dst_whitelist.write(),
(FlowDirection::Destination, ListType::Black) => self.ipv6_dst_blacklist.write(),
};
map_wrapper.add(ip, port)
}
pub async fn remove_ipv4_list(
pub fn remove_ipv4_list(
&self,
direction: FlowDirection,
list_type: ListType,
@ -102,15 +119,15 @@ impl AccessControl {
let ip: u32 = (*address.ip()).to_bits().to_be();
let port = address.port();
let mut map_wrapper = match (direction, list_type) {
(FlowDirection::Source, ListType::White) => self.ipv4_src_whitelist.write().await,
(FlowDirection::Source, ListType::Black) => self.ipv4_src_blacklist.write().await,
(FlowDirection::Destination, ListType::White) => self.ipv4_dst_whitelist.write().await,
(FlowDirection::Destination, ListType::Black) => self.ipv4_dst_blacklist.write().await,
(FlowDirection::Source, ListType::White) => self.ipv4_src_whitelist.write(),
(FlowDirection::Source, ListType::Black) => self.ipv4_src_blacklist.write(),
(FlowDirection::Destination, ListType::White) => self.ipv4_dst_whitelist.write(),
(FlowDirection::Destination, ListType::Black) => self.ipv4_dst_blacklist.write(),
};
map_wrapper.remove(ip, port)
}
pub async fn remove_ipv6_list(
pub fn remove_ipv6_list(
&self,
direction: FlowDirection,
list_type: ListType,
@ -119,43 +136,82 @@ impl AccessControl {
let ip: u128 = (*address.ip()).to_bits().to_be();
let port = address.port();
let mut map_wrapper = match (direction, list_type) {
(FlowDirection::Source, ListType::White) => self.ipv6_src_whitelist.write().await,
(FlowDirection::Source, ListType::Black) => self.ipv6_src_blacklist.write().await,
(FlowDirection::Destination, ListType::White) => self.ipv6_dst_whitelist.write().await,
(FlowDirection::Destination, ListType::Black) => self.ipv6_dst_blacklist.write().await,
(FlowDirection::Source, ListType::White) => self.ipv6_src_whitelist.write(),
(FlowDirection::Source, ListType::Black) => self.ipv6_src_blacklist.write(),
(FlowDirection::Destination, ListType::White) => self.ipv6_dst_whitelist.write(),
(FlowDirection::Destination, ListType::Black) => self.ipv6_dst_blacklist.write(),
};
map_wrapper.remove(ip, port)
}
}
impl AccessControlAdminPort for AccessControl {
fn add_ipv4_list(&self, direction: FlowDirection, list_type: ListType, address: SocketAddrV4) -> Result<(), Error> {
self.add_ipv4_list(direction, list_type, address)
}
fn add_ipv6_list(&self, direction: FlowDirection, list_type: ListType, address: SocketAddrV6) -> Result<(), Error> {
self.add_ipv6_list(direction, list_type, address)
}
fn remove_ipv4_list(
&self,
direction: FlowDirection,
list_type: ListType,
address: SocketAddrV4,
) -> Result<(), Error> {
self.remove_ipv4_list(direction, list_type, address)
}
fn remove_ipv6_list(
&self,
direction: FlowDirection,
list_type: ListType,
address: SocketAddrV6,
) -> Result<(), Error> {
self.remove_ipv6_list(direction, list_type, address)
}
fn get_ipv4_list(&self, direction: FlowDirection, list_type: ListType) -> HashMap<Ipv4Addr, Vec<Port>> {
self.get_ipv4_list(direction, list_type)
}
fn get_ipv6_list(&self, direction: FlowDirection, list_type: ListType) -> HashMap<Ipv6Addr, Vec<Port>> {
self.get_ipv6_list(direction, list_type)
}
}
struct MapWrapper<T> {
map: AyaHashMap<MapData, T, PortRule>,
map: Option<AyaHashMap<MapData, T, PortRule>>,
}
impl<T: NativeConvert + Pod> MapWrapper<T> {
fn new(ebpf: &mut Ebpf, map_name: &str) -> Result<Self, Error> {
let map = ebpf.take_map(map_name).ok_or(EbpfError::MapNotFound)?;
let map = AyaHashMap::try_from(map).map_err(EbpfError::MapOperationError)?;
Ok(Self { map })
Ok(Self { map: Some(map) })
}
fn unavailable() -> Self {
Self { map: None }
}
fn get_list(&self) -> HashMap<T::Native, Vec<Port>> {
self.map
.iter()
let Some(map) = self.map.as_ref() else {
return HashMap::new();
};
map.iter()
.filter_map(Result::ok)
.map(|(key, rule)| (key.into_native(), rule.to_port_vec()))
.collect()
}
fn add(&mut self, ip: T, port: Port) -> Result<(), Error> {
let Some(map) = self.map.as_mut() else {
return Err(EbpfError::NotLoaded.into());
};
if port == 0 {
self.map
.insert(ip, PortRule::new_match_all(), 0)
map.insert(ip, PortRule::new_match_all(), 0)
.map_err(EbpfError::MapOperationError)?;
return Ok(());
}
let mut rule = self.map.get(&ip, 0).unwrap_or_else(|_| PortRule::new_empty());
let mut rule = map.get(&ip, 0).unwrap_or_else(|_| PortRule::new_empty());
if rule.is_match_all() {
return Ok(());
@ -165,29 +221,32 @@ impl<T: NativeConvert + Pod> MapWrapper<T> {
Err(EbpfError::RuleReachLimit)?;
}
self.map.insert(ip, rule, 0).map_err(EbpfError::MapOperationError)?;
map.insert(ip, rule, 0).map_err(EbpfError::MapOperationError)?;
Ok(())
}
fn remove(&mut self, ip: T, port: Port) -> Result<(), Error> {
let Some(map) = self.map.as_mut() else {
return Err(EbpfError::NotLoaded.into());
};
if port == 0 {
self.map.remove(&ip).map_err(EbpfError::MapOperationError)?;
map.remove(&ip).map_err(EbpfError::MapOperationError)?;
return Ok(());
}
let mut rule = self.map.get(&ip, 0).map_err(|_| EbpfError::IpDoesNotExist)?;
let mut rule = map.get(&ip, 0).map_err(|_| EbpfError::IpDoesNotExist)?;
if rule.is_match_all() {
self.map.remove(&ip).map_err(EbpfError::MapOperationError)?;
map.remove(&ip).map_err(EbpfError::MapOperationError)?;
return Ok(());
}
rule.remove_port(port);
if rule.is_empty() {
self.map.remove(&ip).map_err(EbpfError::MapOperationError)?;
map.remove(&ip).map_err(EbpfError::MapOperationError)?;
} else {
self.map.insert(ip, rule, 0).map_err(EbpfError::MapOperationError)?;
map.insert(ip, rule, 0).map_err(EbpfError::MapOperationError)?;
}
Ok(())
}

View File

@ -1,46 +1,59 @@
use std::collections::HashSet;
use core::str;
use common::model::dns_name::DnsName;
use parking_lot::RwLock;
use dashmap::DashSet;
use crate::interface::port::dns_filter_api::DnsFilterPort;
use crate::interface::port::dns_query_filter::DnsQueryFilter;
use crate::model::error::Error;
use crate::model::error::misc::MiscError;
pub struct DnsFilter {
blacklist: RwLock<HashSet<DnsName>>,
blacklist: DashSet<DnsName>,
}
impl DnsFilter {
pub fn new() -> Self {
Self {
blacklist: RwLock::new(HashSet::new()),
blacklist: DashSet::new(),
}
}
pub fn add_domain(&self, domain: &str) -> Result<(), Error> {
let name = domain_to_wire_format(domain)?;
self.blacklist.write().insert(name);
self.blacklist.insert(name);
Ok(())
}
pub fn remove_domain(&self, domain: &str) -> Result<(), Error> {
let name = domain_to_wire_format(domain)?;
self.blacklist.write().remove(&name);
self.blacklist.remove(&name);
Ok(())
}
pub fn list_domains(&self) -> Vec<String> {
self.blacklist.read().iter().filter_map(wire_format_to_domain).collect()
self.blacklist
.iter()
.filter_map(|entry| wire_format_to_domain(&entry))
.collect()
}
/// Fast-path helper combining `parse_query_name` + `is_blacklisted` — used
/// by the AF_XDP RX loop.
pub fn is_query_blacklisted(&self, raw: &[u8]) -> bool {
match Self::parse_query_name(raw) {
Some((name, name_len)) => self.is_blacklisted(&name, name_len),
None => false,
}
}
/// Check if a DNS query name (in wire format) or any of its parent domains is blacklisted.
pub fn is_blacklisted(&self, name: &DnsName, name_len: usize) -> bool {
let bl = self.blacklist.read();
if bl.is_empty() {
if self.blacklist.is_empty() {
return false;
}
// Check exact match
if bl.contains(name) {
if self.blacklist.contains(name) {
return true;
}
// Check parent domains
@ -64,7 +77,7 @@ impl DnsFilter {
let mut parent = DnsName::zeroed();
let remaining = name_len - offset;
parent.data[..remaining.min(128)].copy_from_slice(&name.data[offset..offset + remaining.min(128)]);
if bl.contains(&parent) {
if self.blacklist.contains(&parent) {
return true;
}
}
@ -176,6 +189,24 @@ impl DnsFilter {
}
}
impl DnsFilterPort for DnsFilter {
fn add_domain(&self, domain: &str) -> Result<(), Error> {
self.add_domain(domain)
}
fn remove_domain(&self, domain: &str) -> Result<(), Error> {
self.remove_domain(domain)
}
fn list_domains(&self) -> Vec<String> {
self.list_domains()
}
}
impl DnsQueryFilter for DnsFilter {
fn is_query_blacklisted(&self, raw: &[u8]) -> bool {
self.is_query_blacklisted(raw)
}
}
/// Convert a human-readable domain name (e.g., "example.com") to DNS wire format.
/// The result is a DnsName with lowercase, length-prefixed labels, zero-terminated and zero-padded.
fn domain_to_wire_format(domain: &str) -> Result<DnsName, Error> {
@ -187,16 +218,10 @@ fn domain_to_wire_format(domain: &str) -> Result<DnsName, Error> {
let label_bytes = label.as_bytes();
let label_len = label_bytes.len();
if label_len == 0 || label_len >= 64 {
return Err(MiscError::InvalidDnsName {
reason: format!("invalid label length: {}", label_len),
}
.into());
return Err(MiscError::DnsLabelOutOfRange(label_len).into());
}
if pos + 1 + label_len >= 128 {
return Err(MiscError::InvalidDnsName {
reason: format!("domain name too long: {}", domain),
}
.into());
return Err(MiscError::DnsDomainTooLong(domain).into());
}
name.data[pos] = label_len as u8;
pos += 1;
@ -229,7 +254,7 @@ fn wire_format_to_domain(name: &DnsName) -> Option<String> {
return None;
}
pos += 1;
let label = core::str::from_utf8(&name.data[pos..pos + label_len]).ok()?;
let label = str::from_utf8(&name.data[pos..pos + label_len]).ok()?;
labels.push(label.to_string());
pos += label_len;
}

View File

@ -1,20 +1,22 @@
use std::mem;
use std::net::Ipv6Addr;
use std::sync::Arc;
use std::sync::atomic::Ordering;
use std::time::Duration;
use aya::maps::{MapData, RingBuf};
use tokio::sync::{broadcast, oneshot};
use tokio::time::interval;
use common::define::drop_reason::*;
use common::model::drop_event::DropEvent as RawDropEvent;
use parking_lot::Mutex;
use crate::model::config::constants::DROP_CHANNEL_CAPACITY;
use crate::model::drop_event::{DropCounters, DropEventMessage};
use crate::model::monitoring::drop_event::{DropCounters, DropCountersAtomic, DropEventMessage};
pub struct DropMonitor {
broadcast_tx: broadcast::Sender<DropEventMessage>,
counters: Mutex<DropCounters>,
counters: DropCountersAtomic,
}
impl DropMonitor {
@ -22,7 +24,7 @@ impl DropMonitor {
let (tx, _) = broadcast::channel(DROP_CHANNEL_CAPACITY);
Self {
broadcast_tx: tx,
counters: Mutex::new(DropCounters::default()),
counters: DropCountersAtomic::default(),
}
}
@ -31,25 +33,47 @@ impl DropMonitor {
}
pub fn get_counters(&self) -> DropCounters {
self.counters.lock().clone()
self.counters.snapshot()
}
/// Record a userspace drop decision (XSK worker's DNS filter) by the
/// per-reason counter. Callers at this layer haven't parsed src/dst yet,
/// so no broadcast event is emitted — `/api/stats/drops` stays correct,
/// `/ws/drops` simply does not surface the individual packet. Parse the
/// packet upstream if you need a structured event.
pub fn record_userspace_drop_count_only(&self, reason: u8) {
self.counters.total.fetch_add(1, Ordering::Relaxed);
let bucket = match reason {
DROP_REASON_ACL_BLACKLIST => Some(&self.counters.acl_blacklist),
DROP_REASON_RATE_LIMIT_PKT => Some(&self.counters.rate_limit_pkt),
DROP_REASON_RATE_LIMIT_SYN => Some(&self.counters.rate_limit_syn),
DROP_REASON_RATE_LIMIT_UDP => Some(&self.counters.rate_limit_udp),
DROP_REASON_RATE_LIMIT_DNS => Some(&self.counters.rate_limit_dns),
DROP_REASON_PROTOCOL_FILTER => Some(&self.counters.protocol_filter),
DROP_REASON_DNS_BLACKLIST => Some(&self.counters.dns_blacklist),
DROP_REASON_GEO_BLOCK => Some(&self.counters.geo_block),
_ => None,
};
if let Some(counter) = bucket {
counter.fetch_add(1, Ordering::Relaxed);
}
}
fn process_event(&self, raw: &RawDropEvent) {
// Update counters
{
let mut c = self.counters.lock();
c.total += 1;
match raw.reason {
DROP_REASON_ACL_BLACKLIST => c.acl_blacklist += 1,
DROP_REASON_RATE_LIMIT_PKT => c.rate_limit_pkt += 1,
DROP_REASON_RATE_LIMIT_SYN => c.rate_limit_syn += 1,
DROP_REASON_RATE_LIMIT_UDP => c.rate_limit_udp += 1,
DROP_REASON_RATE_LIMIT_DNS => c.rate_limit_dns += 1,
DROP_REASON_PROTOCOL_FILTER => c.protocol_filter += 1,
DROP_REASON_DNS_BLACKLIST => c.dns_blacklist += 1,
DROP_REASON_GEO_BLOCK => c.geo_block += 1,
_ => {}
}
self.counters.total.fetch_add(1, Ordering::Relaxed);
let bucket = match raw.reason {
DROP_REASON_ACL_BLACKLIST => Some(&self.counters.acl_blacklist),
DROP_REASON_RATE_LIMIT_PKT => Some(&self.counters.rate_limit_pkt),
DROP_REASON_RATE_LIMIT_SYN => Some(&self.counters.rate_limit_syn),
DROP_REASON_RATE_LIMIT_UDP => Some(&self.counters.rate_limit_udp),
DROP_REASON_RATE_LIMIT_DNS => Some(&self.counters.rate_limit_dns),
DROP_REASON_PROTOCOL_FILTER => Some(&self.counters.protocol_filter),
DROP_REASON_DNS_BLACKLIST => Some(&self.counters.dns_blacklist),
DROP_REASON_GEO_BLOCK => Some(&self.counters.geo_block),
_ => None,
};
if let Some(counter) = bucket {
counter.fetch_add(1, Ordering::Relaxed);
}
let reason_str = reason_to_str(raw.reason);
@ -101,7 +125,7 @@ fn format_ips(raw: &RawDropEvent) -> (String, String) {
}
fn format_ipv6(bytes: &[u8; 16]) -> String {
std::net::Ipv6Addr::from(*bytes).to_string()
Ipv6Addr::from(*bytes).to_string()
}
fn reason_to_str(reason: u8) -> &'static str {
@ -124,7 +148,7 @@ pub async fn start_consumer(ring_buf: RingBuf<MapData>, monitor: Arc<DropMonitor
tokio::spawn(async move {
let mut ring_buf = ring_buf;
let mut interval = tokio::time::interval(Duration::from_millis(100));
let mut interval = interval(Duration::from_millis(100));
loop {
tokio::select! {

View File

@ -1,6 +1,7 @@
use std::collections::{HashMap as StdHashMap, HashSet};
use std::sync::Arc;
use arc_swap::ArcSwap;
use aya::Ebpf;
use aya::maps::MapData;
use aya::maps::lpm_trie::{Key, LpmTrie};
@ -9,6 +10,7 @@ use maxminddb::{Reader, geoip2};
use parking_lot::RwLock;
use crate::infrastructure::app_config::AppConfig;
use crate::interface::port::geo_block_api::GeoBlockPort;
use crate::model::error::Error;
use crate::model::error::ebpf::EbpfError;
use crate::model::error::misc::MiscError;
@ -20,9 +22,9 @@ struct GeoIndex {
}
pub struct GeoBlock {
geo_block_v4: RwLock<LpmTrie<MapData, u32, u8>>,
geo_block_v6: RwLock<LpmTrie<MapData, u128, u8>>,
blocked_countries: RwLock<HashSet<String>>,
geo_block_v4: RwLock<Option<LpmTrie<MapData, u32, u8>>>,
geo_block_v6: RwLock<Option<LpmTrie<MapData, u128, u8>>>,
blocked_countries: ArcSwap<HashSet<String>>,
index: Arc<GeoIndex>,
}
@ -35,26 +37,44 @@ impl GeoBlock {
let v6_trie = LpmTrie::try_from(v6_map).map_err(EbpfError::MapOperationError)?;
let db_path = &app_config.misc.geoip_db_name;
let reader = Reader::open_readfile(db_path).map_err(|e| MiscError::GeoIPDatabaseError {
path: db_path.clone(),
reason: e.to_string(),
})?;
let reader = Reader::open_readfile(db_path).map_err(|e| MiscError::GeoIPDatabaseError(db_path.clone(), e))?;
let index = Self::build_index(&reader)?;
Ok(Self {
geo_block_v4: RwLock::new(v4_trie),
geo_block_v6: RwLock::new(v6_trie),
blocked_countries: RwLock::new(HashSet::new()),
geo_block_v4: RwLock::new(Some(v4_trie)),
geo_block_v6: RwLock::new(Some(v6_trie)),
blocked_countries: ArcSwap::from_pointee(HashSet::new()),
index: Arc::new(index),
})
}
/// Construct a GeoBlock with no eBPF trie backing. Attempts to still load
/// the GeoIP index so the frontend can list what *would* be enforced;
/// mutating calls (`block_countries`, `unblock_countries`) return
/// `EbpfError::NotLoaded`.
pub fn unavailable(app_config: &AppConfig) -> Self {
let index = Reader::open_readfile(&app_config.misc.geoip_db_name)
.ok()
.and_then(|reader| Self::build_index(&reader).ok())
.unwrap_or(GeoIndex {
v4: StdHashMap::new(),
v6: StdHashMap::new(),
});
Self {
geo_block_v4: RwLock::new(None),
geo_block_v6: RwLock::new(None),
blocked_countries: ArcSwap::from_pointee(HashSet::new()),
index: Arc::new(index),
}
}
/// Build index from MaxMind DB at startup. One-time cost.
fn build_index(reader: &Reader<Vec<u8>>) -> Result<GeoIndex, Error> {
let mut v4: StdHashMap<String, Vec<(u32, u32)>> = StdHashMap::new();
let mut v6: StdHashMap<String, Vec<(u128, u32)>> = StdHashMap::new();
// SAFETY: "0.0.0.0/0" is a valid IPv4 CIDR literal, parse is infallible.
let ipv4_all: IpNetwork = "0.0.0.0/0".parse().unwrap();
if let Ok(iter) = reader.within(ipv4_all, Default::default()) {
for result in iter {
@ -73,6 +93,7 @@ impl GeoBlock {
}
}
// SAFETY: "::/0" is a valid IPv6 CIDR literal, parse is infallible.
let ipv6_all: IpNetwork = "::/0".parse().unwrap();
if let Ok(iter) = reader.within(ipv6_all, Default::default()) {
for result in iter {
@ -96,42 +117,44 @@ impl GeoBlock {
/// Block multiple countries at once, rebuilding tries only once.
pub fn block_countries(&self, country_codes: &[String]) -> Result<u64, Error> {
{
let mut countries = self.blocked_countries.write();
self.blocked_countries.rcu(|cur| {
let mut next: HashSet<String> = (**cur).clone();
for code in country_codes {
let upper = code.trim().to_uppercase();
if upper.len() == 2 && upper.chars().all(|c| c.is_ascii_alphabetic()) {
countries.insert(upper);
next.insert(upper);
}
}
}
next
});
self.rebuild_tries()
}
/// Unblock multiple countries at once, rebuilding tries only once.
pub fn unblock_countries(&self, country_codes: &[String]) -> Result<u64, Error> {
{
let mut countries = self.blocked_countries.write();
self.blocked_countries.rcu(|cur| {
let mut next: HashSet<String> = (**cur).clone();
for code in country_codes {
countries.remove(&code.trim().to_uppercase());
next.remove(&code.trim().to_uppercase());
}
}
next
});
self.rebuild_tries()
}
pub fn get_blocked_countries(&self) -> Vec<String> {
self.blocked_countries.read().iter().cloned().collect()
self.blocked_countries.load().iter().cloned().collect()
}
/// Rebuild LPM tries from pre-indexed data. Fast — no DB scan.
fn rebuild_tries(&self) -> Result<u64, Error> {
let countries = self.blocked_countries.read().clone();
let countries = self.blocked_countries.load_full();
// Collect entries from index (no DB scan)
let mut v4_entries: Vec<(Key<u32>, u8)> = Vec::new();
let mut v6_entries: Vec<(Key<u128>, u8)> = Vec::new();
for code in &countries {
for code in countries.iter() {
if let Some(prefixes) = self.index.v4.get(code) {
for &(ip_be, prefix_len) in prefixes {
v4_entries.push((Key::new(prefix_len, ip_be), 1u8));
@ -145,10 +168,14 @@ impl GeoBlock {
}
// Lock, clear, insert
let mut v4_trie = self.geo_block_v4.write();
let mut v6_trie = self.geo_block_v6.write();
Self::clear_trie_v4(&mut v4_trie);
Self::clear_trie_v6(&mut v6_trie);
let mut v4_guard = self.geo_block_v4.write();
let mut v6_guard = self.geo_block_v6.write();
let (v4_trie, v6_trie) = match (v4_guard.as_mut(), v6_guard.as_mut()) {
(Some(v4), Some(v6)) => (v4, v6),
_ => Err(EbpfError::NotLoaded)?,
};
Self::clear_trie_v4(v4_trie);
Self::clear_trie_v6(v6_trie);
let mut count = 0u64;
for (key, val) in &v4_entries {
@ -179,3 +206,15 @@ impl GeoBlock {
}
}
}
impl GeoBlockPort for GeoBlock {
fn block_countries(&self, codes: &[String]) -> Result<u64, Error> {
self.block_countries(codes)
}
fn unblock_countries(&self, codes: &[String]) -> Result<u64, Error> {
self.unblock_countries(codes)
}
fn list_blocked(&self) -> Vec<String> {
self.get_blocked_countries()
}
}

View File

@ -15,15 +15,16 @@ use macros::log;
use parking_lot::Mutex;
use tokio::sync::oneshot;
use crate::core::ebpf::access_control::AccessControl;
use crate::core::ebpf::dns_filter::DnsFilter;
use crate::core::ebpf::drop_monitor::DropMonitor;
use crate::core::ebpf::geo_block::GeoBlock;
use crate::core::ebpf::protocol_filter::ProtocolFilter;
use crate::core::ebpf::rate_limit::RateLimitConfig;
use crate::core::ebpf::xsk_manager::XskManager;
use crate::core::ml::engine::Engine;
use crate::adapter::ebpf::access_control::AccessControl;
use crate::adapter::ebpf::dns_filter::DnsFilter;
use crate::adapter::ebpf::drop_monitor::DropMonitor;
use crate::adapter::ebpf::geo_block::GeoBlock;
use crate::adapter::ebpf::protocol_filter::ProtocolFilter;
use crate::adapter::ebpf::rate_limit::RateLimitConfig;
use crate::adapter::ebpf::xsk_manager::XskManager;
use crate::infrastructure::app_config::AppConfig;
use crate::interface::port::dns_query_filter::DnsQueryFilter;
use crate::interface::port::packet_sink::PacketSinkFactory;
use crate::model::error::Error;
use crate::model::error::ebpf::EbpfError;
use crate::model::error::system::SystemError;
@ -66,9 +67,32 @@ impl EbpfServices {
})
}
pub async fn run(self: Arc<Self>, ml_engine: Arc<Engine>) -> Result<(), Error> {
/// Build an EbpfServices with every eBPF-backed subservice in the
/// "unavailable" state. Used when eBPF failed to load at startup.
/// Queries return empty results; mutating calls return `EbpfError::NotLoaded`.
pub fn unavailable(app_config: Arc<AppConfig>) -> Self {
Self {
xsk_manager: Arc::new(XskManager::unavailable(app_config.clone())),
access_control: Arc::new(AccessControl::unavailable()),
protocol_filter: Arc::new(ProtocolFilter::unavailable()),
dns_filter: Arc::new(DnsFilter::new()),
geo_block: Arc::new(GeoBlock::unavailable(&app_config)),
rate_limit: Arc::new(RateLimitConfig::unavailable()),
drop_monitor: Arc::new(DropMonitor::new()),
drop_ring_buf: Mutex::new(None),
shutdowns: SegQueue::new(),
}
}
pub async fn run(self: Arc<Self>, sink_factory: Arc<dyn PacketSinkFactory>) -> Result<(), Error> {
let xsk_manager = self.xsk_manager.clone();
xsk_manager.run(Some(ml_engine), Some(self.dns_filter.clone()), &self.shutdowns)?;
let dns: Arc<dyn DnsQueryFilter> = self.dns_filter.clone();
xsk_manager.run(
Some(sink_factory),
Some(dns),
Some(self.drop_monitor.clone()),
&self.shutdowns,
)?;
let ring_buf = self.drop_ring_buf.lock().take();
if let Some(ring_buf) = ring_buf {

View File

@ -0,0 +1,309 @@
use std::collections::HashMap;
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddrV4, SocketAddrV6};
use aya::maps::{Array as AyaArray, HashMap as AyaHashMap, MapData};
use aya::{Ebpf, Pod};
use common::model::http_method::{HttpMethod, HttpMethodBitmap};
use common::model::ip_address::{AddrPortV4, AddrPortV6, IPv4, IPv6};
use common::model::placeholder::PlaceHolder;
use parking_lot::RwLock;
use crate::model::access_control::ip_address::NativeConvert;
use crate::model::error::Error;
use crate::model::error::ebpf::EbpfError;
pub struct ProtocolFilter {
ipv4_http_service: RwLock<HttpServiceWrapper<AddrPortV4>>,
ipv6_http_service: RwLock<HttpServiceWrapper<AddrPortV6>>,
ssh_white_list_enable: RwLock<WhiteListControl>,
ipv4_ssh_service: RwLock<EntryMap<AddrPortV4>>,
ipv6_ssh_service: RwLock<EntryMap<AddrPortV6>>,
ipv4_ssh_white_list: RwLock<EntryMap<IPv4>>,
ipv6_ssh_white_list: RwLock<EntryMap<IPv6>>,
ipv4_ssh_black_list: RwLock<EntryMap<IPv4>>,
ipv6_ssh_black_list: RwLock<EntryMap<IPv6>>,
}
impl ProtocolFilter {
pub fn new(ebpf: &mut Ebpf) -> Result<Self, Error> {
let service = Self {
ipv4_http_service: RwLock::new(HttpServiceWrapper::new(ebpf, "IPV4_HTTP_SERVICE")?),
ipv6_http_service: RwLock::new(HttpServiceWrapper::new(ebpf, "IPV6_HTTP_SERVICE")?),
ssh_white_list_enable: RwLock::new(WhiteListControl::new(ebpf, "SSH_WHITE_LIST_ENABLE")?),
ipv4_ssh_service: RwLock::new(EntryMap::new(ebpf, "IPV4_SSH_SERVICE")?),
ipv6_ssh_service: RwLock::new(EntryMap::new(ebpf, "IPV6_SSH_SERVICE")?),
ipv4_ssh_white_list: RwLock::new(EntryMap::new(ebpf, "IPV4_SSH_WHITE_LIST")?),
ipv6_ssh_white_list: RwLock::new(EntryMap::new(ebpf, "IPV6_SSH_WHITE_LIST")?),
ipv4_ssh_black_list: RwLock::new(EntryMap::new(ebpf, "IPV4_SSH_BLACK_LIST")?),
ipv6_ssh_black_list: RwLock::new(EntryMap::new(ebpf, "IPV6_SSH_BLACK_LIST")?),
};
Ok(service)
}
/// Construct a ProtocolFilter backed by no eBPF maps.
pub fn unavailable() -> Self {
Self {
ipv4_http_service: RwLock::new(HttpServiceWrapper::unavailable()),
ipv6_http_service: RwLock::new(HttpServiceWrapper::unavailable()),
ssh_white_list_enable: RwLock::new(WhiteListControl::unavailable()),
ipv4_ssh_service: RwLock::new(EntryMap::unavailable()),
ipv6_ssh_service: RwLock::new(EntryMap::unavailable()),
ipv4_ssh_white_list: RwLock::new(EntryMap::unavailable()),
ipv6_ssh_white_list: RwLock::new(EntryMap::unavailable()),
ipv4_ssh_black_list: RwLock::new(EntryMap::unavailable()),
ipv6_ssh_black_list: RwLock::new(EntryMap::unavailable()),
}
}
pub fn get_ipv4_http_service(&self) -> HashMap<SocketAddrV4, Vec<HttpMethod>> {
self.ipv4_http_service.read().get_http_method()
}
pub fn get_ipv6_http_service(&self) -> HashMap<SocketAddrV6, Vec<HttpMethod>> {
self.ipv6_http_service.read().get_http_method()
}
pub fn add_ipv4_http_service(&self, address: SocketAddrV4, http_method: Vec<HttpMethod>) -> Result<(), Error> {
self.ipv4_http_service.write().add_http_service(address, http_method)
}
pub fn add_ipv6_http_service(&self, address: SocketAddrV6, http_method: Vec<HttpMethod>) -> Result<(), Error> {
self.ipv6_http_service.write().add_http_service(address, http_method)
}
pub fn remove_ipv4_http_service(
&self,
address: SocketAddrV4,
removed_http_method: Vec<HttpMethod>,
) -> Result<(), Error> {
self.ipv4_http_service
.write()
.remove_http_service(address, removed_http_method)
}
pub fn remove_ipv6_http_service(
&self,
address: SocketAddrV6,
removed_http_method: Vec<HttpMethod>,
) -> Result<(), Error> {
self.ipv6_http_service
.write()
.remove_http_service(address, removed_http_method)
}
pub fn is_ssh_white_list_enable(&self) -> bool {
self.ssh_white_list_enable.read().is_white_list_enable()
}
pub fn enable_ssh_white_list(&self) -> Result<(), Error> {
self.ssh_white_list_enable.write().enable_white_list()
}
pub fn disable_ssh_white_list(&self) -> Result<(), Error> {
self.ssh_white_list_enable.write().disable_white_list()
}
pub fn get_ipv4_ssh_service(&self) -> Vec<SocketAddrV4> {
self.ipv4_ssh_service.read().get_all()
}
pub fn get_ipv6_ssh_service(&self) -> Vec<SocketAddrV6> {
self.ipv6_ssh_service.read().get_all()
}
pub fn add_ipv4_ssh_service(&self, address: SocketAddrV4) -> Result<(), Error> {
self.ipv4_ssh_service.write().add(address)
}
pub fn add_ipv6_ssh_service(&self, address: SocketAddrV6) -> Result<(), Error> {
self.ipv6_ssh_service.write().add(address)
}
pub fn remove_ipv4_ssh_service(&self, address: SocketAddrV4) -> Result<(), Error> {
self.ipv4_ssh_service.write().remove(address)
}
pub fn remove_ipv6_ssh_service(&self, address: SocketAddrV6) -> Result<(), Error> {
self.ipv6_ssh_service.write().remove(address)
}
pub fn get_ipv4_ssh_white_list(&self) -> Vec<Ipv4Addr> {
self.ipv4_ssh_white_list.read().get_all()
}
pub fn get_ipv6_ssh_white_list(&self) -> Vec<Ipv6Addr> {
self.ipv6_ssh_white_list.read().get_all()
}
pub fn add_ipv4_ssh_white_list(&self, ip: Ipv4Addr) -> Result<(), Error> {
self.ipv4_ssh_white_list.write().add(ip)
}
pub fn add_ipv6_ssh_white_list(&self, ip: Ipv6Addr) -> Result<(), Error> {
self.ipv6_ssh_white_list.write().add(ip)
}
pub fn remove_ipv4_ssh_white_list(&self, ip: Ipv4Addr) -> Result<(), Error> {
self.ipv4_ssh_white_list.write().remove(ip)
}
pub fn remove_ipv6_ssh_white_list(&self, ip: Ipv6Addr) -> Result<(), Error> {
self.ipv6_ssh_white_list.write().remove(ip)
}
pub fn get_ipv4_ssh_black_list(&self) -> Vec<Ipv4Addr> {
self.ipv4_ssh_black_list.read().get_all()
}
pub fn get_ipv6_ssh_black_list(&self) -> Vec<Ipv6Addr> {
self.ipv6_ssh_black_list.read().get_all()
}
pub fn add_ipv4_ssh_black_list(&self, ip: Ipv4Addr) -> Result<(), Error> {
self.ipv4_ssh_black_list.write().add(ip)
}
pub fn add_ipv6_ssh_black_list(&self, ip: Ipv6Addr) -> Result<(), Error> {
self.ipv6_ssh_black_list.write().add(ip)
}
pub fn remove_ipv4_ssh_black_list(&self, ip: Ipv4Addr) -> Result<(), Error> {
self.ipv4_ssh_black_list.write().remove(ip)
}
pub fn remove_ipv6_ssh_black_list(&self, ip: Ipv6Addr) -> Result<(), Error> {
self.ipv6_ssh_black_list.write().remove(ip)
}
}
struct WhiteListControl {
map: Option<AyaArray<MapData, PlaceHolder>>,
}
impl WhiteListControl {
fn new(ebpf: &mut Ebpf, map_name: &str) -> Result<Self, Error> {
let map = ebpf.take_map(map_name).ok_or(EbpfError::MapNotFound)?;
let map = AyaArray::try_from(map).map_err(EbpfError::MapOperationError)?;
Ok(Self { map: Some(map) })
}
fn unavailable() -> Self {
Self { map: None }
}
fn is_white_list_enable(&self) -> bool {
let Some(map) = self.map.as_ref() else {
return false;
};
match map.get(&0, 0) {
Ok(status) => status != 0,
Err(_) => false,
}
}
fn enable_white_list(&mut self) -> Result<(), Error> {
let map = self.map.as_mut().ok_or(EbpfError::NotLoaded)?;
map.set(0, 1_u8, 0).map_err(EbpfError::MapOperationError)?;
Ok(())
}
fn disable_white_list(&mut self) -> Result<(), Error> {
let map = self.map.as_mut().ok_or(EbpfError::NotLoaded)?;
map.set(0, 0_u8, 0).map_err(EbpfError::MapOperationError)?;
Ok(())
}
}
struct HttpServiceWrapper<T> {
map: Option<AyaHashMap<MapData, T, HttpMethodBitmap>>,
}
impl<T: NativeConvert + Pod> HttpServiceWrapper<T> {
fn new(ebpf: &mut Ebpf, map_name: &str) -> Result<Self, Error> {
let map = ebpf.take_map(map_name).ok_or(EbpfError::MapNotFound)?;
let map = AyaHashMap::try_from(map).map_err(EbpfError::MapOperationError)?;
Ok(Self { map: Some(map) })
}
fn unavailable() -> Self {
Self { map: None }
}
fn get_http_method(&self) -> HashMap<T::Native, Vec<HttpMethod>> {
let Some(map) = self.map.as_ref() else {
return HashMap::new();
};
map.iter()
.filter_map(Result::ok)
.map(|(key, value)| {
let address = key.into_native();
(address, HttpMethod::convert_from_bitmap(value))
})
.collect()
}
fn add_http_service(&mut self, address: T::Native, http_method: Vec<HttpMethod>) -> Result<(), Error> {
let map = self.map.as_mut().ok_or(EbpfError::NotLoaded)?;
let address = T::from_native(address);
let ebpf_method = HttpMethod::convert_to_bitmap(http_method);
map.insert(address, ebpf_method, 0)
.map_err(EbpfError::MapOperationError)?;
Ok(())
}
fn remove_http_service(&mut self, address: T::Native, removed_http_method: Vec<HttpMethod>) -> Result<(), Error> {
let map = self.map.as_mut().ok_or(EbpfError::NotLoaded)?;
let address = T::from_native(address);
if let Ok(current_http_method) = map.get(&address, 0) {
let mut http_method = HttpMethod::convert_from_bitmap(current_http_method);
http_method.retain(|method| !removed_http_method.contains(method));
if http_method.is_empty() {
map.remove(&address).map_err(EbpfError::MapOperationError)?;
} else {
let new_http_method = HttpMethod::convert_to_bitmap(http_method);
map.insert(address, new_http_method, 0)
.map_err(EbpfError::MapOperationError)?;
}
Ok(())
} else {
Err(EbpfError::IpDoesNotExist)?
}
}
}
struct EntryMap<T> {
map: Option<AyaHashMap<MapData, T, PlaceHolder>>,
}
impl<T: NativeConvert + Pod> EntryMap<T> {
fn new(ebpf: &mut Ebpf, map_name: &str) -> Result<Self, Error> {
let map = ebpf.take_map(map_name).ok_or(EbpfError::MapNotFound)?;
let map = AyaHashMap::try_from(map).map_err(EbpfError::MapOperationError)?;
Ok(Self { map: Some(map) })
}
fn unavailable() -> Self {
Self { map: None }
}
fn get_all(&self) -> Vec<T::Native> {
let Some(map) = self.map.as_ref() else {
return Vec::new();
};
map.keys().filter_map(Result::ok).map(|key| key.into_native()).collect()
}
fn add(&mut self, key: T::Native) -> Result<(), Error> {
let map = self.map.as_mut().ok_or(EbpfError::NotLoaded)?;
let key = T::from_native(key);
map.insert(key, 0_u8, 0).map_err(EbpfError::MapOperationError)?;
Ok(())
}
fn remove(&mut self, key: T::Native) -> Result<(), Error> {
let map = self.map.as_mut().ok_or(EbpfError::NotLoaded)?;
let key = T::from_native(key);
map.remove(&key).map_err(EbpfError::MapOperationError)?;
Ok(())
}
}

View File

@ -0,0 +1,113 @@
use aya::Ebpf;
use aya::maps::{Array, MapData};
use parking_lot::Mutex;
use crate::interface::port::rate_limit_api::RateLimitPort;
use crate::model::error::Error;
use crate::model::error::ebpf::EbpfError;
pub struct RateLimitConfig {
config_map: Mutex<Option<Array<MapData, u64>>>,
}
impl RateLimitConfig {
pub fn new(ebpf: &mut Ebpf) -> Result<Self, Error> {
let map = ebpf.take_map("RATE_LIMIT_CONFIG").ok_or(EbpfError::MapNotFound)?;
let config_map = Array::try_from(map).map_err(EbpfError::MapOperationError)?;
Ok(Self {
config_map: Mutex::new(Some(config_map)),
})
}
pub fn unavailable() -> Self {
Self {
config_map: Mutex::new(None),
}
}
fn set_at(&self, index: u32, value: u64) -> Result<(), Error> {
let mut guard = self.config_map.lock();
let map = guard.as_mut().ok_or(EbpfError::NotLoaded)?;
map.set(index, value, 0).map_err(EbpfError::MapOperationError)?;
Ok(())
}
fn get_at(&self, index: u32) -> Result<u64, Error> {
let guard = self.config_map.lock();
let map = guard.as_ref().ok_or(EbpfError::NotLoaded)?;
map.get(&index, 0).map_err(|e| EbpfError::MapOperationError(e).into())
}
pub fn set_packet_rate(&self, rate: u64) -> Result<(), Error> {
self.set_at(0, rate)
}
pub fn set_syn_rate(&self, rate: u64) -> Result<(), Error> {
self.set_at(1, rate)
}
pub fn set_udp_rate(&self, rate: u64) -> Result<(), Error> {
self.set_at(2, rate)
}
pub fn set_dns_rate(&self, rate: u64) -> Result<(), Error> {
self.set_at(3, rate)
}
pub fn set_window_ns(&self, ns: u64) -> Result<(), Error> {
self.set_at(4, ns)
}
pub fn get_packet_rate(&self) -> Result<u64, Error> {
self.get_at(0)
}
pub fn get_syn_rate(&self) -> Result<u64, Error> {
self.get_at(1)
}
pub fn get_udp_rate(&self) -> Result<u64, Error> {
self.get_at(2)
}
pub fn get_dns_rate(&self) -> Result<u64, Error> {
self.get_at(3)
}
pub fn get_window_ns(&self) -> Result<u64, Error> {
self.get_at(4)
}
}
impl RateLimitPort for RateLimitConfig {
fn set_packet_rate(&self, rate: u64) -> Result<(), Error> {
self.set_packet_rate(rate)
}
fn set_syn_rate(&self, rate: u64) -> Result<(), Error> {
self.set_syn_rate(rate)
}
fn set_udp_rate(&self, rate: u64) -> Result<(), Error> {
self.set_udp_rate(rate)
}
fn set_dns_rate(&self, rate: u64) -> Result<(), Error> {
self.set_dns_rate(rate)
}
fn set_window_ns(&self, ns: u64) -> Result<(), Error> {
self.set_window_ns(ns)
}
fn get_packet_rate(&self) -> Result<u64, Error> {
self.get_packet_rate()
}
fn get_syn_rate(&self) -> Result<u64, Error> {
self.get_syn_rate()
}
fn get_udp_rate(&self) -> Result<u64, Error> {
self.get_udp_rate()
}
fn get_dns_rate(&self) -> Result<u64, Error> {
self.get_dns_rate()
}
fn get_window_ns(&self) -> Result<u64, Error> {
self.get_window_ns()
}
}

View File

@ -1,5 +1,5 @@
use std::ffi::CString;
use std::io::Write;
use std::io::{ErrorKind, Write};
use std::num::NonZero;
use std::os::fd::AsRawFd;
use std::sync::Arc;
@ -8,24 +8,26 @@ use std::time::Duration;
use aya::Ebpf;
use aya::maps::{MapData, XskMap};
use crossbeam::channel::{Receiver, Sender, bounded};
use crossbeam::channel::{Receiver, Sender, TrySendError, bounded};
use crossbeam::queue::SegQueue;
use macros::log;
use parking_lot::Mutex;
use tokio::sync::oneshot;
use tokio::sync::oneshot::{self, error::TryRecvError};
use xsk_rs::config::{BindFlags, FrameSize, Interface, LibxdpFlags, QueueSize, SocketConfig, UmemConfig};
use xsk_rs::{CompQueue, FillQueue, FrameDesc, RxQueue, Socket, TxQueue, Umem};
use crate::core::ebpf::dns_filter::DnsFilter;
use crate::core::ml::engine::Engine;
use crate::core::ml::flow_tracker::FlowTracker;
use common::define::drop_reason::DROP_REASON_DNS_BLACKLIST;
use crate::adapter::ebpf::drop_monitor::DropMonitor;
use crate::infrastructure::app_config::AppConfig;
use crate::model::config::NetworkConfig;
use crate::model::direction::Direction;
use crate::interface::port::dns_query_filter::DnsQueryFilter;
use crate::interface::port::packet_sink::{PacketSink, PacketSinkFactory};
use crate::model::error::Error;
use crate::model::error::ebpf::EbpfError;
use crate::model::error::system::SystemError;
use crate::model::log::ebpf::EbpfLog;
use crate::model::monitoring::direction::Direction;
use crate::model::system::config::NetworkConfig;
use crate::utils::packet_parser::parse_packet;
/// Pre-allocated buffer pool to avoid per-packet malloc.
@ -61,8 +63,8 @@ impl BufferPool {
pub struct XskManager {
app_config: Arc<AppConfig>,
xsk_map: Mutex<XskMap<MapData>>,
egress_xsk_map: Mutex<XskMap<MapData>>,
xsk_map: Mutex<Option<XskMap<MapData>>>,
egress_xsk_map: Mutex<Option<XskMap<MapData>>>,
}
impl XskManager {
@ -77,17 +79,33 @@ impl XskManager {
Ok(Self {
app_config,
xsk_map: Mutex::new(xsk_map),
egress_xsk_map: Mutex::new(egress_xsk_map),
xsk_map: Mutex::new(Some(xsk_map)),
egress_xsk_map: Mutex::new(Some(egress_xsk_map)),
})
}
pub fn unavailable(app_config: Arc<AppConfig>) -> Self {
Self {
app_config,
xsk_map: Mutex::new(None),
egress_xsk_map: Mutex::new(None),
}
}
pub fn run(
&self,
ml_engine: Option<Arc<Engine>>,
dns_filter: Option<Arc<DnsFilter>>,
sinks: Option<Arc<dyn PacketSinkFactory>>,
dns_filter: Option<Arc<dyn DnsQueryFilter>>,
drop_monitor: Option<Arc<DropMonitor>>,
shutdowns: &SegQueue<oneshot::Sender<()>>,
) -> Result<(), Error> {
// If eBPF failed to load, there are no XSK maps to bind and no queues
// to start — skip silently. AF_XDP would have no maps to attach sockets
// to, and ML sees no packets, which is the designed behaviour.
if self.xsk_map.lock().is_none() || self.egress_xsk_map.lock().is_none() {
return Ok(());
}
let network = self.app_config.network.clone();
let combined_queue_count = network.combined_queue_count;
@ -95,7 +113,7 @@ impl XskManager {
let (ingress_to_egress_tx, ingress_to_egress_rx) = bounded(network.channel_size);
let (egress_to_ingress_tx, egress_to_ingress_rx) = bounded(network.channel_size);
let tracker = ml_engine.as_ref().map(|engine| engine.tracker(queue_id).clone());
let sink = sinks.as_ref().and_then(|f| f.sink_for_queue(queue_id));
let ingress_xsk = XskPair::new(
network.clone(),
@ -103,8 +121,9 @@ impl XskManager {
&network.ingress_ifname,
&network.egress_ifname,
Direction::Ingress,
tracker.clone(),
sink.clone(),
dns_filter.clone(),
drop_monitor.clone(),
)?;
let egress_xsk = XskPair::new(
@ -113,12 +132,15 @@ impl XskManager {
&network.egress_ifname,
&network.ingress_ifname,
Direction::Egress,
tracker,
sink,
None,
drop_monitor.clone(),
)?;
let mut xsk_map = self.xsk_map.lock();
let mut egress_xsk_map = self.egress_xsk_map.lock();
let mut xsk_guard = self.xsk_map.lock();
let mut egress_guard = self.egress_xsk_map.lock();
let xsk_map = xsk_guard.as_mut().ok_or(EbpfError::NotLoaded)?;
let egress_xsk_map = egress_guard.as_mut().ok_or(EbpfError::NotLoaded)?;
let ingress_fd = ingress_xsk.rx.fd().as_raw_fd();
xsk_map
@ -130,8 +152,8 @@ impl XskManager {
.set(queue_id, egress_fd, 0)
.map_err(EbpfError::AfXdpSetFailed)?;
drop(xsk_map);
drop(egress_xsk_map);
drop(xsk_guard);
drop(egress_guard);
let ingress_shutdown = ingress_xsk.run(ingress_to_egress_tx, egress_to_ingress_rx)?;
shutdowns.push(ingress_shutdown);
@ -154,21 +176,24 @@ pub struct XskPair {
tx: TxQueue,
rx: RxQueue,
frame_pool: Vec<FrameDesc>,
tracker: Option<Arc<Mutex<FlowTracker>>>,
dns_filter: Option<Arc<DnsFilter>>,
sink: Option<Arc<dyn PacketSink>>,
dns_filter: Option<Arc<dyn DnsQueryFilter>>,
drop_monitor: Option<Arc<DropMonitor>>,
packet_buffer_size: usize,
buffer_pool_capacity: usize,
}
impl XskPair {
#[allow(clippy::too_many_arguments)]
pub fn new(
config: NetworkConfig,
queue_id: u32,
rx_ifname: &str,
_tx_ifname: &str,
direction: Direction,
tracker: Option<Arc<Mutex<FlowTracker>>>,
dns_filter: Option<Arc<DnsFilter>>,
sink: Option<Arc<dyn PacketSink>>,
dns_filter: Option<Arc<dyn DnsQueryFilter>>,
drop_monitor: Option<Arc<DropMonitor>>,
) -> Result<Self, Error> {
let rx_ifname_c = CString::new(rx_ifname).map_err(|_| SystemError::InvalidConfig)?;
@ -210,7 +235,7 @@ impl XskPair {
let submitted = unsafe { fill_queue.produce(&fill_frames) };
if submitted != fill_frames.len() {
return Err(EbpfError::FillQueueInitFailed.into());
Err(EbpfError::FillQueueInitFailed)?;
}
let pool_frames: Vec<FrameDesc> = frame_descs.iter().skip(fill_frames_count).copied().collect();
@ -223,8 +248,9 @@ impl XskPair {
tx,
rx,
frame_pool: pool_frames,
tracker,
sink,
dns_filter,
drop_monitor,
packet_buffer_size: config.packet_buffer_size,
buffer_pool_capacity: config.buffer_pool_capacity,
};
@ -253,10 +279,10 @@ impl XskPair {
loop {
if let Some(ref mut rx) = shutdown_rx {
match rx.try_recv() {
Ok(_) | Err(oneshot::error::TryRecvError::Closed) => {
Ok(_) | Err(TryRecvError::Closed) => {
break;
}
Err(oneshot::error::TryRecvError::Empty) => {}
Err(TryRecvError::Empty) => {}
}
}
@ -337,20 +363,25 @@ impl XskPair {
let raw = &contents[..packet_len];
// DNS blacklist check — drop blacklisted DNS queries before forwarding
// DNS blacklist check — drop blacklisted DNS queries before forwarding.
// Report to DropMonitor so `/api/stats/drops` and `/ws/drops`
// reflect userspace-decided drops (the kernel eBPF never saw
// this packet's DNS payload, so it emits no DROP_EVENTS entry).
if let Some(ref dns) = self.dns_filter
&& let Some((dns_name, name_len)) = DnsFilter::parse_query_name(raw)
&& dns.is_blacklisted(&dns_name, name_len)
&& dns.is_query_blacklisted(raw)
{
if let Some(ref monitor) = self.drop_monitor {
monitor.record_userspace_drop_count_only(DROP_REASON_DNS_BLACKLIST);
}
continue;
}
// Parse directly from UMEM (zero-copy for ML path).
// Only clone for the forwarding path afterwards.
if let Some(ref tracker) = self.tracker
if let Some(ref sink) = self.sink
&& let Some((packet_info, _)) = parse_packet(raw)
{
tracker.lock().process_packet(packet_info, is_ingress);
sink.process_packet(packet_info, is_ingress);
}
// Clone into pooled buffer for forwarding
@ -358,11 +389,11 @@ impl XskPair {
buf.extend_from_slice(raw);
if let Err(e) = forward_tx.try_send(buf) {
match e {
crossbeam::channel::TrySendError::Full(returned) => {
TrySendError::Full(returned) => {
buffer_pool.put(returned);
log!(EbpfLog::ForwardChannelFull);
}
crossbeam::channel::TrySendError::Disconnected(returned) => {
TrySendError::Disconnected(returned) => {
buffer_pool.put(returned);
log!(EbpfLog::ForwardChannelDisconnected);
}
@ -424,7 +455,6 @@ impl XskPair {
return Ok(0);
}
let sent_count = frames.len();
for (frame, packet) in frames.iter_mut().zip(packets_to_send.iter()) {
unsafe {
self.umem
@ -445,13 +475,17 @@ impl XskPair {
}
if let Err(e) = self.tx.wakeup()
&& e.kind() != std::io::ErrorKind::WouldBlock
&& e.kind() != ErrorKind::WouldBlock
{
log!(EbpfLog::TXWakeupFailed(e.to_string()));
}
// Log dropped packets when frames < packets
let dropped = total_packets - sent_count;
// Drop accounting: a packet is dropped whenever we couldn't put it
// on the TX ring. That includes both the frame-pool-exhausted path
// (frames.len() < total_packets) and the TX-ring backpressure path
// (nb_submitted < frames.len()). Using `nb_submitted` as the sent
// count covers both.
let dropped = total_packets - nb_submitted;
if dropped > 0 {
log!(EbpfLog::FramePoolExhausted(dropped));
}

View File

@ -4,8 +4,8 @@ use actix_web::{HttpResponse, Responder, Scope, web};
use serde::Deserialize;
use crate::core::acl_service::AclService;
use crate::model::direction::FlowDirection;
use crate::model::list_type::ListType;
use crate::model::access_control::list_type::ListType;
use crate::model::monitoring::direction::FlowDirection;
#[derive(Deserialize)]
struct CountryCodesRequest {
@ -27,13 +27,13 @@ pub fn initialize() -> Scope {
async fn get_ipv4_list(path: web::Path<(FlowDirection, ListType)>, acl: web::Data<AclService>) -> impl Responder {
let (direction, list_type) = path.into_inner();
let list = acl.access_control().get_ipv4_list(direction, list_type).await;
let list = acl.access_control().get_ipv4_list(direction, list_type);
HttpResponse::Ok().json(list)
}
async fn get_ipv6_list(path: web::Path<(FlowDirection, ListType)>, acl: web::Data<AclService>) -> impl Responder {
let (direction, list_type) = path.into_inner();
let list = acl.access_control().get_ipv6_list(direction, list_type).await;
let list = acl.access_control().get_ipv6_list(direction, list_type);
HttpResponse::Ok().json(list)
}
@ -43,7 +43,7 @@ async fn add_ipv4_list(
acl: web::Data<AclService>,
) -> impl Responder {
let (direction, list_type) = path.into_inner();
match acl.add_ipv4(direction, list_type, address.into_inner()).await {
match acl.add_ipv4(direction, list_type, address.into_inner()) {
Ok(_) => HttpResponse::Ok().finish(),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
@ -55,7 +55,7 @@ async fn add_ipv6_list(
acl: web::Data<AclService>,
) -> impl Responder {
let (direction, list_type) = path.into_inner();
match acl.add_ipv6(direction, list_type, address.into_inner()).await {
match acl.add_ipv6(direction, list_type, address.into_inner()) {
Ok(_) => HttpResponse::Ok().finish(),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
@ -67,7 +67,7 @@ async fn remove_ipv4_list(
acl: web::Data<AclService>,
) -> impl Responder {
let (direction, list_type) = path.into_inner();
match acl.remove_ipv4(direction, list_type, address.into_inner()).await {
match acl.remove_ipv4(direction, list_type, address.into_inner()) {
Ok(_) => HttpResponse::Ok().finish(),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
@ -79,7 +79,7 @@ async fn remove_ipv6_list(
acl: web::Data<AclService>,
) -> impl Responder {
let (direction, list_type) = path.into_inner();
match acl.remove_ipv6(direction, list_type, address.into_inner()).await {
match acl.remove_ipv6(direction, list_type, address.into_inner()) {
Ok(_) => HttpResponse::Ok().finish(),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}

View File

@ -2,7 +2,7 @@ use actix_web::{HttpResponse, Scope, web};
use serde::Deserialize;
use crate::core::auth::extractor::AuthClaims;
use crate::interface::port::api_key::ApiKeyPort;
use crate::interface::port::api_key::ApiKeyRepo;
pub fn initialize() -> Scope {
web::scope("/api-keys")
@ -11,7 +11,7 @@ pub fn initialize() -> Scope {
.route("/{id}", web::delete().to(delete_key))
}
async fn list_keys(_auth: AuthClaims, db: web::Data<dyn ApiKeyPort>) -> HttpResponse {
async fn list_keys(_auth: AuthClaims, db: web::Data<dyn ApiKeyRepo>) -> HttpResponse {
match db.list_api_keys() {
Ok(keys) => {
let responses: Vec<serde_json::Value> = keys
@ -40,7 +40,7 @@ struct GenerateKeyRequest {
async fn generate_key(
_auth: AuthClaims,
db: web::Data<dyn ApiKeyPort>,
db: web::Data<dyn ApiKeyRepo>,
body: web::Json<GenerateKeyRequest>,
) -> HttpResponse {
use rand::Rng;
@ -52,12 +52,7 @@ async fn generate_key(
.map(char::from)
.collect();
use sha2::{Digest, Sha256};
let key_hash = {
let mut hasher = Sha256::new();
hasher.update(raw_key.as_bytes());
format!("{:x}", hasher.finalize())
};
let key_hash = db.hmac_api_key(&raw_key);
let level = body.level.as_deref().unwrap_or("read_only");
if !matches!(level, "read_only" | "read_write" | "full_access") {
@ -77,7 +72,7 @@ async fn generate_key(
}
}
async fn delete_key(_auth: AuthClaims, db: web::Data<dyn ApiKeyPort>, path: web::Path<i64>) -> HttpResponse {
async fn delete_key(_auth: AuthClaims, db: web::Data<dyn ApiKeyRepo>, path: web::Path<i64>) -> HttpResponse {
let id = path.into_inner();
match db.delete_api_key(id) {
Ok(true) => HttpResponse::Ok().json(serde_json::json!({"deleted": true})),

View File

@ -2,9 +2,14 @@ use actix_web::{HttpResponse, Scope, web};
use crate::adapter::persistence::Database;
use crate::core::auth::extractor::AuthClaims;
use crate::interface::port::audit::AuditRepo;
use crate::model::error::Error;
use crate::model::error::database::DatabaseError;
pub fn initialize() -> Scope {
web::scope("/audit").route("", web::get().to(list_audit_logs))
web::scope("/audit")
.route("", web::get().to(list_audit_logs))
.route("/verify", web::get().to(verify_chain))
}
async fn list_audit_logs(_auth: AuthClaims, db: web::Data<Database>) -> HttpResponse {
@ -27,3 +32,46 @@ async fn list_audit_logs(_auth: AuthClaims, db: web::Data<Database>) -> HttpResp
Err(_) => HttpResponse::Ok().json(serde_json::json!([])),
}
}
/// `GET /api/audit/verify` — walk the WORM hash chain and report whether
/// every row_hash still matches `H(ts || actor || action || detail ||
/// prev_hash)`. Surfaces over HTTP the same verification the CLI's
/// `--verify-audit-log` flag performs, so auditors can check chain
/// integrity without shell access. Any mismatch returns the offending
/// row id inside `error` so the dashboard can link straight to it.
async fn verify_chain(_auth: AuthClaims, audit: web::Data<dyn AuditRepo>) -> HttpResponse {
match audit.verify_audit_log_chain() {
Ok(count) => HttpResponse::Ok().json(serde_json::json!({
"chain_intact": true,
"verified": count,
})),
Err(e) => {
// Tamper detection is a successful verify outcome, not a server
// failure — return 200 with `chain_intact: false` so frontend
// retry/error handling treats real chain corruption as a
// distinct condition from transient DB connectivity issues.
// Reserve 500 for actual DB/IO failures.
let prev_mismatch = matches!(&e, Error::Database(DatabaseError::AuditPrevHashMismatch { .. }));
let row_mismatch = matches!(&e, Error::Database(DatabaseError::AuditRowHashMismatch { .. }));
if prev_mismatch || row_mismatch {
let kind = if prev_mismatch {
"prev_hash_mismatch"
} else {
"row_hash_mismatch"
};
HttpResponse::Ok().json(serde_json::json!({
"chain_intact": false,
"verified": 0,
"kind": kind,
"detail": e.to_string(),
}))
} else {
HttpResponse::InternalServerError().json(serde_json::json!({
"chain_intact": null,
"verified": 0,
"error": e.to_string(),
}))
}
}
}
}

View File

@ -5,10 +5,10 @@ use serde::Deserialize;
use crate::core::auth::extractor::AuthClaims;
use crate::core::auth::jwt::JwtService;
use crate::core::auth::password;
use crate::interface::port::repository::RepositoryPort;
use crate::interface::port::app_repo::AppRepo;
use crate::model::error::auth::AuthError;
type Repo = dyn RepositoryPort;
type Repo = dyn AppRepo;
#[derive(Deserialize)]
struct LoginRequest {
@ -116,7 +116,6 @@ async fn login(body: web::Json<LoginRequest>, db: web::Data<Repo>, jwt: web::Dat
// Permissions come exclusively from groups — no role-based fallback
let permissions = db.get_user_permissions(id).unwrap_or_default();
// Derive role from groups for backwards compat in JWT
let groups = db.get_user_groups(id).unwrap_or_default();
let role = if groups.iter().any(|(_id, name, _desc, _perms)| name == "Administrator") {
"admin".to_string()
@ -257,7 +256,6 @@ async fn list_users(_auth: AuthClaims, db: web::Data<Repo>) -> impl Responder {
.iter()
.map(|(gid, name)| serde_json::json!({"id": gid, "name": name}))
.collect();
// Derive role from groups for backwards compat
let role = if user_groups.iter().any(|(_id, name)| name == "Administrator") {
"admin"
} else {

View File

@ -0,0 +1,26 @@
//! HTTP surface for the BYO (bring-your-own-model) Quickstart flow.
//! Exposes read-only metadata that helps an administrator author a
//! valid `manifest.yaml` — principally the `FEATURE_REGISTRY` list,
//! which is the authoritative set of feature names the system will
//! extract and feed to a user-supplied ONNX model.
use actix_web::{HttpResponse, Scope, web};
use crate::core::auth::extractor::AuthClaims;
use crate::core::ml::feature_extractor::feature_registry_names;
pub fn initialize() -> Scope {
web::scope("/byo").route("/feature-registry", web::get().to(get_feature_registry))
}
/// `GET /api/byo/feature-registry` — list every feature name the
/// manifest validator accepts. Returning this over HTTP lets the
/// BYO Quickstart panel show the authoritative set without shipping
/// duplicated documentation that would drift from the Rust constants.
async fn get_feature_registry(_auth: AuthClaims) -> HttpResponse {
let names = feature_registry_names();
HttpResponse::Ok().json(serde_json::json!({
"count": names.len(),
"features": names,
}))
}

View File

@ -5,8 +5,8 @@ use actix_web::{HttpResponse, Responder, Scope, web};
use common::model::http_method::HttpMethod;
use serde::Deserialize;
use crate::adapter::ebpf::protocol_filter::ProtocolFilter;
use crate::core::dns_filter_service::DnsFilterService;
use crate::core::ebpf::protocol_filter::ProtocolFilter;
/// Convert a fallible result into an Ok (200) or InternalServerError (500) response.
fn ok_or_error<T, E: fmt::Display>(result: Result<T, E>) -> HttpResponse {
@ -111,11 +111,11 @@ fn ssh_blacklist_scope() -> Scope {
// --- HTTP service handlers ---
async fn get_ipv4_http_service(service: web::Data<ProtocolFilter>) -> impl Responder {
HttpResponse::Ok().json(service.get_ipv4_http_service().await)
HttpResponse::Ok().json(service.get_ipv4_http_service())
}
async fn get_ipv6_http_service(service: web::Data<ProtocolFilter>) -> impl Responder {
HttpResponse::Ok().json(service.get_ipv6_http_service().await)
HttpResponse::Ok().json(service.get_ipv6_http_service())
}
async fn add_ipv4_http_service(
@ -123,7 +123,7 @@ async fn add_ipv4_http_service(
service: web::Data<ProtocolFilter>,
) -> impl Responder {
let (addr, methods) = payload.into_inner();
ok_or_error(service.add_ipv4_http_service(addr, methods).await)
ok_or_error(service.add_ipv4_http_service(addr, methods))
}
async fn add_ipv6_http_service(
@ -131,7 +131,7 @@ async fn add_ipv6_http_service(
service: web::Data<ProtocolFilter>,
) -> impl Responder {
let (addr, methods) = payload.into_inner();
ok_or_error(service.add_ipv6_http_service(addr, methods).await)
ok_or_error(service.add_ipv6_http_service(addr, methods))
}
async fn remove_ipv4_http_service(
@ -139,7 +139,7 @@ async fn remove_ipv4_http_service(
service: web::Data<ProtocolFilter>,
) -> impl Responder {
let (addr, methods) = payload.into_inner();
ok_or_error(service.remove_ipv4_http_service(addr, methods).await)
ok_or_error(service.remove_ipv4_http_service(addr, methods))
}
async fn remove_ipv6_http_service(
@ -147,113 +147,113 @@ async fn remove_ipv6_http_service(
service: web::Data<ProtocolFilter>,
) -> impl Responder {
let (addr, methods) = payload.into_inner();
ok_or_error(service.remove_ipv6_http_service(addr, methods).await)
ok_or_error(service.remove_ipv6_http_service(addr, methods))
}
// --- SSH service handlers ---
async fn get_ipv4_ssh_service(service: web::Data<ProtocolFilter>) -> impl Responder {
HttpResponse::Ok().json(service.get_ipv4_ssh_service().await)
HttpResponse::Ok().json(service.get_ipv4_ssh_service())
}
async fn get_ipv6_ssh_service(service: web::Data<ProtocolFilter>) -> impl Responder {
HttpResponse::Ok().json(service.get_ipv6_ssh_service().await)
HttpResponse::Ok().json(service.get_ipv6_ssh_service())
}
async fn add_ipv4_ssh_service(ip_addr: web::Json<SocketAddrV4>, service: web::Data<ProtocolFilter>) -> impl Responder {
ok_or_error(service.add_ipv4_ssh_service(ip_addr.into_inner()).await)
ok_or_error(service.add_ipv4_ssh_service(ip_addr.into_inner()))
}
async fn add_ipv6_ssh_service(ip_addr: web::Json<SocketAddrV6>, service: web::Data<ProtocolFilter>) -> impl Responder {
ok_or_error(service.add_ipv6_ssh_service(ip_addr.into_inner()).await)
ok_or_error(service.add_ipv6_ssh_service(ip_addr.into_inner()))
}
async fn remove_ipv4_ssh_service(
ip_addr: web::Json<SocketAddrV4>,
service: web::Data<ProtocolFilter>,
) -> impl Responder {
ok_or_error(service.remove_ipv4_ssh_service(ip_addr.into_inner()).await)
ok_or_error(service.remove_ipv4_ssh_service(ip_addr.into_inner()))
}
async fn remove_ipv6_ssh_service(
ip_addr: web::Json<SocketAddrV6>,
service: web::Data<ProtocolFilter>,
) -> impl Responder {
ok_or_error(service.remove_ipv6_ssh_service(ip_addr.into_inner()).await)
ok_or_error(service.remove_ipv6_ssh_service(ip_addr.into_inner()))
}
// --- SSH whitelist handlers ---
async fn is_ssh_white_list_enable(service: web::Data<ProtocolFilter>) -> impl Responder {
HttpResponse::Ok().json(service.is_ssh_white_list_enable().await)
HttpResponse::Ok().json(service.is_ssh_white_list_enable())
}
async fn enable_ssh_white_list(service: web::Data<ProtocolFilter>) -> impl Responder {
ok_or_error(service.enable_ssh_white_list().await)
ok_or_error(service.enable_ssh_white_list())
}
async fn disable_ssh_white_list(service: web::Data<ProtocolFilter>) -> impl Responder {
ok_or_error(service.disable_ssh_white_list().await)
ok_or_error(service.disable_ssh_white_list())
}
async fn get_ipv4_ssh_white_list(service: web::Data<ProtocolFilter>) -> impl Responder {
HttpResponse::Ok().json(service.get_ipv4_ssh_white_list().await)
HttpResponse::Ok().json(service.get_ipv4_ssh_white_list())
}
async fn get_ipv6_ssh_white_list(service: web::Data<ProtocolFilter>) -> impl Responder {
HttpResponse::Ok().json(service.get_ipv6_ssh_white_list().await)
HttpResponse::Ok().json(service.get_ipv6_ssh_white_list())
}
async fn add_ipv4_ssh_white_list(ip_addr: web::Json<Ipv4Addr>, service: web::Data<ProtocolFilter>) -> impl Responder {
ok_or_error(service.add_ipv4_ssh_white_list(ip_addr.into_inner()).await)
ok_or_error(service.add_ipv4_ssh_white_list(ip_addr.into_inner()))
}
async fn add_ipv6_ssh_white_list(ip_addr: web::Json<Ipv6Addr>, service: web::Data<ProtocolFilter>) -> impl Responder {
ok_or_error(service.add_ipv6_ssh_white_list(ip_addr.into_inner()).await)
ok_or_error(service.add_ipv6_ssh_white_list(ip_addr.into_inner()))
}
async fn remove_ipv4_ssh_white_list(
ip_addr: web::Json<Ipv4Addr>,
service: web::Data<ProtocolFilter>,
) -> impl Responder {
ok_or_error(service.remove_ipv4_ssh_white_list(ip_addr.into_inner()).await)
ok_or_error(service.remove_ipv4_ssh_white_list(ip_addr.into_inner()))
}
async fn remove_ipv6_ssh_white_list(
ip_addr: web::Json<Ipv6Addr>,
service: web::Data<ProtocolFilter>,
) -> impl Responder {
ok_or_error(service.remove_ipv6_ssh_white_list(ip_addr.into_inner()).await)
ok_or_error(service.remove_ipv6_ssh_white_list(ip_addr.into_inner()))
}
// --- SSH blacklist handlers ---
async fn get_ipv4_ssh_black_list(service: web::Data<ProtocolFilter>) -> impl Responder {
HttpResponse::Ok().json(service.get_ipv4_ssh_black_list().await)
HttpResponse::Ok().json(service.get_ipv4_ssh_black_list())
}
async fn get_ipv6_ssh_black_list(service: web::Data<ProtocolFilter>) -> impl Responder {
HttpResponse::Ok().json(service.get_ipv6_ssh_black_list().await)
HttpResponse::Ok().json(service.get_ipv6_ssh_black_list())
}
async fn add_ipv4_ssh_black_list(ip_addr: web::Json<Ipv4Addr>, service: web::Data<ProtocolFilter>) -> impl Responder {
ok_or_error(service.add_ipv4_ssh_black_list(ip_addr.into_inner()).await)
ok_or_error(service.add_ipv4_ssh_black_list(ip_addr.into_inner()))
}
async fn add_ipv6_ssh_black_list(ip_addr: web::Json<Ipv6Addr>, service: web::Data<ProtocolFilter>) -> impl Responder {
ok_or_error(service.add_ipv6_ssh_black_list(ip_addr.into_inner()).await)
ok_or_error(service.add_ipv6_ssh_black_list(ip_addr.into_inner()))
}
async fn remove_ipv4_ssh_black_list(
ip_addr: web::Json<Ipv4Addr>,
service: web::Data<ProtocolFilter>,
) -> impl Responder {
ok_or_error(service.remove_ipv4_ssh_black_list(ip_addr.into_inner()).await)
ok_or_error(service.remove_ipv4_ssh_black_list(ip_addr.into_inner()))
}
async fn remove_ipv6_ssh_black_list(
ip_addr: web::Json<Ipv6Addr>,
service: web::Data<ProtocolFilter>,
) -> impl Responder {
ok_or_error(service.remove_ipv6_ssh_black_list(ip_addr.into_inner()).await)
ok_or_error(service.remove_ipv6_ssh_black_list(ip_addr.into_inner()))
}

View File

@ -0,0 +1,143 @@
//! HTTP surface for Flow Trace recording. Exposes the rotated CSV
//! shards the writer thread produces so analysts can pull them for
//! offline training / audit.
//!
//! Range support via `actix_files::NamedFile` — the frontend's download
//! progress bar needs `Content-Range` to show % complete on large files.
use std::path::{Path, PathBuf};
use actix_files::NamedFile;
use actix_web::{HttpRequest, HttpResponse, Responder, Scope, web};
use crate::core::ml::engine::Engine;
use crate::core::ml::traffic_logger::{FLOW_TRACE_FILE_EXT, FLOW_TRACE_FILE_MARKER, list_flow_trace_files};
pub fn initialize() -> Scope {
web::scope("/flow-trace")
.route("/files", web::get().to(list_files))
.route("/download/{name}", web::get().to(download))
}
/// `GET /api/flow-trace/files` — JSON summary of every rotated CSV in
/// the recording directory. Sorted oldest-first so clients showing a
/// retention list get a stable order.
async fn list_files(engine: web::Data<Engine>) -> impl Responder {
let Some(directory) = flow_trace_directory(&engine) else {
return HttpResponse::Ok().json(serde_json::json!({ "files": [], "enabled": false }));
};
match list_flow_trace_files(&directory) {
Ok(files) => {
let json_files: Vec<serde_json::Value> = files
.into_iter()
.map(|f| {
serde_json::json!({
"name": f.name,
"size_bytes": f.size_bytes,
"modified_unix_secs": f.modified_unix_secs,
})
})
.collect();
HttpResponse::Ok().json(serde_json::json!({
"files": json_files,
"enabled": true,
}))
}
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({
"error": format!("failed to list flow-trace directory: {e}"),
})),
}
}
/// `GET /api/flow-trace/download/{name}` — streams a single rotated
/// shard with range support.
async fn download(req: HttpRequest, engine: web::Data<Engine>) -> actix_web::Result<HttpResponse> {
let name = match req.match_info().get("name") {
Some(n) => n.to_string(),
None => {
return Ok(HttpResponse::BadRequest().json(serde_json::json!({
"error": "missing filename path segment",
})));
}
};
if !is_safe_flow_trace_name(&name) {
return Ok(HttpResponse::BadRequest().json(serde_json::json!({
"error": "invalid flow-trace filename",
})));
}
let Some(directory) = flow_trace_directory(&engine) else {
return Ok(HttpResponse::NotFound().json(serde_json::json!({
"error": "Flow Trace recording is not enabled",
})));
};
let file_path = directory.join(&name);
if !file_path.is_file() {
return Ok(HttpResponse::NotFound().json(serde_json::json!({
"error": "flow-trace file not found",
})));
}
let named = NamedFile::open_async(&file_path).await?;
Ok(named.into_response(&req))
}
/// Reject anything that isn't a plain `flow-trace-<digits>.csv` entry.
/// Traversal sequences and empty / renamed files get zero chance to
/// escape the recording directory.
pub fn is_safe_flow_trace_name(name: &str) -> bool {
if name.is_empty() || name.contains('/') || name.contains('\\') || name.contains("..") {
return false;
}
let Some(stripped) = name.strip_prefix(FLOW_TRACE_FILE_MARKER) else {
return false;
};
let Some(suffix) = stripped.strip_suffix(FLOW_TRACE_FILE_EXT) else {
return false;
};
!suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_digit())
}
/// Resolve the Flow Trace recording directory from the shared
/// `Engine` if the logger is active. Returns `None` when Flow Trace
/// isn't enabled (Dormant state).
fn flow_trace_directory(engine: &web::Data<Engine>) -> Option<PathBuf> {
engine.traffic_logger_directory().map(Path::to_path_buf)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn safe_name_accepts_canonical_flow_trace_file() {
assert!(is_safe_flow_trace_name("flow-trace-00000000000000000042.csv"));
assert!(is_safe_flow_trace_name("flow-trace-17150000000000000000.csv"));
}
#[test]
fn safe_name_rejects_traversal_sequences() {
assert!(!is_safe_flow_trace_name("../etc/passwd"));
assert!(!is_safe_flow_trace_name("flow-trace-../x.csv"));
assert!(!is_safe_flow_trace_name("../flow-trace-1.csv"));
assert!(!is_safe_flow_trace_name("flow-trace-1/.csv"));
assert!(!is_safe_flow_trace_name("flow-trace-1\\.csv"));
}
#[test]
fn safe_name_rejects_unrelated_prefixes_and_suffixes() {
assert!(!is_safe_flow_trace_name("config.csv"));
assert!(!is_safe_flow_trace_name("flow-trace-42.txt"));
assert!(!is_safe_flow_trace_name(""));
}
#[test]
fn safe_name_rejects_non_numeric_suffix() {
assert!(!is_safe_flow_trace_name("flow-trace-.csv"));
assert!(!is_safe_flow_trace_name("flow-trace-abc.csv"));
assert!(!is_safe_flow_trace_name("flow-trace-12abc.csv"));
}
}

View File

@ -0,0 +1,226 @@
//! HTTP surface for fusion-layer observability + incident explain.
//! Metrics handlers read shared atomic counters maintained by the
//! detection orchestrator — they never touch orchestrator state, so a
//! hung dashboard cannot stall the detection pipeline. The explain
//! handler reads the WORM audit chain populated by
//! `publish_fusion_audit` and surfaces a per-IP evidence timeline so
//! analysts can answer "why was this IP blocked?" without parsing
//! logs by hand.
use actix_web::{HttpRequest, HttpResponse, Responder, Scope, web};
use crate::core::detection::metrics::FusionMetrics;
use crate::interface::port::audit::{AuditLogEntry, AuditRepo};
/// Maximum audit rows scanned per explain request. Caps DB work in
/// case the audit chain grows large enough that a naive full-table
/// scan would be noticeable.
const FUSION_EXPLAIN_SCAN_LIMIT: i64 = 5_000;
/// Upper cap on entries returned to the client per explain request.
/// Guards against a UI rendering path that chokes on enormous JSON.
const FUSION_EXPLAIN_RESPONSE_CAP: usize = 200;
/// Stable audit action string the fusion engine emits — kept in sync
/// with `core::detection::orchestrator::FUSION_AUDIT_ACTION`. If that
/// constant changes, the explain endpoint silently returns nothing, so
/// keep this updated at the same time.
const FUSION_AUDIT_ACTION: &str = "fused_threat_emitted";
pub fn initialize() -> Scope {
web::scope("/fusion")
.route("/metrics", web::get().to(get_metrics))
.route("/explain/{src_ip}", web::get().to(explain_ip))
}
/// `GET /api/fusion/metrics` — lock-free snapshot of fusion counters and
/// derived rates. Drives the operator dashboard's "how well is fusion
/// working on my network?" view.
async fn get_metrics(metrics: web::Data<FusionMetrics>) -> impl Responder {
HttpResponse::Ok().json(metrics.snapshot())
}
/// `GET /api/fusion/explain/{src_ip}` — per-IP fusion evidence timeline.
/// Scans the WORM audit chain for `fused_threat_emitted` entries that
/// match `src_ip`, returning them oldest-first so the UI can render a
/// chronological "why was this IP blocked" view.
async fn explain_ip(req: HttpRequest, audit: web::Data<dyn AuditRepo>) -> impl Responder {
let src_ip = match req.match_info().get("src_ip") {
Some(ip) => ip.to_string(),
None => {
return HttpResponse::BadRequest().json(serde_json::json!({
"error": "missing src_ip path segment",
}));
}
};
let entries = match audit.list_audit_logs_by_action(FUSION_AUDIT_ACTION, FUSION_EXPLAIN_SCAN_LIMIT) {
Ok(e) => e,
Err(e) => {
return HttpResponse::InternalServerError().json(serde_json::json!({
"error": format!("audit store unavailable: {e}"),
}));
}
};
let (matches, truncated) = filter_fusion_evidence_for_ip(&entries, &src_ip, FUSION_EXPLAIN_RESPONSE_CAP);
HttpResponse::Ok().json(serde_json::json!({
"src_ip": src_ip,
"match_count": matches.len(),
"truncated": truncated,
"entries": matches,
}))
}
/// Filter audit entries down to the ones whose JSON detail's `src_ip`
/// matches `target_ip`, ordered oldest-first (ascending id). Entries
/// with unparseable detail are dropped silently — the chain is
/// append-only, so a malformed row is an integrity concern for the
/// audit-verify endpoint to surface, not this handler.
///
/// Returns `(entries_up_to_cap, truncated)`. `truncated` is `true` when
/// at least one matching entry was dropped — `matches.len() == cap` does
/// NOT imply truncation, so we look at `cap + 1` candidates and set the
/// flag only when the overflow entry exists.
///
/// Extracted as a free function so tests can cover the filter /
/// ordering / cap behaviour without an in-memory DB.
pub fn filter_fusion_evidence_for_ip(
entries: &[AuditLogEntry],
target_ip: &str,
cap: usize,
) -> (Vec<serde_json::Value>, bool) {
let mut filtered: Vec<&AuditLogEntry> = entries
.iter()
.filter(|entry| detail_matches_src_ip(&entry.detail, target_ip))
.collect();
filtered.sort_by_key(|entry| entry.id);
let truncated = filtered.len() > cap;
if truncated {
filtered.truncate(cap);
}
let rendered = filtered
.into_iter()
.map(|entry| {
let detail: serde_json::Value = serde_json::from_str(&entry.detail).unwrap_or(serde_json::Value::Null);
serde_json::json!({
"id": entry.id,
"actor": entry.actor,
"action": entry.action,
"created_at": entry.created_at,
"detail": detail,
})
})
.collect();
(rendered, truncated)
}
fn detail_matches_src_ip(detail_json: &str, target_ip: &str) -> bool {
let parsed: serde_json::Value = match serde_json::from_str(detail_json) {
Ok(v) => v,
Err(_) => return false,
};
parsed.get("src_ip").and_then(|v| v.as_str()) == Some(target_ip)
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(id: i64, src_ip: &str, attack: &str) -> AuditLogEntry {
let detail = serde_json::json!({
"src_ip": src_ip,
"attack_type": attack,
"fused_confidence": 0.9,
"per_source": [{"source": "Suricata", "confidence": 0.9, "local_attack_type": "brute-force"}],
})
.to_string();
AuditLogEntry {
id,
actor: "FusionEngine".to_string(),
action: "fused_threat_emitted".to_string(),
detail,
created_at: format!("2026-04-18T10:00:{:02}Z", id),
}
}
#[test]
fn filter_returns_only_matching_src_ip() {
let entries = [
entry(1, "1.2.3.4", "brute_force"),
entry(2, "10.0.0.5", "port_scan"),
entry(3, "1.2.3.4", "exploit"),
];
let (got, truncated) = filter_fusion_evidence_for_ip(&entries, "1.2.3.4", 100);
assert_eq!(got.len(), 2);
assert!(!truncated);
assert_eq!(got[0]["id"], 1);
assert_eq!(got[1]["id"], 3);
}
#[test]
fn filter_sorts_oldest_first_even_when_input_is_reversed() {
// Real repo query returns DESC; filter must still hand back ASC.
let entries = [
entry(30, "1.1.1.1", "a"),
entry(10, "1.1.1.1", "b"),
entry(20, "1.1.1.1", "c"),
];
let (got, _) = filter_fusion_evidence_for_ip(&entries, "1.1.1.1", 100);
let ids: Vec<i64> = got.iter().map(|v| v["id"].as_i64().unwrap()).collect();
assert_eq!(ids, vec![10, 20, 30]);
}
#[test]
fn filter_applies_response_cap() {
let entries: Vec<AuditLogEntry> = (1..=10).map(|i| entry(i, "9.9.9.9", "x")).collect();
let (got, truncated) = filter_fusion_evidence_for_ip(&entries, "9.9.9.9", 3);
assert_eq!(got.len(), 3);
assert!(truncated, "10 matching rows with cap=3 must set truncated");
let ids: Vec<i64> = got.iter().map(|v| v["id"].as_i64().unwrap()).collect();
assert_eq!(ids, vec![1, 2, 3], "cap takes oldest, not newest");
}
#[test]
fn filter_exactly_cap_is_not_truncated() {
// Regression guard: `matches.len() == cap` with no overflow row must
// return `truncated = false`. Earlier `>=` check mis-flagged this.
let entries: Vec<AuditLogEntry> = (1..=3).map(|i| entry(i, "9.9.9.9", "x")).collect();
let (got, truncated) = filter_fusion_evidence_for_ip(&entries, "9.9.9.9", 3);
assert_eq!(got.len(), 3);
assert!(!truncated, "exactly cap matches must NOT report truncated");
}
#[test]
fn filter_drops_rows_with_unparseable_detail() {
let good = entry(1, "1.2.3.4", "brute_force");
let bad = AuditLogEntry {
id: 2,
actor: "FusionEngine".into(),
action: "fused_threat_emitted".into(),
detail: "{{not json".into(),
created_at: "2026-04-18T10:00:02Z".into(),
};
let (got, _) = filter_fusion_evidence_for_ip(&[good, bad], "1.2.3.4", 100);
assert_eq!(got.len(), 1);
assert_eq!(got[0]["id"], 1);
}
#[test]
fn filter_nonmatching_ip_returns_empty() {
let entries = [entry(1, "1.2.3.4", "brute_force")];
let (got, truncated) = filter_fusion_evidence_for_ip(&entries, "5.6.7.8", 100);
assert!(got.is_empty());
assert!(!truncated);
}
#[test]
fn filter_preserves_detail_structure_in_response() {
let entries = [entry(1, "1.2.3.4", "brute_force")];
let (got, _) = filter_fusion_evidence_for_ip(&entries, "1.2.3.4", 100);
assert_eq!(got.len(), 1);
let detail = &got[0]["detail"];
assert_eq!(detail["attack_type"], "brute_force");
assert_eq!(detail["per_source"][0]["source"], "Suricata");
}
}

View File

@ -1,19 +1,32 @@
use actix_web::{HttpResponse, Responder, Scope, web};
use crate::infrastructure::health::SystemHealth;
use crate::infrastructure::suricata_manager::SuricataManager;
pub fn initialize() -> Scope {
web::scope("/health")
.route("/metrics", web::get().to(get_current_metrics))
.route("/status", web::get().to(get_health_status))
.route("/ebpf", web::get().to(get_ebpf_health))
.route("/suricata", web::get().to(get_suricata_health))
}
async fn get_current_metrics(health: web::Data<SystemHealth>) -> impl Responder {
let metrics = health.get_current_metrics().await;
let metrics = health.get_current_metrics();
HttpResponse::Ok().json(metrics)
}
async fn get_health_status(health: web::Data<SystemHealth>) -> impl Responder {
let status = health.is_system_healthy().await;
let status = health.is_system_healthy();
HttpResponse::Ok().json(status)
}
async fn get_ebpf_health(health: web::Data<SystemHealth>) -> impl Responder {
let ebpf = (**health.ebpf_health().load()).clone();
HttpResponse::Ok().json(ebpf)
}
async fn get_suricata_health(manager: web::Data<SuricataManager>) -> impl Responder {
let state = (**manager.health().load()).clone();
HttpResponse::Ok().json(state)
}

View File

@ -1,5 +1,12 @@
use std::fs;
use std::io::ErrorKind;
use std::path::Path;
use std::time::UNIX_EPOCH;
use actix_web::{HttpResponse, Scope, web};
use serde::Serialize;
use serde::{Deserialize, Serialize};
use crate::core::observability::log_buffer::{self, LogEntry};
/// Hardcoded log directory — not configurable via API to prevent directory traversal.
const LOG_DIR: &str = "logs";
@ -7,6 +14,15 @@ const LOG_DIR: &str = "logs";
/// Maximum downloadable log file size (50 MB). Prevents OOM from reading huge files.
const MAX_DOWNLOAD_SIZE: u64 = 50 * 1024 * 1024;
/// Default page size for `/live` when the client does not specify `limit`.
/// Chosen so a 2 s poll against a DEBUG-chatty deployment catches up in
/// one round-trip without being absurd payload-wise.
const LIVE_DEFAULT_LIMIT: usize = 500;
/// Hard cap on `/live?limit=` — prevents pathological clients from asking
/// for the entire buffer at once.
const LIVE_MAX_LIMIT: usize = 2_000;
/// Validate log filename: only alphanumeric, dots, underscores, hyphens.
/// Prevents path traversal.
fn is_valid_log_filename(name: &str) -> bool {
@ -20,9 +36,52 @@ fn is_valid_log_filename(name: &str) -> bool {
pub fn initialize() -> Scope {
web::scope("/logs")
.route("", web::get().to(list_logs))
.route("/live", web::get().to(live_logs))
.route("/{filename}", web::get().to(download_log))
}
#[derive(Deserialize)]
struct LiveQuery {
#[serde(default)]
since_id: Option<u64>,
#[serde(default)]
limit: Option<usize>,
#[serde(default)]
min_level: Option<String>,
}
#[derive(Serialize)]
struct LiveResponse {
entries: Vec<LogEntry>,
next_id: u64,
total_buffered: usize,
dropped_oldest: bool,
}
async fn live_logs(query: web::Query<LiveQuery>) -> HttpResponse {
let since_id = query.since_id.unwrap_or(0);
let limit = query.limit.unwrap_or(LIVE_DEFAULT_LIMIT).clamp(1, LIVE_MAX_LIMIT);
let min_severity = query
.min_level
.as_deref()
.map(|s| log_buffer::level_severity(&s.to_ascii_uppercase()))
.unwrap_or(log_buffer::level_severity("TRACE"));
let snap = log_buffer::snapshot(since_id, min_severity, limit);
// Signal to the UI that it lagged enough for the ring to evict rows
// between polls. Frontend can warn "older entries dropped" without
// silently skipping a gap.
let dropped_oldest = since_id > 0 && snap.entries.first().is_some_and(|e| e.id > since_id + 1);
let next_id = snap.entries.last().map(|e| e.id).unwrap_or(snap.latest_id);
HttpResponse::Ok().json(LiveResponse {
entries: snap.entries,
next_id,
total_buffered: snap.total,
dropped_oldest,
})
}
#[derive(Serialize)]
struct LogFileEntry {
name: String,
@ -32,7 +91,7 @@ struct LogFileEntry {
async fn list_logs() -> HttpResponse {
let log_dir = LOG_DIR;
let entries = match std::fs::read_dir(log_dir) {
let entries = match fs::read_dir(log_dir) {
Ok(dir) => dir
.filter_map(|e| e.ok())
.filter_map(|e| {
@ -44,7 +103,7 @@ async fn list_logs() -> HttpResponse {
let modified = meta
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_secs());
Some(LogFileEntry {
name,
@ -68,10 +127,10 @@ async fn download_log(path: web::Path<String>) -> HttpResponse {
}));
}
let file_path = std::path::Path::new(LOG_DIR).join(&filename);
let file_path = Path::new(LOG_DIR).join(&filename);
// Canonicalize to prevent symlink traversal
let canonical = match std::fs::canonicalize(&file_path) {
let canonical = match fs::canonicalize(&file_path) {
Ok(p) => p,
Err(_) => {
return HttpResponse::NotFound().json(serde_json::json!({
@ -79,7 +138,7 @@ async fn download_log(path: web::Path<String>) -> HttpResponse {
}));
}
};
if let Ok(log_dir_canonical) = std::fs::canonicalize(LOG_DIR)
if let Ok(log_dir_canonical) = fs::canonicalize(LOG_DIR)
&& !canonical.starts_with(&log_dir_canonical)
{
return HttpResponse::Forbidden().json(serde_json::json!({
@ -88,13 +147,13 @@ async fn download_log(path: web::Path<String>) -> HttpResponse {
}
// Check file size before reading to prevent OOM on large logs
match std::fs::metadata(&canonical) {
match fs::metadata(&canonical) {
Ok(meta) if meta.len() > MAX_DOWNLOAD_SIZE => {
return HttpResponse::PayloadTooLarge().json(serde_json::json!({
"error": format!("Log file exceeds maximum download size ({}MB)", MAX_DOWNLOAD_SIZE / 1024 / 1024)
}));
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
Err(e) if e.kind() == ErrorKind::NotFound => {
return HttpResponse::NotFound().json(serde_json::json!({
"error": format!("Log file '{}' not found", filename)
}));
@ -107,7 +166,7 @@ async fn download_log(path: web::Path<String>) -> HttpResponse {
Ok(_) => {}
}
let content = match std::fs::read(&canonical) {
let content = match fs::read(&canonical) {
Ok(bytes) => bytes,
Err(e) => {
return HttpResponse::InternalServerError().json(serde_json::json!({

View File

@ -1,15 +1,39 @@
use actix_web::{HttpResponse, Responder, Scope, web};
use crate::core::auth::extractor::AuthClaims;
use crate::core::ml::adapter::ModelSourceState;
use crate::core::ml::engine::Engine;
use crate::core::ml::inference::Inference;
use crate::infrastructure::communication_manager::CommunicationManager;
use crate::model::event::AuditEvent;
/// Permission required to forcibly revert the active ML source to dormant.
/// Mirrors the upload handler's gate so swap-out and revert are symmetric:
/// without this, anyone holding `ai_detection:write` could disable the
/// detector silently while the upload path required `users:admin`.
const DORMANT_REQUIRED_PERMISSION: &str = "users:admin";
/// Actor prefix recorded on the WORM chain when an admin reverts the ML
/// source. Matches the prefix used by `model_swap` so downstream filters
/// see both events in the same admin-action stream.
const AUDIT_ACTOR_SECURITY_ADMIN_PREFIX: &str = "SecurityAdmin";
/// Action recorded on the WORM chain when the ML source is forced
/// dormant via this endpoint. Stable wire string — UI/audit tooling
/// filters on it, paired with `model_swap` from the upload path.
const AUDIT_ACTION_MODEL_DORMANT: &str = "model_dormant";
pub fn initialize() -> Scope {
web::scope("/ml").route("/status", web::get().to(get_status))
web::scope("/ml")
.route("/status", web::get().to(get_status))
.route("/models/current", web::get().to(get_current_model))
.route("/models/current", web::delete().to(delete_current_model))
}
async fn get_status(engine: web::Data<Engine>) -> impl Responder {
let trackers = engine.trackers();
let num_trackers = trackers.len();
let total_flows: usize = trackers.iter().map(|t| t.lock().flow_count()).sum();
let total_flows: usize = trackers.iter().map(|t| t.flow_count()).sum();
let has_traffic_logger = engine.has_traffic_logger();
HttpResponse::Ok().json(serde_json::json!({
@ -20,3 +44,62 @@ async fn get_status(engine: web::Data<Engine>) -> impl Responder {
"inference_interval_secs": engine.inference_interval_secs(),
}))
}
/// `GET /api/ml/models/current` — wire-format snapshot of the ML source
/// state the dashboard's ML Status panel renders.
async fn get_current_model(inference: web::Data<Inference>) -> impl Responder {
let status = inference.current_status();
let label = if status.is_active() {
"active"
} else if status.is_dormant() {
"dormant"
} else {
"error"
};
HttpResponse::Ok().json(serde_json::json!({
"label": label,
"status": status,
}))
}
/// `DELETE /api/ml/models/current` — admin action: force the ML source back
/// to dormant. No-op when already dormant so the client can retry idempotently.
/// Requires `users:admin` (see `DORMANT_REQUIRED_PERMISSION`) and emits a
/// WORM `model_dormant` audit entry capturing the pre-revert state, mirroring
/// the upload path's `model_swap` so both swap-in and revert are auditable.
async fn delete_current_model(
inference: web::Data<Inference>,
comm: web::Data<CommunicationManager>,
claims: AuthClaims,
) -> impl Responder {
if !claims.permissions.iter().any(|p| p == DORMANT_REQUIRED_PERMISSION) {
return HttpResponse::Forbidden().json(serde_json::json!({
"error": format!("model dormant requires the {DORMANT_REQUIRED_PERMISSION} permission"),
}));
}
let before_status = inference.current_status();
if before_status.is_dormant() {
return HttpResponse::Ok().json(serde_json::json!({
"already_dormant": true,
}));
}
inference.swap_state(ModelSourceState::Dormant);
let audit_detail = serde_json::json!({
"before": serde_json::to_value(&before_status).unwrap_or(serde_json::Value::Null),
})
.to_string();
let _ = comm
.publish_event(AuditEvent {
actor: format!("{AUDIT_ACTOR_SECURITY_ADMIN_PREFIX}@{}", claims.username),
action: AUDIT_ACTION_MODEL_DORMANT.to_string(),
detail: audit_detail,
})
.await;
HttpResponse::Ok().json(serde_json::json!({
"already_dormant": false,
}))
}

View File

@ -2,11 +2,15 @@ pub mod acl;
pub mod api_keys;
pub mod audit;
pub mod auth;
pub mod byo;
pub mod default;
pub mod filter;
pub mod flow_trace;
pub mod fusion;
pub mod health;
pub mod logs;
pub mod ml;
pub mod model_upload;
pub mod notification;
pub mod rate_limit;
pub mod report;

View File

@ -0,0 +1,890 @@
//! Multipart upload surface for BYO model files. Accepts a `manifest`
//! YAML field, an `onnx` binary field, and an optional `scaler` JSON
//! sidecar; streams each to `models/.staging/<uuid>/` with enforced
//! size caps, runs structural + ONNX shape validation, then atomically
//! renames into `models/` under a process-wide gate (AtomicBool, not a
//! mutex — see `PromoteGate`): a second concurrent promote is rejected
//! with 409 Conflict rather than queued. A WORM `model_swap` audit
//! entry records the SHA-256 of both committed files plus a snapshot
//! of the pre-swap state.
//!
//! Body-size caps come from `InferenceConfig::model_upload_max_*_bytes`
//! so admins can tune them from the settings DB without a rebuild.
//! Defaults: 100MB ONNX, 64KB manifest, 64KB scaler. Streaming writes
//! never buffer the full file in RAM, and staged directories are torn
//! down on any error path so failed uploads don't pile up in
//! `models/.staging/`.
use std::fs as std_fs;
use std::fs::File as StdFile;
use std::io;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, SystemTime};
use actix_multipart::Multipart;
use actix_web::{HttpResponse, Responder, Scope, web};
use futures_util::TryStreamExt;
use serde_json::Value as JsonValue;
use sha2::{Digest, Sha256};
use tokio::fs;
use tokio::io::AsyncWriteExt;
use tokio::task;
use uuid::Uuid;
use crate::core::auth::extractor::AuthClaims;
use crate::core::ml::inference::Inference;
use crate::core::ml::manifest::{AdapterKind, ModelManifest};
use crate::core::ml::model_loader::build_adapter;
use crate::infrastructure::app_config::AppConfig;
use crate::infrastructure::communication_manager::CommunicationManager;
use crate::model::config::constants::{MANIFEST_FILENAME, MODELS_DIR, STAGING_SUBDIR};
use crate::model::event::AuditEvent;
use crate::model::system::config::MLInferenceConfig;
/// Multipart field names the client must use. Stable wire contract —
/// the frontend form generator depends on these exact strings.
const FIELD_MANIFEST: &str = "manifest";
const FIELD_ONNX: &str = "onnx";
const FIELD_SCALER: &str = "scaler";
/// Number of bytes of the ONNX body we inspect up-front for an obvious
/// non-Protobuf header. A fuller structural check (shape vs manifest
/// declared `features`) runs during `build_adapter` in the promote path.
const ONNX_SNIFF_BYTES: usize = 16;
/// Permission required to drive the model-upload endpoint. The full
/// RBAC middleware lets anyone with `ai_detection:write` reach
/// `/api/ml/*`, but model promotion can replace the active detector —
/// gate it tighter at the handler layer so only administrators can
/// swap the ML source.
const PROMOTE_REQUIRED_PERMISSION: &str = "users:admin";
/// Actor prefix on the WORM `model_swap` audit entry. Administrators
/// that trigger the upload endpoint land on the chain as
/// `SecurityAdmin@<username>` so downstream filters can separate
/// system-driven entries (actor="system") from human-driven ones
/// without parsing free-form text. Stable across releases — renaming
/// breaks downstream audit tooling that filters on this prefix.
const AUDIT_ACTOR_SECURITY_ADMIN_PREFIX: &str = "SecurityAdmin";
/// Action recorded on the WORM chain when a promote succeeds. Stable
/// wire string — fusion-explain tooling and future "who swapped the
/// model" views filter on it, so the rename must go through the audit
/// chain too.
const AUDIT_ACTION_MODEL_SWAP: &str = "model_swap";
/// Process-wide gate that ensures only one promote ever runs the rename
/// section at a time. The critical section is tiny (three `tokio::fs::rename`
/// syscalls) but must never interleave: a concurrent promote mid-rename could
/// leave `models/` pointing at a manifest whose ONNX hasn't landed yet.
///
/// Unlike a mutex, the gate does not queue. A second concurrent promote sees
/// the gate held and gets `PromoteError::ConcurrentPromote` immediately —
/// administrators wanting to swap models should know another swap is in flight
/// rather than silently waiting behind it.
#[derive(Default)]
pub struct PromoteGate {
in_progress: AtomicBool,
}
impl PromoteGate {
pub fn new() -> Self {
Self::default()
}
/// Try to claim the gate. Returns `Some(guard)` on success; `None` when
/// another promote is already inside the rename section. The guard
/// releases the gate when dropped, including on panic.
fn try_acquire(&self) -> Option<PromoteGuard<'_>> {
if self
.in_progress
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_ok()
{
Some(PromoteGuard { gate: self })
} else {
None
}
}
}
struct PromoteGuard<'a> {
gate: &'a PromoteGate,
}
impl Drop for PromoteGuard<'_> {
fn drop(&mut self) {
self.gate.in_progress.store(false, Ordering::Release);
}
}
pub fn initialize() -> Scope {
// Mounted under `/ml/models/upload` so the entire ML model lifecycle
// (status, dormant, upload) lives under one URL subtree. The peer
// `/ml/models/current` GET/DELETE routes live in `ml::initialize()`;
// actix dispatches each path to whichever scope owns it.
web::scope("/ml/models").route("/upload", web::post().to(upload))
}
/// `POST /api/ml/models/upload` — multipart with `manifest` (YAML text),
/// `onnx` (binary), and optional `scaler` (JSON). Streams fields into
/// `models/.staging/<uuid>/`, validates the manifest + ONNX shape, and
/// atomically renames the triple into `models/` on success. A WORM
/// `model_swap` audit entry captures the SHA-256 pair plus the
/// pre-swap ML source state. The staging directory is always torn
/// down on the way out, even on success (post-promote it's empty).
async fn upload(
app_config: web::Data<AppConfig>,
inference: web::Data<Inference>,
comm: web::Data<CommunicationManager>,
promote_lock: web::Data<PromoteGate>,
claims: AuthClaims,
payload: Multipart,
) -> impl Responder {
if !claims.permissions.iter().any(|p| p == PROMOTE_REQUIRED_PERMISSION) {
return HttpResponse::Forbidden().json(serde_json::json!({
"error": format!("model upload requires the {PROMOTE_REQUIRED_PERMISSION} permission"),
}));
}
let staging_root = PathBuf::from(MODELS_DIR).join(STAGING_SUBDIR);
let staging_id = Uuid::new_v4().to_string();
let staging_dir = staging_root.join(&staging_id);
let caps = UploadCaps {
manifest: app_config.inference.model_upload_max_manifest_bytes,
onnx: app_config.inference.model_upload_max_onnx_bytes,
scaler: app_config.inference.model_upload_max_scaler_bytes,
};
let summary = match ingest_multipart(payload, &staging_dir, caps).await {
Ok(s) => s,
Err(e) => {
let _ = fs::remove_dir_all(&staging_dir).await;
return e.into_response();
}
};
let batch_size = app_config.inference.inference_batch_size;
let outcome = validate_and_promote(
&staging_dir,
&summary,
inference.get_ref(),
comm.get_ref(),
promote_lock.get_ref(),
&claims.username,
batch_size,
)
.await;
// Always sweep staging — successful promote renames the files out,
// leaving a now-empty directory; failures leave partial state we
// don't want orbiting forever.
let _ = fs::remove_dir_all(&staging_dir).await;
match outcome {
Ok(report) => HttpResponse::Ok().json(serde_json::json!({
"promoted": true,
"staging_id": staging_id,
"manifest_bytes": summary.manifest_bytes,
"onnx_bytes": summary.onnx_bytes,
"scaler_bytes": summary.scaler_bytes,
"manifest_name": report.manifest_name,
"adapter_kind": report.adapter_kind,
"manifest_sha256": report.manifest_sha256,
"onnx_sha256": report.onnx_sha256,
})),
Err(e) => e.into_response(),
}
}
/// Successful-path metadata the handler surfaces to the client.
#[derive(Debug)]
struct UploadSummary {
manifest_bytes: usize,
onnx_bytes: usize,
onnx_filename: String,
/// Bytes written for the optional scaler sidecar. `None` when the
/// field wasn't submitted at all.
scaler_bytes: Option<usize>,
}
/// Per-field byte caps. Plumbed from `InferenceConfig` through the
/// handler so admins can tune caps from the DB without a code change.
#[derive(Debug, Clone, Copy)]
struct UploadCaps {
manifest: usize,
onnx: usize,
scaler: usize,
}
/// Errors that can surface a specific HTTP response. Kept in-module
/// because none of these have callers outside this handler.
///
/// The `*TooLarge(usize)` variants carry the admin-configured cap so
/// the response can tell the client which ceiling they hit without
/// having to query `/api/config` separately.
#[derive(Debug)]
enum UploadError {
MissingField(&'static str),
DuplicateField(&'static str),
UnknownField(String),
ManifestTooLarge(usize),
OnnxTooLarge(usize),
ScalerTooLarge(usize),
OnnxNotBinary,
StreamFailure(String),
StagingSetupFailure(String),
}
impl UploadError {
fn into_response(self) -> HttpResponse {
let (status, message) = match self {
Self::MissingField(name) => (400, format!("missing required multipart field: {name}")),
Self::DuplicateField(name) => (400, format!("multipart field sent twice: {name}")),
Self::UnknownField(name) => (400, format!("unexpected multipart field: {name}")),
Self::ManifestTooLarge(max_bytes) => (413, format!("manifest exceeds {max_bytes} bytes")),
Self::OnnxTooLarge(max_bytes) => (413, format!("onnx exceeds {max_bytes} bytes")),
Self::ScalerTooLarge(max_bytes) => (413, format!("scaler exceeds {max_bytes} bytes")),
Self::OnnxNotBinary => (
400,
"onnx field does not look like a protobuf-encoded ONNX model".to_string(),
),
Self::StreamFailure(err) => (400, format!("upload stream error: {err}")),
Self::StagingSetupFailure(err) => (500, format!("staging directory error: {err}")),
};
let body = serde_json::json!({ "error": message });
match status {
400 => HttpResponse::BadRequest().json(body),
413 => HttpResponse::PayloadTooLarge().json(body),
_ => HttpResponse::InternalServerError().json(body),
}
}
}
async fn ingest_multipart(
mut payload: Multipart,
staging_dir: &Path,
caps: UploadCaps,
) -> Result<UploadSummary, UploadError> {
fs::create_dir_all(staging_dir)
.await
.map_err(|e| UploadError::StagingSetupFailure(e.to_string()))?;
let mut manifest_written: Option<usize> = None;
let mut onnx_summary: Option<(String, usize)> = None;
let mut scaler_summary: Option<(String, usize)> = None;
while let Some(mut field) = payload
.try_next()
.await
.map_err(|e| UploadError::StreamFailure(e.to_string()))?
{
let field_name = field
.content_disposition()
.and_then(|cd| cd.get_name())
.unwrap_or("")
.to_string();
match field_name.as_str() {
FIELD_MANIFEST => {
if manifest_written.is_some() {
return Err(UploadError::DuplicateField(FIELD_MANIFEST));
}
let dest = staging_dir.join(MANIFEST_FILENAME);
let written = stream_field_to_file(&mut field, &dest, caps.manifest, FieldKind::Manifest).await?;
manifest_written = Some(written);
}
FIELD_ONNX => {
if onnx_summary.is_some() {
return Err(UploadError::DuplicateField(FIELD_ONNX));
}
let onnx_filename = field
.content_disposition()
.and_then(|cd| cd.get_filename())
.map(sanitize_filename)
.unwrap_or_else(|| "model.onnx".to_string());
let dest = staging_dir.join(&onnx_filename);
let written = stream_field_to_file(&mut field, &dest, caps.onnx, FieldKind::Onnx).await?;
onnx_summary = Some((onnx_filename, written));
}
FIELD_SCALER => {
if scaler_summary.is_some() {
return Err(UploadError::DuplicateField(FIELD_SCALER));
}
let scaler_filename = field
.content_disposition()
.and_then(|cd| cd.get_filename())
.map(sanitize_filename)
.unwrap_or_else(|| "inference_config.json".to_string());
let dest = staging_dir.join(&scaler_filename);
let written = stream_field_to_file(&mut field, &dest, caps.scaler, FieldKind::Scaler).await?;
scaler_summary = Some((scaler_filename, written));
}
other => {
return Err(UploadError::UnknownField(other.to_string()));
}
}
}
let manifest_bytes = manifest_written.ok_or(UploadError::MissingField(FIELD_MANIFEST))?;
let (onnx_filename, onnx_bytes) = onnx_summary.ok_or(UploadError::MissingField(FIELD_ONNX))?;
let scaler_bytes = scaler_summary.map(|(_, n)| n);
Ok(UploadSummary {
manifest_bytes,
onnx_bytes,
onnx_filename,
scaler_bytes,
})
}
/// Discriminator for which size cap / sniff rule applies to a given
/// multipart field.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FieldKind {
Manifest,
Onnx,
Scaler,
}
/// Stream a multipart field directly to disk. Aborts (and leaves the
/// caller to clean up) when the declared byte cap is exceeded or when
/// the binary sniff rejects the first chunk.
async fn stream_field_to_file(
field: &mut actix_multipart::Field,
dest: &Path,
max_bytes: usize,
kind: FieldKind,
) -> Result<usize, UploadError> {
let mut file = fs::File::create(dest)
.await
.map_err(|e| UploadError::StagingSetupFailure(e.to_string()))?;
let mut total = 0usize;
let mut sniffed = kind != FieldKind::Onnx;
while let Some(chunk) = field
.try_next()
.await
.map_err(|e| UploadError::StreamFailure(e.to_string()))?
{
if !sniffed {
// Cheap up-front validation: reject obvious non-ONNX blobs
// (empty first chunk, all-zero header, all-printable text).
if !looks_like_onnx(&chunk) {
return Err(UploadError::OnnxNotBinary);
}
sniffed = true;
}
total = total.saturating_add(chunk.len());
if total > max_bytes {
return Err(match kind {
FieldKind::Manifest => UploadError::ManifestTooLarge(max_bytes),
FieldKind::Onnx => UploadError::OnnxTooLarge(max_bytes),
FieldKind::Scaler => UploadError::ScalerTooLarge(max_bytes),
});
}
file.write_all(&chunk)
.await
.map_err(|e| UploadError::StreamFailure(e.to_string()))?;
}
file.flush()
.await
.map_err(|e| UploadError::StreamFailure(e.to_string()))?;
Ok(total)
}
/// Shape-preserving filename sanitizer: keep the extension the client
/// sent (it may be `.onnx`, `.bin`, whatever), but strip any directory
/// traversal so the staging dir can never escape.
pub fn sanitize_filename(raw: impl AsRef<str>) -> String {
let raw = raw.as_ref();
let trimmed = Path::new(raw)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("model.onnx");
if trimmed.is_empty() || trimmed == "." || trimmed == ".." {
"model.onnx".to_string()
} else {
trimmed.to_string()
}
}
/// Reject manifest-declared filenames that aren't single-segment basenames.
/// The multipart layer sanitizes client-sent part filenames (silent rewrite),
/// but manifest fields like `models.model` and `preprocessing.scaler_sidecar`
/// are user-controlled YAML — a value such as `../../../etc/cron.d/evil` would
/// otherwise flow into `staging_dir.join(..)` / `models_dir.join(..)` and let
/// `users:admin` write outside the models tree. Reject loudly rather than
/// silently rewriting so an operator who fat-fingered a path sees the failure.
fn validate_manifest_basename(field: &str, value: &str) -> Result<(), PromoteError> {
if value.is_empty() {
return Err(PromoteError::ManifestInvalid(format!(
"manifest field {field} is empty"
)));
}
if value.contains('/') || value.contains('\\') {
return Err(PromoteError::ManifestInvalid(format!(
"manifest field {field} must be a basename, not a path: {value:?}"
)));
}
if value == "." || value == ".." || value.contains("..") {
return Err(PromoteError::ManifestInvalid(format!(
"manifest field {field} must not contain path-traversal segments: {value:?}"
)));
}
if Path::new(value).is_absolute() {
return Err(PromoteError::ManifestInvalid(format!(
"manifest field {field} must be relative, not absolute: {value:?}"
)));
}
if Path::new(value).file_name().and_then(|s| s.to_str()) != Some(value) {
return Err(PromoteError::ManifestInvalid(format!(
"manifest field {field} must be a plain basename: {value:?}"
)));
}
Ok(())
}
/// Loose first-chunk heuristic. An ONNX protobuf starts with a varint
/// tag byte — the field=1 wire=varint (`0x08` for `ir_version`) and
/// field=1 wire=length-delimited (`0x0a`) patterns both occur in real
/// models — but enumerating positive accept patterns is fragile because
/// tract accepts several tag orderings. We instead:
///
/// 1. Reject magic bytes of container formats that users routinely
/// upload by mistake (ZIP, PNG, PDF, ELF).
/// 2. Reject all-zero and all-printable-ASCII prefixes (buffers and
/// text files).
///
/// The authoritative structural validation happens during
/// `build_adapter`; this heuristic's job is catching the obvious wrong
/// upload before the bytes hit disk.
pub fn looks_like_onnx(first_chunk: &[u8]) -> bool {
if first_chunk.is_empty() {
return false;
}
// Container formats that users commonly confuse with ONNX.
const BLOCKED_MAGICS: &[&[u8]] = &[
b"PK\x03\x04", // ZIP / JAR / DOCX — some pipelines ship ONNX weights this way,
// but our upload path expects a single standalone .onnx file.
b"\x89PNG",
b"%PDF",
b"\x7fELF",
];
for magic in BLOCKED_MAGICS {
if first_chunk.starts_with(magic) {
return false;
}
}
let prefix = &first_chunk[..first_chunk.len().min(ONNX_SNIFF_BYTES)];
if prefix.iter().all(|&b| b == 0) {
return false;
}
let mostly_ascii = prefix.iter().filter(|&&b| b.is_ascii_graphic() || b == b' ').count() >= prefix.len() - 1;
if mostly_ascii {
return false;
}
true
}
/// Validate the staged manifest + ONNX + optional sidecar, then
/// atomically promote them into `models/`. The sequence is:
///
/// 1. Parse and structurally validate `manifest.yaml` against the
/// `FEATURE_REGISTRY` plus manifest-level invariants.
/// 2. Reject `multi_task` adapters — v1 upload supports single-ONNX
/// models only; multi-task manifests reference two ONNX files and
/// need a different multipart shape.
/// 3. Rename the uploaded `.onnx` to the filename the manifest
/// declares in `models.model`. The client is free to ship the
/// binary with any user-facing name; the manifest is the canonical
/// layout the watcher rebuilds from.
/// 4. Run `from_manifest_with_sidecar` + `build_adapter` — this exercises
/// the same loader the hot-reload watcher will use after promote,
/// including the 5-second wall-clock budget around `tract`. If
/// anything fails here, nothing in `models/` has changed yet.
/// 5. SHA-256 both files and snapshot the `Inference` state for the
/// audit detail body before we mutate anything shared.
/// 6. Under `PromoteLock`, rename ONNX first, optional sidecar second,
/// manifest last. The manifest is the watcher's commit marker —
/// by landing it last we avoid the window where the watcher reads
/// a manifest that points at a not-yet-renamed ONNX.
/// 7. Publish a WORM `model_swap` audit event. Failure to publish is
/// logged but does not roll back the rename; the chain prefers a
/// missing audit entry to a rolled-back promote that a downstream
/// subscriber may already have reacted to.
async fn validate_and_promote(
staging_dir: &Path,
summary: &UploadSummary,
inference: &Inference,
comm: &CommunicationManager,
promote_lock: &PromoteGate,
actor_username: &str,
batch_size: usize,
) -> Result<PromoteReport, PromoteError> {
let staging_manifest = staging_dir.join(MANIFEST_FILENAME);
// Structural manifest validation. The full `build_adapter` pipeline
// below will revisit this via `from_manifest_with_sidecar`, but a
// cheap up-front `load` surfaces manifest-only problems (bad YAML,
// unknown feature, missing `models.model`) before we rename anything.
let manifest_preview =
ModelManifest::load(&staging_manifest).map_err(|e| PromoteError::ManifestInvalid(e.to_string()))?;
if matches!(manifest_preview.adapter, AdapterKind::MultiTask) {
return Err(PromoteError::UnsupportedAdapter);
}
let declared_onnx = manifest_preview
.models
.model
.clone()
.ok_or_else(|| PromoteError::ManifestInvalid("single-onnx adapters require models.model".to_string()))?;
validate_manifest_basename("models.model", &declared_onnx)?;
if let Some(ref pp) = manifest_preview.preprocessing {
validate_manifest_basename("preprocessing.scaler_sidecar", &pp.scaler_sidecar)?;
}
let uploaded_onnx = staging_dir.join(&summary.onnx_filename);
let staged_onnx = staging_dir.join(&declared_onnx);
if uploaded_onnx != staged_onnx {
fs::rename(&uploaded_onnx, &staged_onnx)
.await
.map_err(|e| PromoteError::StagingIo(format!("rename staged onnx: {e}")))?;
}
// Full validate — sidecar reconciliation, ONNX shape vs manifest
// features, tract optimize+runnable under the 5s load budget.
let (config, manifest) = MLInferenceConfig::from_manifest_with_sidecar(&staging_manifest)
.map_err(|e| PromoteError::ValidationFailed(e.to_string()))?;
let _adapter = build_adapter(&manifest, Some(&staging_manifest), &config, batch_size)
.map_err(|e| PromoteError::ValidationFailed(e.to_string()))?;
let manifest_sha256 = sha256_file(&staging_manifest)
.await
.map_err(|e| PromoteError::StagingIo(format!("sha256 manifest: {e}")))?;
let onnx_sha256 = sha256_file(&staged_onnx)
.await
.map_err(|e| PromoteError::StagingIo(format!("sha256 onnx: {e}")))?;
let before_status = inference.current_status();
let _guard = promote_lock.try_acquire().ok_or(PromoteError::ConcurrentPromote)?;
let models_dir = PathBuf::from(MODELS_DIR);
let target_onnx = models_dir.join(&declared_onnx);
fs::rename(&staged_onnx, &target_onnx)
.await
.map_err(|e| PromoteError::PromoteIo(format!("rename onnx into models/: {e}")))?;
if let Some(ref pp) = manifest.preprocessing {
validate_manifest_basename("preprocessing.scaler_sidecar", &pp.scaler_sidecar)?;
let src = staging_dir.join(&pp.scaler_sidecar);
let dst = models_dir.join(&pp.scaler_sidecar);
fs::rename(&src, &dst)
.await
.map_err(|e| PromoteError::PromoteIo(format!("rename sidecar into models/: {e}")))?;
}
let target_manifest = models_dir.join(MANIFEST_FILENAME);
fs::rename(&staging_manifest, &target_manifest)
.await
.map_err(|e| PromoteError::PromoteIo(format!("rename manifest into models/: {e}")))?;
drop(_guard);
let audit_detail = serde_json::json!({
"manifest_name": manifest.name,
"adapter_kind": manifest.adapter.as_str(),
"manifest_sha256": manifest_sha256,
"onnx_sha256": onnx_sha256,
"before": serde_json::to_value(&before_status).unwrap_or(JsonValue::Null),
})
.to_string();
let _ = comm
.publish_event(AuditEvent {
actor: format!("{AUDIT_ACTOR_SECURITY_ADMIN_PREFIX}@{actor_username}"),
action: AUDIT_ACTION_MODEL_SWAP.to_string(),
detail: audit_detail,
})
.await;
Ok(PromoteReport {
manifest_name: manifest.name,
adapter_kind: manifest.adapter.as_str().to_string(),
manifest_sha256,
onnx_sha256,
})
}
/// Metadata surfaced back to the client when the promote succeeds.
#[derive(Debug)]
struct PromoteReport {
manifest_name: String,
adapter_kind: String,
manifest_sha256: String,
onnx_sha256: String,
}
/// Validation / promote error taxonomy. Distinct from `UploadError` so
/// the two stages produce different HTTP status codes: staging-ingest
/// failures are typically client-facing (400/413), while validation
/// and rename failures are server-side (422/500).
#[derive(Debug)]
enum PromoteError {
ManifestInvalid(String),
ValidationFailed(String),
UnsupportedAdapter,
StagingIo(String),
PromoteIo(String),
ConcurrentPromote,
}
impl PromoteError {
fn into_response(self) -> HttpResponse {
let (status, message) = match self {
Self::ManifestInvalid(err) => (422, format!("manifest invalid: {err}")),
Self::ValidationFailed(err) => (422, format!("model failed validation: {err}")),
Self::UnsupportedAdapter => (
422,
"multi_task adapter is not supported by the v1 upload flow — \
submit an autoencoder_only or classifier_only manifest"
.to_string(),
),
Self::StagingIo(err) => (500, format!("staging io error: {err}")),
Self::PromoteIo(err) => (500, format!("promote io error: {err}")),
Self::ConcurrentPromote => (409, "another model promote is already in progress".to_string()),
};
let body = serde_json::json!({ "error": message });
match status {
422 => HttpResponse::UnprocessableEntity().json(body),
409 => HttpResponse::Conflict().json(body),
_ => HttpResponse::InternalServerError().json(body),
}
}
}
/// Read `path` in 64KB chunks and return its SHA-256 hex digest.
/// Offloaded to `spawn_blocking` so a large ONNX can't stall the
/// actix worker while the hash computes.
async fn sha256_file(path: &Path) -> io::Result<String> {
let path = path.to_path_buf();
task::spawn_blocking(move || -> io::Result<String> {
let mut file = StdFile::open(&path)?;
let mut hasher = Sha256::new();
let mut buf = [0u8; 64 * 1024];
loop {
let n = file.read(&mut buf)?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
}
let out = hasher.finalize();
let mut hex = String::with_capacity(64);
for byte in out {
use std::fmt::Write;
// SAFETY: write! on a String is infallible.
let _ = write!(&mut hex, "{byte:02x}");
}
Ok(hex)
})
.await
.unwrap_or_else(|e| Err(io::Error::other(format!("sha256 join: {e}"))))
}
/// Remove staging subdirectories older than `max_age`. Runs on startup
/// and on a periodic timer so failed uploads don't accumulate.
pub fn clean_staging_orphans(staging_root: &Path, max_age: Duration) -> io::Result<usize> {
if !staging_root.exists() {
return Ok(0);
}
let now = SystemTime::now();
let mut cleaned = 0usize;
for entry in std_fs::read_dir(staging_root)? {
let entry = entry?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let metadata = entry.metadata()?;
let mtime = metadata.modified()?;
let age = now.duration_since(mtime).unwrap_or_default();
if age >= max_age {
std_fs::remove_dir_all(&path)?;
cleaned += 1;
}
}
Ok(cleaned)
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::*;
#[test]
fn onnx_sniff_rejects_empty() {
assert!(!looks_like_onnx(&[]));
}
#[test]
fn onnx_sniff_rejects_zero_padded_prefix() {
assert!(!looks_like_onnx(&[0u8; 32]));
}
#[test]
fn onnx_sniff_rejects_plain_text() {
// A YAML or plain-text payload that ended up in the wrong field.
assert!(!looks_like_onnx(b"name: wrong-file\nkind: yaml\n"));
assert!(!looks_like_onnx(b"PK\x03\x04"));
}
#[test]
fn onnx_sniff_accepts_varint_tag_prefix() {
// `0x08` = tag field 1, wire-type varint (ir_version). Real ONNX
// files commonly open with this.
let buf = [0x08u8, 0x07, 0x12, 0x0a, 0x70, 0x79, 0x74, 0x6f, 0x72, 0x63, 0x68, 0x00];
assert!(looks_like_onnx(&buf));
}
#[test]
fn onnx_sniff_accepts_length_delimited_tag() {
// `0x0a` = tag field 1, wire-type length-delimited. Also valid.
let buf = [0x0au8, 0x10, 0x80, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07];
assert!(looks_like_onnx(&buf));
}
#[test]
fn sanitize_filename_strips_directory_components() {
assert_eq!(sanitize_filename("../../etc/passwd"), "passwd");
assert_eq!(sanitize_filename("subdir/model.onnx"), "model.onnx");
assert_eq!(sanitize_filename("/abs/path/classifier.onnx"), "classifier.onnx");
}
#[test]
fn sanitize_filename_rejects_degenerate_values() {
assert_eq!(sanitize_filename(""), "model.onnx");
assert_eq!(sanitize_filename("."), "model.onnx");
assert_eq!(sanitize_filename(".."), "model.onnx");
}
#[test]
fn orphan_cleanup_removes_every_dir_when_max_age_is_zero() {
// A zero-length max age declares every existing entry stale, so
// the helper must sweep all of them. Portable without touching
// filesystem mtime APIs.
let tmp = std::env::temp_dir().join(format!("nguardia-staging-test-{}", Uuid::new_v4()));
std::fs::create_dir_all(&tmp).unwrap();
std::fs::create_dir_all(tmp.join("abandoned-1")).unwrap();
std::fs::create_dir_all(tmp.join("abandoned-2")).unwrap();
// A file (not a dir) should be ignored by the sweep.
std::fs::write(tmp.join("sidecar.log"), b"noise").unwrap();
let cleaned = clean_staging_orphans(&tmp, Duration::ZERO).unwrap();
assert_eq!(cleaned, 2);
assert!(!tmp.join("abandoned-1").exists());
assert!(!tmp.join("abandoned-2").exists());
assert!(tmp.join("sidecar.log").exists(), "non-directory entries must survive");
std::fs::remove_dir_all(&tmp).ok();
}
#[test]
fn orphan_cleanup_preserves_fresh_directories() {
// With a generous max_age, a freshly-created directory must not
// be touched — the positive case of the time-guard.
let tmp = std::env::temp_dir().join(format!("nguardia-staging-fresh-{}", Uuid::new_v4()));
std::fs::create_dir_all(&tmp).unwrap();
std::fs::create_dir_all(tmp.join("recent")).unwrap();
let cleaned = clean_staging_orphans(&tmp, Duration::from_secs(3600)).unwrap();
assert_eq!(cleaned, 0);
assert!(tmp.join("recent").exists());
std::fs::remove_dir_all(&tmp).ok();
}
#[test]
fn orphan_cleanup_is_noop_when_staging_root_missing() {
let missing = PathBuf::from("/nonexistent/staging/path/for/test");
let cleaned = clean_staging_orphans(&missing, Duration::from_secs(60)).unwrap();
assert_eq!(cleaned, 0);
}
#[tokio::test]
async fn sha256_file_produces_known_hex_digest() {
// Canonical NIST-style empty-string vector: the SHA-256 of the
// empty byte sequence is the hex digest below. Asserting the
// concrete value guards against a silently-swapped hash impl.
let tmp = std::env::temp_dir().join(format!("nguardia-sha256-empty-{}", Uuid::new_v4()));
std::fs::write(&tmp, b"").unwrap();
let hex = sha256_file(&tmp).await.expect("hash empty file");
assert_eq!(hex, "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
std::fs::remove_file(&tmp).ok();
}
#[tokio::test]
async fn sha256_file_hex_matches_multi_chunk_content() {
// A payload larger than the 64KB internal read buffer so the
// chunk-loop path actually executes; "abc" repeated until > 64KB.
let tmp = std::env::temp_dir().join(format!("nguardia-sha256-bulk-{}", Uuid::new_v4()));
let payload = "abc".repeat(30_000);
std::fs::write(&tmp, payload.as_bytes()).unwrap();
let hex = sha256_file(&tmp).await.expect("hash large file");
let mut hasher = Sha256::new();
hasher.update(payload.as_bytes());
let expected = hasher.finalize();
let expected_hex: String = expected.iter().map(|b| format!("{b:02x}")).collect();
assert_eq!(hex, expected_hex);
std::fs::remove_file(&tmp).ok();
}
#[test]
fn promote_error_validation_failure_maps_to_422() {
let resp = PromoteError::ValidationFailed("shape mismatch".into()).into_response();
assert_eq!(resp.status().as_u16(), 422);
}
#[test]
fn promote_error_manifest_invalid_maps_to_422() {
let resp = PromoteError::ManifestInvalid("bad yaml".into()).into_response();
assert_eq!(resp.status().as_u16(), 422);
}
#[test]
fn promote_error_unsupported_adapter_maps_to_422() {
let resp = PromoteError::UnsupportedAdapter.into_response();
assert_eq!(resp.status().as_u16(), 422);
}
#[test]
fn promote_error_staging_io_maps_to_500() {
let resp = PromoteError::StagingIo("disk full".into()).into_response();
assert_eq!(resp.status().as_u16(), 500);
}
#[test]
fn promote_error_promote_io_maps_to_500() {
let resp = PromoteError::PromoteIo("rename failed".into()).into_response();
assert_eq!(resp.status().as_u16(), 500);
}
#[test]
fn upload_error_scaler_too_large_maps_to_413_and_echoes_cap() {
let resp = UploadError::ScalerTooLarge(1234).into_response();
assert_eq!(resp.status().as_u16(), 413);
}
#[test]
fn upload_error_onnx_too_large_echoes_configured_cap_in_message() {
// Dynamic cap from config must reach the client verbatim — this
// guards against a future refactor that silently drops the cap
// from the format string.
let rendered = format!("{:?}", UploadError::OnnxTooLarge(7_000_000));
assert!(
rendered.contains("7000000"),
"rendered error must include the cap: {rendered}"
);
}
}

View File

@ -1,12 +1,17 @@
use std::fs;
use actix_web::{HttpResponse, Scope, web};
use chrono::Local;
use tokio::task::spawn_blocking;
use crate::adapter::persistence::Database;
use crate::core::auth::extractor::AuthClaims;
use crate::core::email::report::generate_weekly_report;
use crate::core::email::scheduler::SmtpClient;
use crate::core::report::engine;
use crate::infrastructure::secret_store::SecretStore;
use crate::interface::port::repository::RepositoryPort;
use crate::interface::port::secret_store::SecretStorePort;
use crate::interface::port::setting::SettingRepo;
pub fn initialize() -> Scope {
web::scope("/report")
.route("/generate", web::post().to(generate_report))
@ -20,14 +25,14 @@ async fn generate_report(_auth: AuthClaims, db: web::Data<Database>) -> HttpResp
.ok()
.flatten()
.unwrap_or_else(|| "/var/lib/netguardia/reports".to_string());
if let Err(e) = std::fs::create_dir_all(&report_dir) {
if let Err(e) = fs::create_dir_all(&report_dir) {
return HttpResponse::InternalServerError().json(serde_json::json!({
"error": format!("Failed to create report directory: {}", e)
}));
}
let db_ref = db.get_ref();
match engine::generate_html_report(db_ref as &dyn RepositoryPort, &report_dir) {
Ok(path) => match std::fs::read(&path) {
match engine::generate_html_report(db_ref as &dyn SettingRepo, &report_dir) {
Ok(path) => match fs::read(&path) {
Ok(content) => HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.insert_header((
@ -52,7 +57,7 @@ async fn generate_report(_auth: AuthClaims, db: web::Data<Database>) -> HttpResp
async fn report_data(_auth: AuthClaims, db: web::Data<Database>) -> HttpResponse {
let db_ref = db.get_ref();
match engine::generate_report_json(db_ref as &dyn RepositoryPort) {
match engine::generate_report_json(db_ref as &dyn SettingRepo) {
Ok(data) => HttpResponse::Ok().json(data),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
@ -60,7 +65,7 @@ async fn report_data(_auth: AuthClaims, db: web::Data<Database>) -> HttpResponse
/// Manually trigger: generate the weekly report and send it via SMTP now.
async fn send_report(_auth: AuthClaims, db: web::Data<Database>, secrets: web::Data<SecretStore>) -> HttpResponse {
let db_ref = db.get_ref() as &dyn RepositoryPort;
let db_ref = db.get_ref() as &dyn SettingRepo;
let secrets_ref = secrets.get_ref() as &dyn SecretStorePort;
let smtp = match SmtpClient::from_database(db_ref, Some(secrets_ref)) {
@ -89,7 +94,7 @@ async fn send_report(_auth: AuthClaims, db: web::Data<Database>, secrets: web::D
}
};
let html = match crate::core::email::report::generate_weekly_report(db_ref) {
let html = match generate_weekly_report(db_ref) {
Ok(h) => h,
Err(e) => {
return HttpResponse::InternalServerError().json(serde_json::json!({
@ -99,9 +104,9 @@ async fn send_report(_auth: AuthClaims, db: web::Data<Database>, secrets: web::D
}
};
let subject = format!("NetGuardia Weekly Report — {}", chrono::Local::now().format("%Y-%m-%d"));
let subject = format!("NetGuardia Weekly Report — {}", Local::now().format("%Y-%m-%d"));
let send_result = tokio::task::spawn_blocking(move || smtp.send(&recipient, &subject, &html)).await;
let send_result = spawn_blocking(move || smtp.send(&recipient, &subject, &html)).await;
match send_result {
Ok(Ok(())) => HttpResponse::Ok().json(serde_json::json!({

View File

@ -1,14 +1,18 @@
use actix_web::{HttpResponse, Scope, web};
use serde::Deserialize;
use std::fs;
use std::path::Path;
use std::sync::atomic::Ordering;
use actix_web::{HttpResponse, Scope, web};
use macros::log;
use serde::Deserialize;
use serde_json::Value;
use crate::adapter::persistence::Database;
use crate::core::auth::password;
use crate::core::auth::setup_guard::SetupCompleteFlag;
use crate::infrastructure::secret_store::SecretStore;
use crate::interface::port::secret_store::SecretStorePort;
use crate::model::error::Error;
use crate::model::error::system::SystemError;
pub fn initialize() -> Scope {
@ -27,7 +31,7 @@ async fn setup_status(setup_flag: web::Data<SetupCompleteFlag>) -> HttpResponse
async fn list_interfaces() -> HttpResponse {
// List available network interfaces
let interfaces: Vec<serde_json::Value> = match std::fs::read_dir("/sys/class/net") {
let interfaces: Vec<Value> = match fs::read_dir("/sys/class/net") {
Ok(entries) => entries
.filter_map(|e| e.ok())
.map(|e| {
@ -98,7 +102,7 @@ async fn complete_setup(
}));
}
let iface_path = format!("/sys/class/net/{}", iface);
if !std::path::Path::new(&iface_path).exists() {
if !Path::new(&iface_path).exists() {
return HttpResponse::BadRequest().json(serde_json::json!({
"error": format!("Network interface '{}' not found", iface)
}));
@ -172,52 +176,7 @@ async fn complete_setup(
}))
}
#[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,
secrets: &dyn SecretStorePort,
req: &SetupRequest,
) -> Result<(), crate::model::error::Error> {
fn save_config(db: &Database, secrets: &dyn SecretStorePort, req: &SetupRequest) -> Result<(), Error> {
// Save network config
db.set_setting("ingress_interface", &req.ingress_interface)?;
db.set_setting("egress_interface", &req.egress_interface)?;
@ -258,3 +217,44 @@ fn save_config(
Ok(())
}
#[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"));
}
}

View File

@ -1,8 +1,12 @@
use std::str::FromStr;
use actix_web::{HttpResponse, Scope, web};
use serde::Deserialize;
use crate::core::auth::extractor::AuthClaims;
use crate::core::playbook_service::PlaybookService;
use crate::core::soar::engine::SoarEngine;
use crate::model::event::{DetectionSource, ThreatDetectedEvent};
use crate::model::soar::playbook_data::{CreateConditionInput, CreatePlaybookInput};
#[derive(Deserialize)]
@ -44,6 +48,7 @@ pub fn initialize() -> Scope {
.route("/whitelist", web::get().to(list_whitelist))
.route("/whitelist", web::post().to(add_whitelist))
.route("/whitelist/{ip}", web::delete().to(remove_whitelist))
.route("/dry-run", web::post().to(dry_run))
}
async fn list_playbooks(_auth: AuthClaims, svc: web::Data<PlaybookService>) -> HttpResponse {
@ -317,3 +322,83 @@ async fn remove_whitelist(_auth: AuthClaims, svc: web::Data<PlaybookService>, pa
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
}
}
/// Client shape for `POST /api/soar/dry-run`. Only the fields a SOAR
/// matcher actually reads are carried — `dest_ip`, `protocol`,
/// `packet_rate`, `flow_count` participate in neither trigger-matching
/// nor condition evaluation, so accepting them would just invite
/// confusion. Sensible defaults fill in the rest of the synthetic
/// `ThreatDetectedEvent` body.
#[derive(Deserialize)]
struct DryRunRequest {
attack_type: String,
confidence: f32,
source_ip: String,
#[serde(default)]
sources: Option<Vec<String>>,
#[serde(default)]
active_source_count: Option<usize>,
#[serde(default)]
fused_confidence: Option<f32>,
#[serde(default)]
geoip_country: Option<String>,
#[serde(default)]
is_repeat_offender: Option<bool>,
}
/// `POST /api/soar/dry-run` — simulate every enabled playbook against
/// a synthetic event. No actions execute, no cooldown or frequency
/// state gets recorded. Useful for an admin who just edited a
/// playbook's conditions and wants to sanity-check the match logic
/// before enabling it.
async fn dry_run(_auth: AuthClaims, engine: web::Data<SoarEngine>, body: web::Json<DryRunRequest>) -> HttpResponse {
let event = match build_event(body.into_inner()) {
Ok(e) => e,
Err(msg) => {
return HttpResponse::BadRequest().json(serde_json::json!({ "error": msg }));
}
};
let matches = engine.dry_run(&event);
HttpResponse::Ok().json(serde_json::json!({
"match_count": matches.iter().filter(|m| m.would_fire).count(),
"playbooks_evaluated": matches.len(),
"results": matches,
}))
}
/// Translate a wire `DryRunRequest` into a synthetic `ThreatDetectedEvent`.
/// Errors on typo'd `DetectionSource` names so an admin dry-running a
/// `SingleSourceHigh` condition doesn't silently get an empty sources
/// vector and a "doesn't match" result they misread as the playbook
/// being broken.
fn build_event(req: DryRunRequest) -> Result<ThreatDetectedEvent, String> {
let sources: Vec<DetectionSource> = match req.sources {
Some(names) => names
.iter()
.map(|n| DetectionSource::from_str(n).map_err(|_| format!("unknown DetectionSource: {n}")))
.collect::<Result<Vec<_>, _>>()?,
None => vec![DetectionSource::ML],
};
if sources.is_empty() {
return Err("sources[] must contain at least one DetectionSource (send null to default to [ML])".to_string());
}
let active_source_count = req.active_source_count.unwrap_or(sources.len());
let fused_confidence = req.fused_confidence.unwrap_or(req.confidence);
Ok(ThreatDetectedEvent {
attack_type: req.attack_type,
confidence: req.confidence,
source_ip: req.source_ip,
dest_ip: "0.0.0.0".to_string(),
flow_count: 1,
packet_rate: 0.0,
protocol: 6,
geoip_country: req.geoip_country,
is_repeat_offender: req.is_repeat_offender.unwrap_or(false),
sources,
active_source_count,
fused_confidence,
ae_score: 0.0,
anomaly_score: 0.0,
c2_score: 0.0,
})
}

View File

@ -1,6 +1,6 @@
use actix_web::{HttpResponse, Responder, Scope, web};
use crate::core::ebpf::drop_monitor::DropMonitor;
use crate::adapter::ebpf::drop_monitor::DropMonitor;
use crate::infrastructure::statistics::FlowStatistics;
pub fn initialize() -> Scope {

View File

@ -1,14 +1,17 @@
use actix_web::{HttpResponse, Responder, Scope, web};
use serde::Deserialize;
use crate::core::auth::extractor::AuthClaims;
use crate::core::config_service::ConfigService;
use crate::core::system::{ShutdownHandle, ShutdownMode};
use crate::infrastructure::communication_manager::CommunicationManager;
use crate::infrastructure::system::{ShutdownHandle, ShutdownMode};
use crate::interface::communication::command_types::ChangeEnforceModeCommand;
use crate::interface::communication::query_types::GetEnforceModeQuery;
use crate::interface::port::repository::RepositoryPort;
use crate::interface::port::app_repo::AppRepo;
use crate::utils::boot_time;
use crate::utils::logging::Logging;
type Repo = dyn RepositoryPort;
type Repo = dyn AppRepo;
#[derive(Deserialize)]
struct EnforceModeRequest {
@ -30,7 +33,7 @@ pub fn initialize() -> Scope {
}
async fn get_boot_time() -> impl Responder {
HttpResponse::Ok().json(crate::utils::boot_time::boot_time())
HttpResponse::Ok().json(boot_time::boot_time())
}
async fn get_enforce_mode(comm: web::Data<CommunicationManager>) -> impl Responder {
@ -80,7 +83,7 @@ async fn get_config(svc: web::Data<ConfigService>) -> impl Responder {
async fn get_log_level() -> impl Responder {
HttpResponse::Ok().json(serde_json::json!({
"level": crate::utils::logging::Logging::current_level(),
"level": Logging::current_level(),
}))
}
@ -90,7 +93,7 @@ struct LogLevelRequest {
}
async fn set_log_level(body: web::Json<LogLevelRequest>) -> impl Responder {
match crate::utils::logging::Logging::set_level(&body.level) {
match Logging::set_level(&body.level) {
Ok(new_level) => HttpResponse::Ok().json(serde_json::json!({
"level": new_level,
"message": "Log level updated",
@ -133,7 +136,10 @@ async fn update_config(
}
}
async fn shutdown(handle: web::Data<ShutdownHandle>) -> impl Responder {
async fn shutdown(auth: AuthClaims, handle: web::Data<ShutdownHandle>) -> impl Responder {
if !auth.permissions.iter().any(|p| p == "system:admin") {
return HttpResponse::Forbidden().json(serde_json::json!({"error": "Requires system:admin permission"}));
}
if handle.trigger(ShutdownMode::Shutdown) {
HttpResponse::Ok().json(serde_json::json!({"message": "Shutdown initiated"}))
} else {
@ -141,7 +147,10 @@ async fn shutdown(handle: web::Data<ShutdownHandle>) -> impl Responder {
}
}
async fn restart(handle: web::Data<ShutdownHandle>) -> impl Responder {
async fn restart(auth: AuthClaims, handle: web::Data<ShutdownHandle>) -> impl Responder {
if !auth.permissions.iter().any(|p| p == "system:admin") {
return HttpResponse::Forbidden().json(serde_json::json!({"error": "Requires system:admin permission"}));
}
if handle.trigger(ShutdownMode::Restart) {
HttpResponse::Ok().json(serde_json::json!({"message": "Restart initiated"}))
} else {

View File

@ -1,4 +1,5 @@
pub mod access_control_adapter;
pub mod ebpf;
pub mod http;
pub mod persistence;
pub mod telegram;

File diff suppressed because it is too large Load Diff

View File

@ -1,47 +1,50 @@
use std::sync::Arc;
use std::time::{Duration, Instant};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use async_trait::async_trait;
use parking_lot::Mutex;
use macros::log;
use reqwest::Client;
use tracing::{debug, warn};
use tokio::time::sleep;
use crate::interface::port::notification::{AlertNotifier, AlertPayload, NotificationConfigPort};
use crate::interface::port::repository::RepositoryPort;
use crate::interface::port::app_repo::AppRepo;
use crate::interface::port::notification::{AlertNotifier, AlertNotifierFactory, AlertPayload};
use crate::interface::port::secret_store::SecretStorePort;
use crate::interface::port::setting::SettingRepo;
use crate::model::config::constants::TELEGRAM_MAX_RETRIES;
use crate::model::error::Error;
use crate::model::error::notification::NotificationError;
use crate::model::log::system::SystemLog;
/// Telegram Bot API adapter implementing AlertNotifier.
pub struct TelegramAdapter {
client: Client,
notif: Arc<dyn NotificationConfigPort>,
repo: Arc<dyn RepositoryPort>,
notif: Arc<dyn SettingRepo>,
repo: Arc<dyn AppRepo>,
secrets: Option<Arc<dyn SecretStorePort>>,
/// Rate limiter: (count, window_start)
rate_state: Mutex<(u32, Instant)>,
/// Packed rate-limit state: high 32 bits = window-start unix seconds,
/// low 32 bits = count consumed in this window. Updated via CAS so the
/// hot path stays lock-free.
rate_state: AtomicU64,
}
impl TelegramAdapter {
pub fn new(
notif: Arc<dyn NotificationConfigPort>,
repo: Arc<dyn RepositoryPort>,
notif: Arc<dyn SettingRepo>,
repo: Arc<dyn AppRepo>,
secrets: Option<Arc<dyn SecretStorePort>>,
) -> 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),
})?;
.map_err(|e| NotificationError::TelegramRequestFailed(e.without_url()))?;
Ok(Self {
client,
notif,
repo,
secrets,
rate_state: Mutex::new((0, Instant::now())),
rate_state: AtomicU64::new(0),
})
}
@ -51,9 +54,7 @@ impl TelegramAdapter {
match self.notif.get_notification_config("telegram")? {
Some(json_str) => {
let config: serde_json::Value =
serde_json::from_str(&json_str).map_err(|e| NotificationError::TelegramApiError {
reason: format!("Invalid telegram config JSON: {}", e),
})?;
serde_json::from_str(&json_str).map_err(NotificationError::TelegramRequestFailed)?;
let mut token = config.get("bot_token").and_then(|v| v.as_str()).map(|s| s.to_string());
let chat_id = config.get("chat_id").and_then(|v| v.as_str()).map(|s| s.to_string());
@ -74,30 +75,60 @@ impl TelegramAdapter {
}
}
/// 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();
}
let max_per_min: u32 = self
.repo
.get_setting("telegram_max_messages_per_minute")
/// Read the configured per-window message cap from settings.
fn rate_limit_max_messages(&self) -> u32 {
self.repo
.get_setting("telegram_rate_limit_max_messages")
.ok()
.flatten()
.and_then(|v| v.parse().ok())
.unwrap_or(20);
if *count >= max_per_min {
.unwrap_or(20)
}
/// Read the configured window length (seconds) from settings.
fn rate_limit_window_secs(&self) -> u32 {
self.repo
.get_setting("telegram_rate_limit_window_secs")
.ok()
.flatten()
.and_then(|v| v.parse().ok())
.unwrap_or(60)
}
/// Check rate limit. Returns true if send is allowed.
fn check_rate_limit(&self) -> bool {
let max_messages = self.rate_limit_max_messages();
if max_messages == 0 {
return false;
}
let window_secs = self.rate_limit_window_secs().max(1);
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as u32)
.unwrap_or(0);
*count += 1;
true
loop {
let cur = self.rate_state.load(Ordering::Acquire);
let count = cur as u32;
let window = (cur >> 32) as u32;
let (next_count, next_window) = if now.saturating_sub(window) >= window_secs {
(1u32, now)
} else if count >= max_messages {
return false;
} else {
(count + 1, window)
};
let new = ((next_window as u64) << 32) | next_count as u64;
if self
.rate_state
.compare_exchange_weak(cur, new, Ordering::AcqRel, Ordering::Acquire)
.is_ok()
{
return true;
}
}
}
/// Send a message via Telegram Bot API with retry on 429.
@ -119,7 +150,11 @@ impl TelegramAdapter {
if e.is_timeout() {
NotificationError::Timeout
} else {
NotificationError::TelegramApiError { reason: e.to_string() }
// Strip URL — it embeds the bot token in the path
// (`/bot<TOKEN>/sendMessage`) and reqwest::Error's
// Display includes the full URL by default, which
// would leak the token into journal/error logs.
NotificationError::TelegramRequestFailed(e.without_url())
}
})?;
@ -132,10 +167,7 @@ impl TelegramAdapter {
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::TelegramChatNotFound(chat_id.to_string()).into());
}
return Err(NotificationError::TelegramAuthError.into());
}
@ -150,28 +182,21 @@ impl TelegramAdapter {
.unwrap_or(5);
if attempt < TELEGRAM_MAX_RETRIES {
warn!(
"Telegram rate limited, retrying after {}s (attempt {}/{})",
log!(SystemLog::TelegramRateLimitedRetry(
retry_after,
attempt + 1,
TELEGRAM_MAX_RETRIES
);
tokio::time::sleep(Duration::from_secs(retry_after)).await;
TELEGRAM_MAX_RETRIES,
));
sleep(Duration::from_secs(retry_after)).await;
continue;
} else {
return Err(NotificationError::TelegramRateLimited {
retry_after_secs: retry_after,
}
.into());
return Err(NotificationError::TelegramRateLimited(retry_after).into());
}
}
// Other error
let body = resp.text().await.unwrap_or_default();
return Err(NotificationError::TelegramApiError {
reason: format!("HTTP {}: {}", status, body),
}
.into());
Err(NotificationError::TelegramHttpError(status.as_u16(), body))?;
}
unreachable!()
@ -205,23 +230,17 @@ impl AlertNotifier for TelegramAdapter {
let (bot_token, chat_id) = match self.get_config()? {
Some(config) => config,
None => {
debug!("Telegram not configured, skipping alert");
log!(SystemLog::TelegramNotConfiguredSkipped);
return Ok(());
}
};
if !self.check_rate_limit() {
let max_per_min: u32 = self
.repo
.get_setting("telegram_max_messages_per_minute")
.ok()
.flatten()
.and_then(|v| v.parse().ok())
.unwrap_or(20);
warn!(
"Telegram rate limit reached ({}/min), dropping alert for IP {}",
max_per_min, payload.source_ip
);
log!(SystemLog::TelegramLocalRateLimitDropped(
self.rate_limit_max_messages(),
self.rate_limit_window_secs(),
payload.source_ip.clone(),
));
return Ok(());
}
@ -232,12 +251,7 @@ impl AlertNotifier for TelegramAdapter {
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());
}
None => Err(NotificationError::NotConfigured("telegram"))?,
};
self.send_message(
@ -248,3 +262,26 @@ impl AlertNotifier for TelegramAdapter {
.await
}
}
/// Adapter-side factory that satisfies the `AlertNotifierFactory` port. Holds
/// the same shared dependencies the long-lived adapter uses; each `create()`
/// call instantiates a fresh `TelegramAdapter` so the test path observes
/// whatever config the user just saved.
pub struct TelegramAdapterFactory {
notif: Arc<dyn SettingRepo>,
repo: Arc<dyn AppRepo>,
secrets: Option<Arc<dyn SecretStorePort>>,
}
impl TelegramAdapterFactory {
pub fn new(notif: Arc<dyn SettingRepo>, repo: Arc<dyn AppRepo>, secrets: Option<Arc<dyn SecretStorePort>>) -> Self {
Self { notif, repo, secrets }
}
}
impl AlertNotifierFactory for TelegramAdapterFactory {
fn create(&self) -> Result<Arc<dyn AlertNotifier>, Error> {
let adapter = TelegramAdapter::new(self.notif.clone(), self.repo.clone(), self.secrets.clone())?;
Ok(Arc::new(adapter))
}
}

View File

@ -1,21 +1,23 @@
use actix_web::rt::spawn;
use actix_web::{HttpRequest, HttpResponse, Result, web};
use actix_ws::{Message, MessageStream, Session, handle};
use futures_util::StreamExt;
use macros::log;
use tokio::sync::broadcast;
use tokio::sync::broadcast::error::RecvError;
use crate::core::ml::alert::MLAlert;
use crate::model::detection::ml_detection::AlertMessage;
use crate::model::error::http::HttpError;
use crate::model::error::misc::MiscError;
use crate::model::log::http::HttpLog;
use crate::model::ml_detection::AlertMessage;
pub async fn websocket_alert(req: HttpRequest, body: web::Payload, ai: web::Data<MLAlert>) -> Result<HttpResponse> {
let (response, session, msg_stream) = handle(&req, body)?;
let broadcast_rx = ai.subscribe_to_alerts();
actix_web::rt::spawn(async move {
spawn(async move {
handle_alert_connection(session, msg_stream, broadcast_rx).await;
});
@ -41,11 +43,11 @@ async fn handle_alert_connection(
break;
}
}
Err(broadcast::error::RecvError::Lagged(skipped)) => {
Err(RecvError::Lagged(skipped)) => {
log!(HttpLog::WebSocketLagged(skipped));
continue;
}
Err(broadcast::error::RecvError::Closed) => {
Err(RecvError::Closed) => {
break;
}
}

View File

@ -1,14 +1,16 @@
use actix_web::rt::spawn;
use actix_web::{HttpRequest, HttpResponse, Result, web};
use actix_ws::{Message, MessageStream, Session, handle};
use futures_util::StreamExt;
use macros::log;
use tokio::sync::broadcast;
use tokio::sync::broadcast::error::RecvError;
use crate::core::ebpf::drop_monitor::DropMonitor;
use crate::model::drop_event::DropEventMessage;
use crate::adapter::ebpf::drop_monitor::DropMonitor;
use crate::model::error::http::HttpError;
use crate::model::error::misc::MiscError;
use crate::model::log::http::HttpLog;
use crate::model::monitoring::drop_event::DropEventMessage;
pub async fn websocket_drops(
req: HttpRequest,
@ -19,7 +21,7 @@ pub async fn websocket_drops(
let broadcast_rx = monitor.subscribe();
actix_web::rt::spawn(async move {
spawn(async move {
handle_drop_connection(session, msg_stream, broadcast_rx).await;
});
@ -45,11 +47,11 @@ async fn handle_drop_connection(
break;
}
}
Err(broadcast::error::RecvError::Lagged(skipped)) => {
Err(RecvError::Lagged(skipped)) => {
log!(HttpLog::WebSocketLagged(skipped));
continue;
}
Err(broadcast::error::RecvError::Closed) => {
Err(RecvError::Closed) => {
break;
}
}

View File

@ -1,12 +1,13 @@
use std::time::Duration;
use actix_web::rt::spawn;
use actix_web::{HttpRequest, HttpResponse, web};
use actix_ws::Message;
use futures_util::StreamExt;
use tokio::time::interval;
use crate::infrastructure::statistics::FlowStatistics;
use crate::model::flow_stats::FlowSubscription;
use crate::model::monitoring::flow_stats::FlowSubscription;
/// Default subscription: all flows, no filter, 5 second interval
fn default_subscription() -> FlowSubscription {
@ -31,7 +32,7 @@ pub async fn flow_stats_ws(
) -> Result<HttpResponse, actix_web::Error> {
let (response, mut session, mut msg_stream) = actix_ws::handle(&req, body)?;
actix_web::rt::spawn(async move {
spawn(async move {
let mut subscription = default_subscription();
let mut ticker = interval(Duration::from_secs(subscription.interval_secs.unwrap_or(5)));
@ -66,11 +67,7 @@ pub async fn flow_stats_ws(
}
}
}
Some(Ok(Message::Ping(bytes))) => {
if session.pong(&bytes).await.is_err() {
break;
}
}
Some(Ok(Message::Ping(bytes))) if session.pong(&bytes).await.is_err() => break,
Some(Ok(Message::Close(_))) | None => break,
_ => {}
}

View File

@ -0,0 +1,192 @@
//! WebSocket bridge for post-fusion threat events.
//!
//! `/ws/fusion` subscribes to the `ThreatDetectedEvent` broadcast that the
//! `DetectionOrchestrator` already publishes through `CommunicationManager`
//! (the same stream SOAR consumes). Each event is wrapped with a server-side
//! `ts` (unix seconds) so the dashboard can render relative timestamps
//! without doing the conversion itself.
//!
//! Distinct from `/ws/alerts` (flow-level ML detections via `MLAlert`):
//! this stream is the **fused, per-IP, multi-source** view that drives the
//! Overview "Recent Threats" card and the sources-agreed chip. Treating
//! them as one channel would conflate two bounded contexts — see
//! `docs/strategy/DOMAIN_MAP.md` for the BC split rationale.
use std::time::{SystemTime, UNIX_EPOCH};
use actix_web::rt::spawn;
use actix_web::{HttpRequest, HttpResponse, Result, web};
use actix_ws::{Message, MessageStream, Session, handle};
use futures_util::StreamExt;
use macros::log;
use tokio::sync::broadcast;
use tokio::sync::broadcast::error::RecvError;
use crate::infrastructure::communication_manager::CommunicationManager;
use crate::model::error::http::HttpError;
use crate::model::error::misc::MiscError;
use crate::model::event::ThreatDetectedEvent;
use crate::model::log::http::HttpLog;
pub async fn websocket_fusion(
req: HttpRequest,
body: web::Payload,
comm: web::Data<CommunicationManager>,
) -> Result<HttpResponse> {
let (response, session, msg_stream) = handle(&req, body)?;
let broadcast_rx = match comm.subscribe_event::<ThreatDetectedEvent>() {
Ok(rx) => rx,
Err(e) => {
log!(HttpLog::FusionSubscribeFailed(e.to_string()));
return Ok(HttpResponse::InternalServerError().json(serde_json::json!({
"error": "fusion event channel not registered",
})));
}
};
spawn(async move {
handle_fusion_connection(session, msg_stream, broadcast_rx).await;
});
Ok(response)
}
async fn handle_fusion_connection(
mut session: Session,
mut msg_stream: MessageStream,
mut broadcast_rx: broadcast::Receiver<ThreatDetectedEvent>,
) {
loop {
tokio::select! {
msg_result = msg_stream.next() => {
if !handle_client_message(&mut session, msg_result).await {
break;
}
},
broadcast_result = broadcast_rx.recv() => {
match broadcast_result {
Ok(event) => {
if !send_event(&mut session, &event).await {
break;
}
}
Err(RecvError::Lagged(skipped)) => {
log!(HttpLog::WebSocketLagged(skipped));
continue;
}
Err(RecvError::Closed) => {
break;
}
}
},
}
}
let _ = session.close(None).await;
}
async fn handle_client_message(
session: &mut Session,
msg_result: Option<Result<Message, actix_ws::ProtocolError>>,
) -> bool {
match msg_result {
Some(Ok(Message::Text(_))) => true,
Some(Ok(Message::Ping(bytes))) => session.pong(&bytes).await.is_ok(),
Some(Ok(Message::Close(reason))) => {
let _ = (session.clone()).close(reason).await;
false
}
Some(Err(err)) => {
log!(HttpError::WebSocketError(err));
false
}
None => false,
_ => true,
}
}
/// Wrap each event in `{ts, ...event_fields}`. The `ts` is a server-stamped
/// unix-seconds value so the client can render "5s ago" without inferring
/// the time from the audit chain. All declared fields of
/// `ThreatDetectedEvent` flow through verbatim via the event's own
/// `Serialize` derive — no field whitelist to drift out of date.
async fn send_event(session: &mut Session, event: &ThreatDetectedEvent) -> bool {
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let payload = match serde_json::to_value(event) {
Ok(serde_json::Value::Object(mut map)) => {
map.insert("ts".to_string(), serde_json::Value::from(ts));
serde_json::Value::Object(map)
}
// The derived Serialize on a struct always produces an Object —
// this branch only fires if the type changes shape in a future
// refactor. Falling back to the raw value keeps the stream alive.
Ok(other) => other,
Err(err) => {
log!(MiscError::SerializeError(err));
return false;
}
};
match serde_json::to_string(&payload) {
Ok(json) => session.text(json).await.is_ok(),
Err(err) => {
log!(MiscError::SerializeError(err));
false
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::event::DetectionSource;
fn sample_event() -> ThreatDetectedEvent {
ThreatDetectedEvent {
attack_type: "brute_force".to_string(),
confidence: 0.92,
source_ip: "203.0.113.10".to_string(),
dest_ip: "10.0.0.1".to_string(),
flow_count: 3,
packet_rate: 12.5,
protocol: 6,
geoip_country: Some("CN".to_string()),
is_repeat_offender: true,
sources: vec![DetectionSource::ML, DetectionSource::Suricata],
active_source_count: 2,
fused_confidence: 0.99,
ae_score: 0.0,
anomaly_score: 0.0,
c2_score: 0.0,
}
}
#[test]
fn event_serializes_with_canonical_source_strings() {
let event = sample_event();
let json = serde_json::to_value(&event).expect("serialize event");
let sources = json["sources"].as_array().expect("sources array");
assert_eq!(sources[0], "ML");
assert_eq!(sources[1], "Suricata");
assert_eq!(json["active_source_count"], 2);
assert_eq!(json["geoip_country"], "CN");
}
#[test]
fn envelope_adds_ts_field_to_event_object() {
// The `send_event` wire path inserts `ts` into the event's own
// serde object; mirror that here without an actix session so the
// wrapping logic stays covered when the orchestrator schema evolves.
let event = sample_event();
let mut value = serde_json::to_value(&event).expect("serialize event");
let object = value.as_object_mut().expect("expected object shape");
object.insert("ts".to_string(), serde_json::Value::from(1_700_000_000_u64));
assert_eq!(value["ts"], 1_700_000_000_u64);
assert_eq!(value["attack_type"], "brute_force");
}
}

View File

@ -1,14 +1,16 @@
use actix_web::rt::spawn;
use actix_web::{HttpRequest, HttpResponse, Result, web};
use actix_ws::{Message, MessageStream, Session, handle};
use futures_util::StreamExt;
use macros::log;
use tokio::sync::broadcast;
use tokio::sync::broadcast::error::RecvError;
use crate::infrastructure::health::SystemHealth;
use crate::model::error::http::HttpError;
use crate::model::error::misc::MiscError;
use crate::model::health::SystemHealthMetrics;
use crate::model::log::http::HttpLog;
use crate::model::system::health::SystemHealthMetrics;
pub async fn websocket_system_health(
req: HttpRequest,
@ -19,7 +21,7 @@ pub async fn websocket_system_health(
let broadcast_rx = health.subscribe_to_metrics();
actix_web::rt::spawn(async move {
spawn(async move {
handle_health_connection(session, msg_stream, broadcast_rx).await;
});
@ -45,11 +47,11 @@ async fn handle_health_connection(
break;
}
}
Err(broadcast::error::RecvError::Lagged(skipped)) => {
Err(RecvError::Lagged(skipped)) => {
log!(HttpLog::WebSocketLagged(skipped));
continue;
}
Err(broadcast::error::RecvError::Closed) => {
Err(RecvError::Closed) => {
break;
}
}

View File

@ -1,5 +1,6 @@
pub mod alert_websocket;
pub mod drop_websocket;
pub mod flow_websocket;
pub mod fusion_websocket;
pub mod health_websocket;
pub mod routes;

View File

@ -1,10 +1,11 @@
use actix_web::{HttpRequest, HttpResponse, Responder, Scope, web};
use serde::Deserialize;
use super::{alert_websocket, drop_websocket, flow_websocket, health_websocket};
use super::{alert_websocket, drop_websocket, flow_websocket, fusion_websocket, health_websocket};
use crate::adapter::ebpf::drop_monitor::DropMonitor;
use crate::core::auth::jwt::JwtService;
use crate::core::ebpf::drop_monitor::DropMonitor;
use crate::core::ml::alert::MLAlert;
use crate::infrastructure::communication_manager::CommunicationManager;
use crate::infrastructure::health::SystemHealth;
use crate::infrastructure::statistics::FlowStatistics;
@ -17,6 +18,7 @@ pub fn initialize() -> Scope {
web::scope("/ws")
.route("/health", web::get().to(health_ws))
.route("/alerts", web::get().to(alerts_ws))
.route("/fusion", web::get().to(fusion_ws))
.route("/flows", web::get().to(flows_ws))
.route("/drops", web::get().to(drops_ws))
}
@ -84,6 +86,24 @@ async fn alerts_ws(
}
}
async fn fusion_ws(
req: HttpRequest,
stream: web::Payload,
comm: web::Data<CommunicationManager>,
query: web::Query<WsQuery>,
jwt: web::Data<JwtService>,
) -> impl Responder {
if let Err(resp) = validate_ws_token(&req, &query, &jwt) {
return resp;
}
match fusion_websocket::websocket_fusion(req, stream, comm).await {
Ok(response) => response,
Err(err) => {
HttpResponse::InternalServerError().json(serde_json::json!({"error": format!("WebSocket error: {}", err)}))
}
}
}
async fn flows_ws(
req: HttpRequest,
stream: web::Payload,

View File

@ -1,26 +1,30 @@
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 crate::interface::port::access_control_admin::AccessControlAdminPort;
use crate::interface::port::app_repo::AppRepo;
use crate::interface::port::geo_block_api::GeoBlockPort;
use crate::model::monitoring::direction::FlowDirection;
use macros::log;
use crate::model::access_control::list_type::ListType;
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>,
db: Arc<dyn AppRepo>,
access_control: Arc<dyn AccessControlAdminPort>,
geo_block: Arc<dyn GeoBlockPort>,
}
impl AclService {
pub fn new(db: Arc<dyn RepositoryPort>, access_control: Arc<AccessControl>, geo_block: Arc<GeoBlock>) -> Self {
pub fn new(
db: Arc<dyn AppRepo>,
access_control: Arc<dyn AccessControlAdminPort>,
geo_block: Arc<dyn GeoBlockPort>,
) -> Self {
Self {
db,
access_control,
@ -28,13 +32,8 @@ impl AclService {
}
}
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?;
pub fn add_ipv4(&self, direction: FlowDirection, list_type: ListType, address: SocketAddrV4) -> Result<(), Error> {
self.access_control.add_ipv4_list(direction, list_type, address)?;
if let Err(e) = self.db.insert_acl_rule(
4,
direction_str(direction),
@ -42,11 +41,7 @@ impl AclService {
&address.ip().to_string(),
address.port(),
) {
if let Err(rollback_err) = self
.access_control
.remove_ipv4_list(direction, list_type, address)
.await
{
if let Err(rollback_err) = self.access_control.remove_ipv4_list(direction, list_type, address) {
log!(EbpfError::RollbackFailed(rollback_err));
}
return Err(e);
@ -54,13 +49,8 @@ impl AclService {
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?;
pub fn add_ipv6(&self, direction: FlowDirection, list_type: ListType, address: SocketAddrV6) -> Result<(), Error> {
self.access_control.add_ipv6_list(direction, list_type, address)?;
if let Err(e) = self.db.insert_acl_rule(
6,
direction_str(direction),
@ -68,11 +58,7 @@ impl AclService {
&address.ip().to_string(),
address.port(),
) {
if let Err(rollback_err) = self
.access_control
.remove_ipv6_list(direction, list_type, address)
.await
{
if let Err(rollback_err) = self.access_control.remove_ipv6_list(direction, list_type, address) {
log!(EbpfError::RollbackFailed(rollback_err));
}
return Err(e);
@ -80,15 +66,13 @@ impl AclService {
Ok(())
}
pub async fn remove_ipv4(
pub fn remove_ipv4(
&self,
direction: FlowDirection,
list_type: ListType,
address: SocketAddrV4,
) -> Result<(), Error> {
self.access_control
.remove_ipv4_list(direction, list_type, address)
.await?;
self.access_control.remove_ipv4_list(direction, list_type, address)?;
if let Err(e) = self.db.delete_acl_rule(
4,
direction_str(direction),
@ -96,7 +80,7 @@ impl AclService {
&address.ip().to_string(),
address.port(),
) {
if let Err(rollback_err) = self.access_control.add_ipv4_list(direction, list_type, address).await {
if let Err(rollback_err) = self.access_control.add_ipv4_list(direction, list_type, address) {
log!(EbpfError::RollbackFailed(rollback_err));
}
return Err(e);
@ -104,15 +88,13 @@ impl AclService {
Ok(())
}
pub async fn remove_ipv6(
pub fn remove_ipv6(
&self,
direction: FlowDirection,
list_type: ListType,
address: SocketAddrV6,
) -> Result<(), Error> {
self.access_control
.remove_ipv6_list(direction, list_type, address)
.await?;
self.access_control.remove_ipv6_list(direction, list_type, address)?;
if let Err(e) = self.db.delete_acl_rule(
6,
direction_str(direction),
@ -120,7 +102,7 @@ impl AclService {
&address.ip().to_string(),
address.port(),
) {
if let Err(rollback_err) = self.access_control.add_ipv6_list(direction, list_type, address).await {
if let Err(rollback_err) = self.access_control.add_ipv6_list(direction, list_type, address) {
log!(EbpfError::RollbackFailed(rollback_err));
}
return Err(e);
@ -147,11 +129,11 @@ impl AclService {
}
pub fn get_blocked_countries(&self) -> Vec<String> {
self.geo_block.get_blocked_countries()
self.geo_block.list_blocked()
}
pub fn access_control(&self) -> &AccessControl {
&self.access_control
pub fn access_control(&self) -> &dyn AccessControlAdminPort {
self.access_control.as_ref()
}
}

View File

@ -0,0 +1,195 @@
//! CSRF defense-in-depth middleware.
//!
//! The primary auth path uses `Authorization: Bearer <jwt>` — a scheme
//! the browser never auto-attaches — so classical CSRF against a
//! malicious same-origin form POST is already neutralized. This
//! middleware adds a belt-and-suspenders layer on top:
//!
//! - State-changing requests (anything that isn't `GET`/`HEAD`/`OPTIONS`)
//! must carry an `X-CSRF-Token` header.
//! - The header's presence alone is the check. Cross-origin attackers
//! cannot set custom request headers on simple requests (browsers
//! block that via the CORS preflight), so a successful request from
//! a third-party page would need to run JS inside our origin, at
//! which point CSRF is the wrong threat label anyway.
//! - Exempt: auth / setup bootstrap endpoints (no session yet),
//! WebSocket upgrade (no body to forge), and `X-API-Key`
//! authentication (sealed credential — the request isn't a browser
//! navigation at all).
//!
//! The decision lives in `should_require_csrf_token` so unit tests can
//! cover the path without spinning up an Actix test harness.
use std::future::{Future, Ready, ready};
use std::pin::Pin;
use std::rc::Rc;
use std::task::{Context, Poll};
use actix_web::body::EitherBody;
use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform};
use actix_web::http::Method;
use actix_web::{Error as ActixError, HttpResponse};
/// Request header carrying the CSRF token. Clients (frontend fetch /
/// axios wrappers) set this on every state-changing request; its value
/// is whatever the client produced (we don't validate content).
pub const CSRF_HEADER: &str = "X-CSRF-Token";
/// Header used by non-browser clients for API-key authentication. Such
/// clients are exempt from the CSRF requirement.
const API_KEY_HEADER: &str = "X-API-Key";
pub struct CsrfMiddleware;
impl<S, B> Transform<S, ServiceRequest> for CsrfMiddleware
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
B: 'static,
{
type Response = ServiceResponse<EitherBody<B>>;
type Error = ActixError;
type Transform = CsrfMiddlewareService<S>;
type InitError = ();
type Future = Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future {
ready(Ok(CsrfMiddlewareService {
service: Rc::new(service),
}))
}
}
pub struct CsrfMiddlewareService<S> {
service: Rc<S>,
}
impl<S, B> Service<ServiceRequest> for CsrfMiddlewareService<S>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
B: 'static,
{
type Response = ServiceResponse<EitherBody<B>>;
type Error = ActixError;
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>>>>;
fn poll_ready(&self, ctx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.service.poll_ready(ctx)
}
fn call(&self, req: ServiceRequest) -> Self::Future {
let service = Rc::clone(&self.service);
let path = req.path().to_string();
let method = req.method().clone();
let has_api_key = req.headers().contains_key(API_KEY_HEADER);
let has_csrf_token = req.headers().contains_key(CSRF_HEADER);
Box::pin(async move {
if should_require_csrf_token(&path, &method, has_api_key) && !has_csrf_token {
let resp = HttpResponse::Forbidden().json(serde_json::json!({
"error": "Missing CSRF token",
"header": CSRF_HEADER,
}));
return Ok(req.into_response(resp).map_into_right_body());
}
let res = service.call(req).await?.map_into_left_body();
Ok(res)
})
}
}
/// Decide whether a request must present a CSRF token. The rules are
/// extracted as a free function so the middleware is a thin shim and
/// the policy can be unit-tested without an HTTP harness.
pub fn should_require_csrf_token(path: &str, method: &Method, has_api_key: bool) -> bool {
if has_api_key {
return false;
}
if !is_state_changing(method) {
return false;
}
if is_csrf_exempt_path(path) {
return false;
}
true
}
fn is_state_changing(method: &Method) -> bool {
!matches!(*method, Method::GET | Method::HEAD | Method::OPTIONS)
}
/// Paths that cannot meaningfully carry a CSRF token because the
/// session that would mint one hasn't been established yet, or because
/// the route uses a protocol outside the CSRF threat model.
fn is_csrf_exempt_path(path: &str) -> bool {
// Login / setup bootstrap: no session yet, so no token to match.
if path == "/api/auth/login" || path.starts_with("/api/setup/") {
return true;
}
// WebSocket upgrade happens over a GET anyway, but list the prefix
// explicitly so the intent is visible when someone reads the file.
if path.starts_with("/ws/") {
return true;
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn get_requests_do_not_need_csrf_token() {
assert!(!should_require_csrf_token("/api/acl/rules", &Method::GET, false));
assert!(!should_require_csrf_token("/api/stats/summary", &Method::HEAD, false));
assert!(!should_require_csrf_token("/api/ml/status", &Method::OPTIONS, false));
}
#[test]
fn state_changing_requests_on_admin_routes_require_csrf_token() {
assert!(should_require_csrf_token("/api/acl/rules", &Method::POST, false));
assert!(should_require_csrf_token(
"/api/ml/models/current",
&Method::DELETE,
false
));
assert!(should_require_csrf_token("/api/soar/playbooks/1", &Method::PUT, false));
assert!(should_require_csrf_token(
"/api/notifications/telegram",
&Method::PATCH,
false
));
}
#[test]
fn login_and_setup_are_exempt() {
// Login hasn't yet issued a session, so there's no token to carry.
assert!(!should_require_csrf_token("/api/auth/login", &Method::POST, false));
assert!(!should_require_csrf_token(
"/api/setup/initialize",
&Method::POST,
false
));
}
#[test]
fn websocket_upgrade_is_exempt() {
// WS upgrade is a GET anyway but stays exempt under any verb.
assert!(!should_require_csrf_token("/ws/events", &Method::GET, false));
assert!(!should_require_csrf_token("/ws/events", &Method::POST, false));
}
#[test]
fn api_key_clients_are_exempt_even_on_state_changing_routes() {
// Non-browser clients present a sealed credential; CSRF is a
// browser threat model.
assert!(!should_require_csrf_token("/api/acl/rules", &Method::POST, true));
assert!(!should_require_csrf_token("/api/soar/playbooks", &Method::DELETE, true));
}
#[test]
fn api_key_exemption_takes_precedence_over_path_rules() {
// Even if the path is a state-changing admin route, the API-key
// header flips the requirement off before the path check runs.
assert!(!should_require_csrf_token("/api/system/reload", &Method::POST, true));
}
}

View File

@ -1,9 +1,11 @@
use std::future::{Ready, ready};
use std::ops::Deref;
use actix_web::dev::Payload;
use actix_web::{FromRequest, HttpMessage, HttpRequest};
use actix_web::error::ErrorUnauthorized;
use actix_web::{Error as ActixError, FromRequest, HttpMessage, HttpRequest};
use crate::model::auth::Claims;
use crate::model::identity::auth::Claims;
/// Actix-web extractor that pulls `Claims` from request extensions.
///
@ -19,7 +21,7 @@ use crate::model::auth::Claims;
/// ```
pub struct AuthClaims(pub Claims);
impl std::ops::Deref for AuthClaims {
impl Deref for AuthClaims {
type Target = Claims;
fn deref(&self) -> &Self::Target {
&self.0
@ -27,15 +29,13 @@ impl std::ops::Deref for AuthClaims {
}
impl FromRequest for AuthClaims {
type Error = actix_web::Error;
type Error = ActixError;
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"}),
))),
None => ready(Err(ErrorUnauthorized(serde_json::json!({"error": "Unauthorized"})))),
}
}
}

View File

@ -1,7 +1,10 @@
use std::future::{Future, Ready, ready};
use std::net::IpAddr;
use std::pin::Pin;
use std::rc::Rc;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::task::{Context, Poll};
use actix_web::body::EitherBody;
use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform};
@ -40,13 +43,13 @@ fn is_safe_redirect_host(host: &str) -> bool {
}
// Try parsing as IP — allow private ranges only
if let Ok(ip) = hostname.parse::<std::net::IpAddr>() {
if let Ok(ip) = hostname.parse::<IpAddr>() {
return match ip {
std::net::IpAddr::V4(v4) => {
IpAddr::V4(v4) => {
let o = v4.octets();
o[0] == 10 || (o[0] == 172 && (16..=31).contains(&o[1])) || (o[0] == 192 && o[1] == 168) || o[0] == 127
}
std::net::IpAddr::V6(v6) => v6.is_loopback() || (v6.segments()[0] & 0xfe00) == 0xfc00,
IpAddr::V6(v6) => v6.is_loopback() || (v6.segments()[0] & 0xfe00) == 0xfc00,
};
}
@ -68,13 +71,13 @@ where
fn new_transform(&self, service: S) -> Self::Future {
ready(Ok(HttpsRedirectService {
service: std::rc::Rc::new(service),
service: Rc::new(service),
}))
}
}
pub struct HttpsRedirectService<S> {
service: std::rc::Rc<S>,
service: Rc<S>,
}
impl<S, B> Service<ServiceRequest> for HttpsRedirectService<S>
@ -86,12 +89,12 @@ where
type Error = ActixError;
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>>>>;
fn poll_ready(&self, ctx: &mut core::task::Context<'_>) -> std::task::Poll<Result<(), Self::Error>> {
fn poll_ready(&self, ctx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.service.poll_ready(ctx)
}
fn call(&self, req: ServiceRequest) -> Self::Future {
let service = std::rc::Rc::clone(&self.service);
let service = Rc::clone(&self.service);
Box::pin(async move {
// Check if force_https is enabled

View File

@ -1,11 +1,12 @@
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode, errors::ErrorKind};
use crate::interface::port::secret_store::SecretStorePort;
use crate::model::auth::Claims;
use crate::model::error::Error;
use crate::model::error::auth::AuthError;
use crate::model::identity::auth::Claims;
pub struct JwtService {
encoding_key: EncodingKey,
@ -14,20 +15,15 @@ pub struct JwtService {
}
impl JwtService {
pub fn new(secrets: &Arc<dyn SecretStorePort>, expiry_hours: u64) -> Result<Self, Error> {
let raw_bytes = match secrets.get_secret("jwt_secret")? {
Some(hex_str) => hex_decode(&hex_str).map_err(|_| AuthError::InvalidToken)?,
None => {
use rand::Rng;
let secret: [u8; 32] = rand::rng().random();
secrets.set_secret("jwt_secret", &hex_encode(&secret))?;
secret.to_vec()
}
};
/// Generate a fresh random JWT signing secret on every boot.
/// This intentionally invalidates all existing tokens on restart.
pub fn new(_secrets: &Arc<dyn SecretStorePort>, expiry_hours: u64) -> Result<Self, Error> {
use rand::Rng;
let secret: [u8; 32] = rand::rng().random();
Ok(Self {
encoding_key: EncodingKey::from_secret(&raw_bytes),
decoding_key: DecodingKey::from_secret(&raw_bytes),
encoding_key: EncodingKey::from_secret(&secret),
decoding_key: DecodingKey::from_secret(&secret),
expiry_hours,
})
}
@ -39,9 +35,9 @@ impl JwtService {
role: &str,
permissions: Vec<String>,
) -> Result<String, Error> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or(std::time::Duration::ZERO)
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or(Duration::ZERO)
.as_secs();
let claims = Claims {
@ -67,25 +63,6 @@ impl JwtService {
}
}
fn hex_encode(data: &[u8]) -> String {
use std::fmt::Write;
let mut s = String::with_capacity(data.len() * 2);
for b in data {
write!(s, "{:02x}", b).unwrap();
}
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::*;
@ -137,18 +114,17 @@ mod tests {
}
#[test]
fn test_jwt_secret_persistence() {
fn test_jwt_secret_changes_on_new_instance() {
let db = Arc::new(Database::new(":memory:").unwrap());
let secrets: Arc<dyn SecretStorePort> = Arc::new(SecretStore::new(db));
// First creation generates and stores secret
let jwt1 = JwtService::new(&secrets, 24).unwrap();
let token = jwt1.create_token(1, "admin", "admin", vec![]).unwrap();
// Second creation reuses stored secret
// New instance = new secret = old token invalid (simulates restart)
let jwt2 = JwtService::new(&secrets, 24).unwrap();
let claims = jwt2.validate_token(&token).unwrap();
assert_eq!(claims.username, "admin");
let result = jwt2.validate_token(&token);
assert!(result.is_err());
}
#[test]
@ -160,36 +136,4 @@ mod tests {
let result = jwt2.validate_token(&token);
assert!(result.is_err());
}
#[test]
fn test_hex_decode_valid() {
let result = hex_decode("48656c6c6f").unwrap();
assert_eq!(result, b"Hello");
}
#[test]
fn test_hex_decode_empty() {
let result = hex_decode("").unwrap();
assert_eq!(result, Vec::<u8>::new());
}
#[test]
fn test_hex_decode_odd_length() {
let result = hex_decode("abc");
assert!(result.is_err());
}
#[test]
fn test_hex_decode_invalid_chars() {
let result = hex_decode("gg");
assert!(result.is_err());
}
#[test]
fn test_hex_roundtrip() {
let data = b"NetGuardia\x00\xff";
let encoded = hex_encode(data);
let decoded = hex_decode(&encoded).unwrap();
assert_eq!(decoded, data);
}
}

View File

@ -1,16 +1,18 @@
use std::future::{Future, Ready, ready};
use std::pin::Pin;
use std::rc::Rc;
use std::task::{Context, Poll};
use actix_web::body::EitherBody;
use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform};
use actix_web::http::Method;
use actix_web::{Error as ActixError, HttpMessage, HttpResponse, web};
use macros::log;
use crate::core::auth::jwt::JwtService;
use crate::interface::port::api_key::ApiKeyPort;
use crate::interface::port::repository::RepositoryPort;
use crate::interface::port::api_key::ApiKeyRepo;
use crate::interface::port::app_repo::AppRepo;
use crate::model::error::auth::AuthError;
pub struct AuthMiddleware;
@ -37,7 +39,7 @@ pub struct AuthMiddlewareService<S> {
service: Rc<S>,
}
fn required_permission(path: &str, method: &actix_web::http::Method) -> Option<String> {
fn required_permission(path: &str, method: &Method) -> Option<String> {
let resource = if path == "/api/auth/login" || path == "/api/auth/me" || path == "/api/auth/change-password" {
return None; // Public auth endpoints: login (no auth), me/change-password (auth-only, no RBAC)
} else if path.starts_with("/api/auth/") {
@ -45,8 +47,12 @@ fn required_permission(path: &str, method: &actix_web::http::Method) -> Option<S
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/") {
} else if path.starts_with("/api/ml/") || path.starts_with("/api/byo/") {
"ai_detection"
} else if path.starts_with("/api/fusion/") {
"fusion"
} else if path.starts_with("/api/flow-trace/") {
"flow_trace"
} else if path.starts_with("/api/acl/geo/") {
"geo_block"
} else if path.starts_with("/api/acl/") {
@ -75,7 +81,7 @@ fn required_permission(path: &str, method: &actix_web::http::Method) -> Option<S
};
let action = match *method {
actix_web::http::Method::GET => "read",
Method::GET => "read",
_ => "write",
};
@ -91,7 +97,7 @@ where
type Error = ActixError;
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>>>>;
fn poll_ready(&self, ctx: &mut core::task::Context<'_>) -> std::task::Poll<Result<(), Self::Error>> {
fn poll_ready(&self, ctx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.service.poll_ready(ctx)
}
@ -140,19 +146,19 @@ where
} else if let Some(api_key_header) = req.headers().get("X-API-Key") {
// API key auth with rate limiting
let api_key = api_key_header.to_str().unwrap_or("");
let api_key_port = match req.app_data::<web::Data<dyn ApiKeyPort>>() {
let api_key_port = match req.app_data::<web::Data<dyn ApiKeyRepo>>() {
Some(d) => d.clone(),
None => {
let resp = HttpResponse::InternalServerError()
.json(serde_json::json!({"error": "ApiKeyPort not configured"}));
.json(serde_json::json!({"error": "ApiKeyRepo not configured"}));
return Ok(req.into_response(resp).map_into_right_body());
}
};
let repo = match req.app_data::<web::Data<dyn RepositoryPort>>() {
let repo = match req.app_data::<web::Data<dyn AppRepo>>() {
Some(d) => d.clone(),
None => {
let resp = HttpResponse::InternalServerError()
.json(serde_json::json!({"error": "RepositoryPort not configured"}));
.json(serde_json::json!({"error": "AppRepo not configured"}));
return Ok(req.into_response(resp).map_into_right_body());
}
};

View File

@ -1,3 +1,4 @@
pub mod csrf;
pub mod extractor;
pub mod https_redirect;
pub mod jwt;

View File

@ -1,7 +1,9 @@
use std::future::{Future, Ready, ready};
use std::pin::Pin;
use std::rc::Rc;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::task::{Context, Poll};
use actix_web::body::EitherBody;
use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform};
@ -26,13 +28,13 @@ where
fn new_transform(&self, service: S) -> Self::Future {
ready(Ok(SetupGuardService {
service: std::rc::Rc::new(service),
service: Rc::new(service),
}))
}
}
pub struct SetupGuardService<S> {
service: std::rc::Rc<S>,
service: Rc<S>,
}
impl<S, B> Service<ServiceRequest> for SetupGuardService<S>
@ -44,12 +46,12 @@ where
type Error = ActixError;
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>>>>;
fn poll_ready(&self, ctx: &mut core::task::Context<'_>) -> std::task::Poll<Result<(), Self::Error>> {
fn poll_ready(&self, ctx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.service.poll_ready(ctx)
}
fn call(&self, req: ServiceRequest) -> Self::Future {
let service = std::rc::Rc::clone(&self.service);
let service = Rc::clone(&self.service);
Box::pin(async move {
let path = req.path().to_string();

View File

@ -1,6 +1,8 @@
use std::sync::Arc;
use crate::interface::port::repository::RepositoryPort;
use serde_json::Value;
use crate::interface::port::app_repo::AppRepo;
use crate::interface::port::secret_store::SecretStorePort;
use crate::model::error::Error;
use crate::model::error::misc::MiscError;
@ -54,19 +56,22 @@ const SETTINGS_MAP: &[(&str, &[&str])] = &[
("misc", &["geoip_db_name"]),
("soar", &["soar_max_auto_block_cap", "soar_max_ttl_secs"]),
("ml", &["ml_drift_window_secs"]),
("telegram", &["telegram_max_messages_per_minute"]),
(
"telegram",
&["telegram_rate_limit_max_messages", "telegram_rate_limit_window_secs"],
),
("dns", &["dns_max_domains_per_request"]),
("smtp", &["smtp_host", "smtp_port", "smtp_username", "smtp_recipient"]),
];
/// Domain service for system configuration read/write.
pub struct ConfigService {
db: Arc<dyn RepositoryPort>,
db: Arc<dyn AppRepo>,
secrets: Option<Arc<dyn SecretStorePort>>,
}
impl ConfigService {
pub fn new(db: Arc<dyn RepositoryPort>) -> Self {
pub fn new(db: Arc<dyn AppRepo>) -> Self {
Self { db, secrets: None }
}
@ -127,7 +132,8 @@ impl ConfigService {
"ml_drift_window_secs": get("ml_drift_window_secs"),
},
"telegram": {
"telegram_max_messages_per_minute": get("telegram_max_messages_per_minute"),
"telegram_rate_limit_max_messages": get("telegram_rate_limit_max_messages"),
"telegram_rate_limit_window_secs": get("telegram_rate_limit_window_secs"),
},
"dns": {
"dns_max_domains_per_request": get("dns_max_domains_per_request"),
@ -162,7 +168,11 @@ impl ConfigService {
}
}
// Route secret keys through SecretStore (encrypted storage)
// Route secret keys through SecretStore (encrypted storage).
// After writing to SecretStore, scrub the plaintext row in `settings`
// so a legacy plaintext value from pre-SecretStore deployments cannot
// linger — readers fall back to SecretStore when the plaintext row
// is empty.
if let Some(ref secrets) = self.secrets {
for key in SECRET_KEYS {
// Secret keys live under their parent section (e.g., smtp_password under smtp)
@ -174,9 +184,7 @@ impl ConfigService {
.and_then(json_value_as_string)
{
secrets.set_secret(key, &val)?;
// Clear plaintext residue from settings table to prevent
// pre-migration plaintext passwords from persisting.
let _ = self.db.set_setting(key, "");
self.db.set_setting(key, "")?;
updated.push(key.to_string());
}
}
@ -190,14 +198,11 @@ impl ConfigService {
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());
Err(MiscError::ValidationError(format!(
"Invalid pipeline stage '{}'. Valid stages: {}",
stage,
VALID_PIPELINE_STAGES.join(", ")
)))?;
}
}
}
@ -214,9 +219,9 @@ impl ConfigService {
/// 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()),
Value::String(s) if !s.is_empty() => Some(s.clone()),
Value::Bool(b) => Some(b.to_string()),
Value::Number(n) => Some(n.to_string()),
_ => None,
}
}

View File

@ -74,11 +74,11 @@ impl BotnetDetector {
};
if let Some(unique_sources) = should_alert {
log!(DetectionLog::BotnetDetected {
dst_ip: key.clone(),
log!(DetectionLog::BotnetDetected(
key.clone(),
unique_sources,
window_secs: BOTNET_WINDOW_SECS,
});
BOTNET_WINDOW_SECS,
));
// source_ip = the latest attacker; dest_ip = the victim being targeted.
// SOAR blocks source_ip, so we must NOT put the victim here.
@ -91,6 +91,9 @@ impl BotnetDetector {
protocol: alert.protocol,
packet_count: 0,
flow_duration_us: 0,
ae_score: 0.0,
anomaly_score: 0.0,
c2_score: 0.0,
};
let _ = detection_tx.try_send(event);
@ -127,6 +130,8 @@ impl BotnetDetector {
#[cfg(test)]
mod tests {
use std::thread;
use super::*;
fn make_alert(src_ip: &str, dst_ip: &str) -> AlertMessage {
@ -142,6 +147,8 @@ mod tests {
attack_type: Some("DDoS".to_string()),
confidence: 0.9,
ae_score: 0.5,
anomaly_score: 0.0,
c2_score: 0.0,
packet_count: 100,
flow_duration_us: 1_000_000,
}
@ -182,7 +189,7 @@ mod tests {
detector.process(&alert, &tx);
assert_eq!(detector.state.len(), 1);
std::thread::sleep(Duration::from_millis(20));
thread::sleep(Duration::from_millis(20));
let removed = detector.cleanup();
assert_eq!(removed, 1);
assert_eq!(detector.state.len(), 0);

View File

@ -1,7 +1,9 @@
use std::time::Duration;
use macros::log;
use tokio::sync::broadcast::error::RecvError;
use tokio::sync::{broadcast, mpsc};
use tokio::time::interval;
use crate::core::correlation::botnet::BotnetDetector;
use crate::core::correlation::lateral::LateralMovementDetector;
@ -43,15 +45,15 @@ impl CorrelationEngine {
async fn run(mut self) {
log!(DetectionLog::CorrelationEngineStarted);
let mut cleanup_interval = tokio::time::interval(Duration::from_secs(CLEANUP_INTERVAL_SECS));
let mut cleanup_interval = interval(Duration::from_secs(CLEANUP_INTERVAL_SECS));
loop {
tokio::select! {
result = self.alert_rx.recv() => {
match result {
Ok(alert) => self.process_alert(&alert),
Err(broadcast::error::RecvError::Lagged(_)) => continue,
Err(broadcast::error::RecvError::Closed) => break,
Err(RecvError::Lagged(_)) => continue,
Err(RecvError::Closed) => break,
}
}
_ = cleanup_interval.tick() => {
@ -70,7 +72,7 @@ impl CorrelationEngine {
fn cleanup(&self) {
let removed = self.botnet.cleanup() + self.scan.cleanup() + self.lateral.cleanup();
if removed > 0 {
log!(DetectionLog::CorrelationCleanup { removed });
log!(DetectionLog::CorrelationCleanup(removed));
}
}
}

View File

@ -74,11 +74,11 @@ impl LateralMovementDetector {
};
if let Some(unique_dests) = should_alert {
log!(DetectionLog::LateralMovementDetected {
src_ip: key.clone(),
log!(DetectionLog::LateralMovementDetected(
key.clone(),
unique_dests,
window_secs: LATERAL_WINDOW_SECS,
});
LATERAL_WINDOW_SECS,
));
let event = DetectionEvent {
source: DetectionSource::Correlation,
@ -89,6 +89,9 @@ impl LateralMovementDetector {
protocol: alert.protocol,
packet_count: 0,
flow_duration_us: 0,
ae_score: 0.0,
anomaly_score: 0.0,
c2_score: 0.0,
};
let _ = detection_tx.try_send(event);
@ -201,6 +204,8 @@ mod tests {
attack_type: Some("Exploitation".to_string()),
confidence: 0.8,
ae_score: 0.4,
anomaly_score: 0.0,
c2_score: 0.0,
packet_count: 50,
flow_duration_us: 500_000,
}

View File

@ -72,11 +72,7 @@ impl ScanDetector {
};
if let Some((unique_ports, last_dst_ip)) = should_alert {
log!(DetectionLog::ScanDetected {
src_ip: key.clone(),
unique_ports,
window_secs: SCAN_WINDOW_SECS,
});
log!(DetectionLog::ScanDetected(key.clone(), unique_ports, SCAN_WINDOW_SECS,));
let event = DetectionEvent {
source: DetectionSource::Correlation,
@ -87,6 +83,9 @@ impl ScanDetector {
protocol: alert.protocol,
packet_count: 0,
flow_duration_us: 0,
ae_score: 0.0,
anomaly_score: 0.0,
c2_score: 0.0,
};
let _ = detection_tx.try_send(event);
@ -137,6 +136,8 @@ mod tests {
attack_type: Some("Reconnaissance".to_string()),
confidence: 0.7,
ae_score: 0.3,
anomaly_score: 0.0,
c2_score: 0.0,
packet_count: 5,
flow_duration_us: 100_000,
}

View File

@ -2,7 +2,9 @@ use std::time::{Duration, Instant};
use dashmap::DashMap;
use macros::log;
use tokio::sync::broadcast::error::RecvError;
use tokio::sync::{broadcast, mpsc};
use tokio::time::interval;
use crate::model::detection::ml_detection::AlertMessage;
use crate::model::event::{DetectionEvent, DetectionSource};
@ -61,15 +63,15 @@ impl BeaconingDetector {
async fn run(mut self) {
log!(DetectionLog::BeaconingDetectorStarted);
let mut analysis_interval = tokio::time::interval(Duration::from_secs(ANALYSIS_INTERVAL_SECS));
let mut analysis_interval = interval(Duration::from_secs(ANALYSIS_INTERVAL_SECS));
loop {
tokio::select! {
result = self.alert_rx.recv() => {
match result {
Ok(alert) => self.record_flow(&alert),
Err(broadcast::error::RecvError::Lagged(_)) => continue,
Err(broadcast::error::RecvError::Closed) => break,
Err(RecvError::Lagged(_)) => continue,
Err(RecvError::Closed) => break,
}
}
_ = analysis_interval.tick() => {
@ -124,13 +126,13 @@ impl BeaconingDetector {
// Phase 2: selective write-lock only for entries that need last_alerted update.
for (key, cv, count) in alerts {
let (src_ip, dst_ip, dst_port) = &key;
log!(DetectionLog::BeaconingDetected {
src_ip: src_ip.clone(),
dst_ip: dst_ip.clone(),
dst_port: *dst_port,
log!(DetectionLog::BeaconingDetected(
src_ip.clone(),
dst_ip.clone(),
*dst_port,
cv,
count,
});
));
let event = DetectionEvent {
source: DetectionSource::Beaconing,
@ -141,6 +143,9 @@ impl BeaconingDetector {
protocol: 6,
packet_count: count as u64,
flow_duration_us: 0,
ae_score: 0.0,
anomaly_score: 0.0,
c2_score: 0.0,
};
let _ = self.detection_tx.try_send(event);

View File

@ -0,0 +1,135 @@
//! Fusion policy math primitive — cross-source confidence aggregation.
//!
//! The policy assumes detection sources are conditionally independent given
//! a true attack. In practice ML and CV can share signal on C2 beaconing,
//! so a future calibration pass may introduce per-pair weights; this
//! module stays the canonical home for whichever formula is in force.
use crate::model::event::DetectionSource;
/// Compute `1 ∏(1 c_i)` over the given per-source confidences.
///
/// - Empty input → `0.0` (no evidence).
/// - Single input → returns that confidence unchanged.
/// - Values are clamped to `[0.0, 1.0]` to keep the result bounded even if
/// an upstream source ships noisy unnormalized scores.
pub fn fused_confidence(per_source: &[f32]) -> f32 {
if per_source.is_empty() {
return 0.0;
}
let mut inverse: f64 = 1.0;
for &c in per_source {
let clamped = (c as f64).clamp(0.0, 1.0);
inverse *= 1.0 - clamped;
}
(1.0 - inverse).clamp(0.0, 1.0) as f32
}
/// Default per-source fusion-window length in seconds. Each value scales
/// the orchestrator's lookahead budget when that source opens a dedup key.
/// Slower sources (Suricata signatures) get longer windows so a follow-up
/// ML hit still lands inside; faster sources (ML ticks) use short windows
/// because they'd otherwise waste latency waiting on downstream signals.
#[derive(Debug, Clone, Copy)]
pub struct FusionWindowLengths {
pub suricata_secs: u64,
pub cv_secs: u64,
pub ml_secs: u64,
pub graph_secs: u64,
}
/// Valid range, in seconds, for a fusion window. Clamps protect against a
/// misconfigured source opening a wedged (too-long) or useless (zero) key.
pub const FUSION_WINDOW_MIN_SECS: u64 = 1;
pub const FUSION_WINDOW_MAX_SECS: u64 = 30;
impl Default for FusionWindowLengths {
fn default() -> Self {
Self {
suricata_secs: 10,
cv_secs: 8,
ml_secs: 2,
graph_secs: 5,
}
}
}
impl FusionWindowLengths {
/// Range-clamped lookup for the window length of the first source to
/// open a fusion key.
pub fn for_source(&self, source: DetectionSource) -> u64 {
let raw = match source {
DetectionSource::Suricata => self.suricata_secs,
DetectionSource::Beaconing => self.cv_secs,
DetectionSource::ML => self.ml_secs,
DetectionSource::Correlation => self.graph_secs,
};
raw.clamp(FUSION_WINDOW_MIN_SECS, FUSION_WINDOW_MAX_SECS)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_fusion_returns_zero() {
assert_eq!(fused_confidence(&[]), 0.0);
}
#[test]
fn single_source_passes_through() {
assert!((fused_confidence(&[0.8]) - 0.8).abs() < 1e-5);
assert_eq!(fused_confidence(&[0.0]), 0.0);
assert_eq!(fused_confidence(&[1.0]), 1.0);
}
#[test]
fn two_sources_boost() {
// 1 (1 0.7)(1 0.9) = 1 0.03 = 0.97
let got = fused_confidence(&[0.7, 0.9]);
assert!((got - 0.97).abs() < 1e-5);
}
#[test]
fn three_sources_monotone_in_count() {
let two = fused_confidence(&[0.6, 0.7]);
let three = fused_confidence(&[0.6, 0.7, 0.5]);
assert!(three >= two);
}
#[test]
fn four_sources_bounded() {
let four = fused_confidence(&[0.9, 0.8, 0.7, 0.6]);
assert!(four < 1.0);
assert!(four > 0.99);
}
#[test]
fn out_of_range_confidences_are_clamped() {
// Negative / above-1 inputs don't break the math.
assert_eq!(fused_confidence(&[-0.5, -0.1]), 0.0);
assert_eq!(fused_confidence(&[2.0, 3.0]), 1.0);
}
#[test]
fn window_lengths_defaults() {
let w = FusionWindowLengths::default();
assert_eq!(w.for_source(DetectionSource::Suricata), 10);
assert_eq!(w.for_source(DetectionSource::Beaconing), 8);
assert_eq!(w.for_source(DetectionSource::ML), 2);
assert_eq!(w.for_source(DetectionSource::Correlation), 5);
}
#[test]
fn window_lengths_clamped_to_range() {
let w = FusionWindowLengths {
suricata_secs: 999,
cv_secs: 0,
ml_secs: 2,
graph_secs: 5,
};
assert_eq!(w.for_source(DetectionSource::Suricata), 30);
assert_eq!(w.for_source(DetectionSource::Beaconing), 1);
}
}

View File

@ -0,0 +1,223 @@
//! Lock-free fusion observability counters. The orchestrator bumps these
//! on ingress / emit / eviction; HTTP handlers (and eventually the admin
//! dashboard) read atomic snapshots without touching orchestrator state.
use std::sync::atomic::{AtomicU64, Ordering};
use serde::Serialize;
use crate::model::event::DetectionSource;
/// Relaxed ordering is enough for counters: readers tolerate arbitrary
/// interleaving, and no counter's value gates access to another memory
/// location. Anything stronger would just waste fence instructions on the
/// hot packet path without adding any real invariant.
const ORDER: Ordering = Ordering::Relaxed;
/// Atomic counters maintained by the detection orchestrator. Shared via
/// `Arc` with the HTTP metrics handler so dashboards can read without
/// blocking the orchestrator task.
#[derive(Debug, Default)]
pub struct FusionMetrics {
/// Total fused emits (single-source + multi-source combined).
total_emits: AtomicU64,
/// Fused emits whose final `active_source_count` was ≥ 2 (i.e. fusion
/// actually fired across multiple sources rather than single-source solo).
multi_source_emits: AtomicU64,
/// Fusion windows evicted under LRU pressure before they could emit.
/// Kept separate from shutdown-drain drops — those are legitimate.
windows_evicted: AtomicU64,
/// Per-source event count. Increments once per ingress detection
/// regardless of whether the event fires a fused emit downstream.
ml_fires: AtomicU64,
suricata_fires: AtomicU64,
beaconing_fires: AtomicU64,
correlation_fires: AtomicU64,
}
impl FusionMetrics {
pub fn new() -> Self {
Self::default()
}
/// Record an ingress detection from the given source. Called before
/// fusion-window bookkeeping so per-source counters reflect raw
/// volume, not what survives dedup.
pub fn record_fire(&self, source: DetectionSource) {
let counter = match source {
DetectionSource::ML => &self.ml_fires,
DetectionSource::Suricata => &self.suricata_fires,
DetectionSource::Beaconing => &self.beaconing_fires,
DetectionSource::Correlation => &self.correlation_fires,
};
counter.fetch_add(1, ORDER);
}
/// Record a fused emit. `source_count` is the number of distinct
/// sources that contributed to this emit — 1 for single-source,
/// 2..=4 when fusion actually agreed.
pub fn record_emit(&self, source_count: usize) {
self.total_emits.fetch_add(1, ORDER);
if source_count >= 2 {
self.multi_source_emits.fetch_add(1, ORDER);
}
}
/// Record a fusion-window eviction that happened before the window
/// could emit. Shutdown-drain drops are not counted here.
pub fn record_eviction(&self) {
self.windows_evicted.fetch_add(1, ORDER);
}
/// Take an atomic snapshot of every counter and derive the three
/// rate figures the dashboard surfaces.
pub fn snapshot(&self) -> FusionMetricsSnapshot {
let total_emits = self.total_emits.load(ORDER);
let multi_source_emits = self.multi_source_emits.load(ORDER);
let windows_evicted = self.windows_evicted.load(ORDER);
let ml = self.ml_fires.load(ORDER);
let suricata = self.suricata_fires.load(ORDER);
let beaconing = self.beaconing_fires.load(ORDER);
let correlation = self.correlation_fires.load(ORDER);
let agreed_rate = ratio(multi_source_emits, total_emits);
let drop_denominator = total_emits + windows_evicted;
let window_drop_rate = ratio(windows_evicted, drop_denominator);
let total_fires = ml + suricata + beaconing + correlation;
let per_source_fire_rate = PerSourceRate {
ml: ratio(ml, total_fires),
suricata: ratio(suricata, total_fires),
beaconing: ratio(beaconing, total_fires),
correlation: ratio(correlation, total_fires),
};
FusionMetricsSnapshot {
total_emits,
multi_source_emits,
agreed_rate,
windows_evicted,
window_drop_rate,
per_source_fires: PerSourceCount {
ml,
suricata,
beaconing,
correlation,
},
per_source_fire_rate,
}
}
}
/// Return `numerator / denominator` as `f64`, or `0.0` when the
/// denominator is zero. Saves every rate caller from an `if denom == 0`
/// rewrite of the same guard.
fn ratio(numerator: u64, denominator: u64) -> f64 {
if denominator == 0 {
0.0
} else {
numerator as f64 / denominator as f64
}
}
/// Wire-format snapshot consumed by `GET /api/fusion/metrics`. Derived
/// fields (`agreed_rate`, `window_drop_rate`, `per_source_fire_rate`)
/// are precomputed server-side so the UI doesn't have to re-implement
/// the formulas and drift.
#[derive(Debug, Clone, Serialize)]
pub struct FusionMetricsSnapshot {
pub total_emits: u64,
pub multi_source_emits: u64,
/// `multi_source_emits / total_emits`.
pub agreed_rate: f64,
pub windows_evicted: u64,
/// `windows_evicted / (windows_evicted + total_emits)`.
pub window_drop_rate: f64,
pub per_source_fires: PerSourceCount,
pub per_source_fire_rate: PerSourceRate,
}
#[derive(Debug, Clone, Serialize)]
pub struct PerSourceCount {
pub ml: u64,
pub suricata: u64,
pub beaconing: u64,
pub correlation: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct PerSourceRate {
pub ml: f64,
pub suricata: f64,
pub beaconing: f64,
pub correlation: f64,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn zero_traffic_snapshot_reports_zero_rates() {
let m = FusionMetrics::new();
let s = m.snapshot();
assert_eq!(s.total_emits, 0);
assert_eq!(s.agreed_rate, 0.0);
assert_eq!(s.window_drop_rate, 0.0);
assert_eq!(s.per_source_fire_rate.ml, 0.0);
}
#[test]
fn agreed_rate_reflects_multi_source_ratio() {
let m = FusionMetrics::new();
m.record_emit(1); // single-source
m.record_emit(2); // multi
m.record_emit(3); // multi
m.record_emit(1); // single
let s = m.snapshot();
assert_eq!(s.total_emits, 4);
assert_eq!(s.multi_source_emits, 2);
assert!((s.agreed_rate - 0.5).abs() < 1e-9);
}
#[test]
fn window_drop_rate_isolates_evictions_from_emits() {
let m = FusionMetrics::new();
for _ in 0..9 {
m.record_emit(1);
}
m.record_eviction(); // 1 evicted / 10 total tracked
let s = m.snapshot();
assert_eq!(s.windows_evicted, 1);
assert!((s.window_drop_rate - 0.1).abs() < 1e-9);
}
#[test]
fn per_source_fire_rate_sums_to_one_when_nonzero() {
let m = FusionMetrics::new();
m.record_fire(DetectionSource::ML);
m.record_fire(DetectionSource::ML);
m.record_fire(DetectionSource::Suricata);
m.record_fire(DetectionSource::Beaconing);
let s = m.snapshot();
let sum = s.per_source_fire_rate.ml
+ s.per_source_fire_rate.suricata
+ s.per_source_fire_rate.beaconing
+ s.per_source_fire_rate.correlation;
assert!((sum - 1.0).abs() < 1e-9, "per-source rates must sum to 1, got {sum}");
assert!((s.per_source_fire_rate.ml - 0.5).abs() < 1e-9);
}
#[test]
fn record_fire_routes_to_correct_source_bucket() {
let m = FusionMetrics::new();
m.record_fire(DetectionSource::Suricata);
m.record_fire(DetectionSource::Correlation);
let s = m.snapshot();
assert_eq!(s.per_source_fires.suricata, 1);
assert_eq!(s.per_source_fires.correlation, 1);
assert_eq!(s.per_source_fires.ml, 0);
assert_eq!(s.per_source_fires.beaconing, 0);
}
}

View File

@ -1,2 +1,4 @@
pub mod beaconing;
pub mod fusion_math;
pub mod metrics;
pub mod orchestrator;

View File

@ -2,46 +2,86 @@ use std::num::NonZero;
use std::sync::Arc;
use std::time::{Duration, Instant};
use lru::LruCache;
use macros::log;
use tokio::sync::mpsc;
use tokio::time::interval;
use super::fusion_math::{FusionWindowLengths, fused_confidence};
use super::metrics::FusionMetrics;
use crate::infrastructure::communication_manager::CommunicationManager;
use crate::infrastructure::geoip::GeoIpService;
use crate::model::detection::attack_type::translate;
use crate::model::error::system::SystemError;
use crate::model::event::{DetectionEvent, DetectionSource, ThreatDetectedEvent};
use crate::model::event::{AuditEvent, DetectionEvent, DetectionSource, ThreatDetectedEvent};
use crate::model::log::detection::DetectionLog;
/// Dedup window: detections for the same (source_ip, attack_type) within this window
/// are suppressed after the first emission.
/// Dedup window: detections for the same `(source_ip, canonical_attack_type)`
/// within this window are suppressed after initial fusion-window expiry.
const DEDUP_WINDOW_SECS: u64 = 30;
/// How often to sweep expired dedup entries.
const CLEANUP_INTERVAL_SECS: u64 = 60;
/// Repeat offender detection: same IP within this duration counts as repeat.
const REPEAT_OFFENDER_WINDOW_SECS: u64 = 2 * 60 * 60; // 2 hours
const REPEAT_OFFENDER_WINDOW_SECS: u64 = 2 * 60 * 60;
/// Maximum dedup entries to prevent unbounded memory growth under sustained attack.
const MAX_DEDUP_ENTRIES: usize = 50_000;
/// Declared as `NonZero` at compile time so `LruCache::new` never needs a
/// runtime unwrap — if this ever goes to zero, the const expression fails
/// to compile, not the running server.
// SAFETY: NonZero::new on a non-zero literal is infallible; const-evaluated.
const MAX_DEDUP_ENTRIES: NonZero<usize> = NonZero::new(50_000).unwrap();
struct DedupEntry {
sources: Vec<DetectionSource>,
emitted_at: Instant,
/// Actor recorded on every fusion-chain WORM entry. Stable across releases —
/// downstream audit tooling filters on this string.
const FUSION_AUDIT_ACTOR: &str = "FusionEngine";
/// Action recorded on every fusion-chain WORM entry. Stable across releases.
const FUSION_AUDIT_ACTION: &str = "fused_threat_emitted";
/// Per-source record within an in-flight dedup entry. Keeps the strongest
/// confidence per source so multi-hit from one source doesn't inflate the
/// fused policy. `local_attack_type` is the raw source-specific label seen
/// before canonicalization — preserved for WORM audit evidence so the
/// explain-this-block UI can show Suricata's classtype next to ML's class
/// name that both folded into the same canonical dedup key.
#[derive(Debug, Clone)]
struct SourceSample {
source: DetectionSource,
confidence: f32,
local_attack_type: String,
}
/// Coordinates detections from multiple sources (ML, future: rules, correlation, threat feeds).
/// Deduplicates, enriches with GeoIP/hit count/repeat offender, and emits ThreatDetectedEvent.
struct DedupEntry {
sources: Vec<SourceSample>,
/// When the orchestrator first emitted for this key.
first_emitted_at: Instant,
/// Most recent emit (reset on each fused re-emit within the fusion window).
emitted_at: Instant,
/// Fusion window length for this key, fixed by the first source to arrive.
/// Later arrivals don't reset it so the lookahead budget stays predictable.
fusion_window: Duration,
}
/// Coordinates detections from ML / Suricata / Beaconing / Correlation.
/// Incoming events are canonicalized into a shared attack-type dictionary so
/// dedup keys collide across sources; events sharing a key inside the fusion
/// window accumulate, and additional sources arriving mid-window trigger a
/// re-emit with the combined confidence `1 ∏(1 c_i)`.
pub struct DetectionOrchestrator {
rx: mpsc::Receiver<DetectionEvent>,
comm: Arc<CommunicationManager>,
geoip: Option<Arc<GeoIpService>>,
metrics: Arc<FusionMetrics>,
// Enrichment state
// SAFETY: NonZero::new on a literal is infallible.
src_ip_counts: lru::LruCache<String, u32>,
repeat_tracker: lru::LruCache<String, Instant>,
// Dedup state — LRU-bounded to prevent unbounded growth under sustained attack
// Dedup state — LRU-bounded to prevent unbounded growth under sustained attack.
dedup: lru::LruCache<(String, String), DedupEntry>,
dedup_window: Duration,
/// Per-source fusion window lengths. Future versions may read overrides
/// from DB; the defaults live in `FusionWindowLengths::default`.
fusion_windows: FusionWindowLengths,
}
impl DetectionOrchestrator {
@ -49,20 +89,23 @@ impl DetectionOrchestrator {
rx: mpsc::Receiver<DetectionEvent>,
comm: Arc<CommunicationManager>,
geoip: Option<Arc<GeoIpService>>,
metrics: Arc<FusionMetrics>,
) -> Self {
Self {
rx,
comm,
geoip,
// SAFETY: NonZero::new on a non-zero literal is infallible.
src_ip_counts: lru::LruCache::new(NonZero::new(10_000).unwrap()),
repeat_tracker: lru::LruCache::new(NonZero::new(5_000).unwrap()),
dedup: lru::LruCache::new(NonZero::new(MAX_DEDUP_ENTRIES).unwrap()),
metrics,
// SAFETY: NonZero::new on non-zero literals; MAX_DEDUP_ENTRIES is
// already a NonZero const so no unwrap needed for that one.
src_ip_counts: LruCache::new(NonZero::new(10_000).unwrap()),
repeat_tracker: LruCache::new(NonZero::new(5_000).unwrap()),
dedup: LruCache::new(MAX_DEDUP_ENTRIES),
dedup_window: Duration::from_secs(DEDUP_WINDOW_SECS),
fusion_windows: FusionWindowLengths::default(),
}
}
/// Spawn the orchestrator as a background task.
pub fn start(self) {
tokio::spawn(async move { self.run().await });
}
@ -70,14 +113,14 @@ impl DetectionOrchestrator {
async fn run(mut self) {
log!(DetectionLog::OrchestratorStarted);
let mut cleanup_interval = tokio::time::interval(Duration::from_secs(CLEANUP_INTERVAL_SECS));
let mut cleanup_interval = interval(Duration::from_secs(CLEANUP_INTERVAL_SECS));
loop {
tokio::select! {
event = self.rx.recv() => {
match event {
Some(detection) => self.handle_detection(detection).await,
None => break, // All senders dropped
None => break,
}
}
_ = cleanup_interval.tick() => {
@ -87,64 +130,170 @@ impl DetectionOrchestrator {
}
}
async fn handle_detection(&mut self, event: DetectionEvent) {
async fn handle_detection(&mut self, mut event: DetectionEvent) {
// Count every ingress event per-source before dedup — this is the
// raw firing rate, independent of whether the event survives to emit.
self.metrics.record_fire(event.source);
// Canonicalize the raw attack_type so Suricata's "brute-force"
// classtype and ML's "Brute Force" class name land on the same dedup
// key — the precondition for cross-source fusion. Keep the original
// label for audit evidence.
let raw_label = event.attack_type.clone();
let canonical = translate(event.source, &event.attack_type);
event.attack_type = canonical.as_str().to_string();
let key = (event.source_ip.clone(), event.attack_type.clone());
let now = Instant::now();
// Dedup check
if let Some(entry) = self.dedup.get(&key)
&& now.checked_duration_since(entry.emitted_at).unwrap_or(Duration::ZERO) < self.dedup_window
{
// Within window: add source attribution but don't re-emit
if !entry.sources.contains(&event.source) {
// Re-get as mutable to update sources
if let Some(entry) = self.dedup.get_mut(&key) {
entry.sources.push(event.source.clone());
// Path A: existing dedup entry. Decide re-emit (fusion window still
// open) vs silence (window closed but dedup still active).
if let Some(entry) = self.dedup.get_mut(&key) {
let since_first = now.saturating_duration_since(entry.first_emitted_at);
// Post-dedup-window — treat as a brand-new event (fall through).
if since_first >= self.dedup_window {
// Expired dedup; fall through to Path B by dropping the entry.
self.dedup.pop(&key);
} else if since_first < entry.fusion_window {
// Still inside the fusion window — accumulate.
let is_new_source = !entry.sources.iter().any(|s| s.source == event.source);
if is_new_source {
entry.sources.push(SourceSample {
source: event.source,
confidence: event.confidence,
local_attack_type: raw_label.clone(),
});
entry.emitted_at = now;
} else {
// Same source firing again inside the window — keep the
// strongest confidence (and its raw label) for fusion math.
if let Some(existing) = entry.sources.iter_mut().find(|s| s.source == event.source)
&& existing.confidence < event.confidence
{
existing.confidence = event.confidence;
existing.local_attack_type = raw_label.clone();
}
}
// Only RE-EMIT when a new source joined — same-source
// refires are silenced to avoid SOAR cooldown churn.
if is_new_source {
self.emit_fused(&event, &key).await;
} else {
log!(DetectionLog::DetectionDeduplicated(
event.source_ip.clone(),
event.attack_type.clone(),
));
}
return;
} else {
// Past fusion window, still inside dedup silence → drop.
log!(DetectionLog::DetectionDeduplicated(
event.source_ip.clone(),
event.attack_type.clone(),
));
return;
}
log!(DetectionLog::DetectionDeduplicated {
source_ip: event.source_ip,
attack_type: event.attack_type,
});
return;
}
// Enrich and emit
let threat_event = self.enrich(&event).await;
let sources = vec![event.source.clone()];
// Path B: brand-new key (or expired dedup). Emit single-source,
// open a fusion window sized by this source.
let fusion_window = Duration::from_secs(self.fusion_windows.for_source(event.source));
log!(DetectionLog::DetectionEmitted {
source_ip: event.source_ip.clone(),
attack_type: event.attack_type.clone(),
confidence: event.confidence,
sources_count: sources.len(),
});
// Detect LRU-pressure eviction: if the dedup map is already at capacity
// and this key wasn't present, inserting will evict the least-recently-
// used entry silently. That's a real lost-signal event; count it and
// warn so the operator sees sustained-attack saturation.
let cap = self.dedup.cap().get();
let was_full = self.dedup.len() >= cap;
let key_was_absent = self.dedup.peek(&key).is_none();
if was_full && key_was_absent {
self.metrics.record_eviction();
log!(DetectionLog::FusionWindowEvicted(key.0.clone(), key.1.clone()));
}
// Record dedup entry (LRU-bounded)
self.dedup.put(
key,
key.clone(),
DedupEntry {
sources,
sources: vec![SourceSample {
source: event.source,
confidence: event.confidence,
local_attack_type: raw_label,
}],
first_emitted_at: now,
emitted_at: now,
fusion_window,
},
);
self.emit_fused(&event, &key).await;
}
/// Build the fused ThreatDetectedEvent from the current dedup entry's
/// per-source samples, apply enrichment (hit count / repeat / geoip),
/// and publish. Called both on first emit (single source) and on
/// within-window re-emit (2..=4 sources). Also emits a WORM AuditEvent
/// carrying the full per-source evidence chain.
async fn emit_fused(&mut self, trigger_event: &DetectionEvent, key: &(String, String)) {
let per_source_samples: Vec<SourceSample> = match self.dedup.get(key) {
Some(entry) => entry.sources.clone(),
None => return,
};
let confs: Vec<f32> = per_source_samples.iter().map(|s| s.confidence).collect();
let sources_vec: Vec<DetectionSource> = per_source_samples.iter().map(|s| s.source).collect();
let fused = fused_confidence(&confs);
let mut threat_event = self.enrich(trigger_event).await;
threat_event.sources = sources_vec;
threat_event.active_source_count = threat_event.sources.len();
threat_event.fused_confidence = fused;
threat_event.confidence = fused;
log!(DetectionLog::DetectionEmitted(
trigger_event.source_ip.clone(),
trigger_event.attack_type.clone(),
threat_event.confidence,
trigger_event.ae_score,
trigger_event.anomaly_score,
trigger_event.c2_score,
threat_event.active_source_count,
));
self.metrics.record_emit(threat_event.active_source_count);
self.publish_fusion_audit(trigger_event, fused, &per_source_samples)
.await;
if let Err(e) = self.comm.publish_event(threat_event).await {
log!(SystemError::MlSoarBridgeFailed(e));
}
}
/// Emit a WORM AuditEvent so the eventual "why was this IP blocked?"
/// explain view can reconstruct the fusion evidence chain — which
/// sources fired, at what confidence, and what raw label each used
/// before the canonical dictionary folded them onto a shared key.
async fn publish_fusion_audit(&self, trigger_event: &DetectionEvent, fused: f32, per_source: &[SourceSample]) {
let audit = AuditEvent {
actor: FUSION_AUDIT_ACTOR.to_string(),
action: FUSION_AUDIT_ACTION.to_string(),
detail: build_fusion_audit_detail(&trigger_event.source_ip, &trigger_event.attack_type, fused, per_source),
};
if let Err(e) = self.comm.publish_event(audit).await {
log!(DetectionLog::FusionAuditPublishFailed(e.to_string()));
}
}
async fn enrich(&mut self, event: &DetectionEvent) -> ThreatDetectedEvent {
let src_ip = &event.source_ip;
// Compute packet rate
let packet_rate = if event.flow_duration_us > 0 {
event.packet_count as f64 / (event.flow_duration_us as f64 / 1_000_000.0)
} else {
0.0
};
// Update hit count (LRU bounded)
let hit_count = match self.src_ip_counts.get_mut(src_ip) {
Some(c) => {
*c = c.saturating_add(1);
@ -156,7 +305,6 @@ impl DetectionOrchestrator {
}
};
// Check repeat offender (same IP within window)
let repeat_window = Duration::from_secs(REPEAT_OFFENDER_WINDOW_SECS);
let now = Instant::now();
let is_repeat = self
@ -165,7 +313,6 @@ impl DetectionOrchestrator {
.is_some_and(|last| now.checked_duration_since(*last).unwrap_or(Duration::ZERO) < repeat_window);
self.repeat_tracker.put(src_ip.clone(), now);
// GeoIP lookup
let geoip_country = if let Some(ref svc) = self.geoip {
if let Ok(ip) = src_ip.parse() {
svc.lookup(ip).await.ok().flatten().and_then(|loc| loc.country_code)
@ -186,20 +333,130 @@ impl DetectionOrchestrator {
protocol: event.protocol,
geoip_country,
is_repeat_offender: is_repeat,
sources: vec![event.source.clone()],
// These three get overwritten in `emit_fused` with the
// accumulated values; initialize to the single-source defaults
// so a direct caller also gets a consistent shape.
sources: vec![event.source],
active_source_count: 1,
fused_confidence: event.confidence,
ae_score: event.ae_score,
anomaly_score: event.anomaly_score,
c2_score: event.c2_score,
}
}
fn cleanup_expired(&mut self) {
// Full scan: LRU order reflects access time, not insertion time, so
// peek_lru + break-on-first-unexpired would skip older idle entries
// sitting in the middle of the map. Dedup touches an entry's LRU
// position via `get_mut` on every re-emit, which can leave an entry
// with an older `first_emitted_at` deeper in the cache than a newly
// inserted neighbour. A full retain is O(N) but cleanup runs every
// 60s and `MAX_DEDUP_ENTRIES` caps N at 50_000 — one walk is cheap.
let now = Instant::now();
let window = self.dedup_window;
// Pop expired entries from the LRU (oldest entries are least recently used)
while let Some((_, entry)) = self.dedup.peek_lru() {
if now.checked_duration_since(entry.emitted_at).unwrap_or(Duration::ZERO) >= window {
self.dedup.pop_lru();
} else {
break;
let mut expired: Vec<(String, String)> = Vec::new();
for (key, entry) in self.dedup.iter() {
if now
.checked_duration_since(entry.first_emitted_at)
.unwrap_or(Duration::ZERO)
>= window
{
expired.push(key.clone());
}
}
for key in expired {
self.dedup.pop(&key);
}
}
}
/// Serialize the WORM audit evidence payload for a fused threat emission.
/// Extracted as a free function so tests can cover schema shape without a
/// live CommunicationManager harness.
fn build_fusion_audit_detail(src_ip: &str, attack_type: &str, fused: f32, per_source: &[SourceSample]) -> String {
let per_source_json: Vec<serde_json::Value> = per_source
.iter()
.map(|s| {
serde_json::json!({
"source": s.source.to_string(),
"confidence": s.confidence,
"local_attack_type": s.local_attack_type,
})
})
.collect();
serde_json::json!({
"src_ip": src_ip,
"attack_type": attack_type,
"fused_confidence": fused,
"per_source": per_source_json,
})
.to_string()
}
#[cfg(test)]
mod tests {
//! Orchestrator integration tests require an in-memory CommunicationManager
//! harness. Until then, the fusion math lives in `fusion_math::tests`,
//! canonical translation in `model::detection::attack_type::tests`, and
//! the audit evidence schema is covered below.
use super::*;
fn sample(source: DetectionSource, confidence: f32, local: &str) -> SourceSample {
SourceSample {
source,
confidence,
local_attack_type: local.to_string(),
}
}
#[test]
fn audit_detail_is_valid_json_with_required_top_level_keys() {
let detail = build_fusion_audit_detail(
"1.2.3.4",
"brute_force",
0.97,
&[sample(DetectionSource::Suricata, 0.8, "brute-force")],
);
let v: serde_json::Value = serde_json::from_str(&detail).expect("audit detail must be valid JSON");
assert_eq!(v["src_ip"], "1.2.3.4");
assert_eq!(v["attack_type"], "brute_force");
assert!((v["fused_confidence"].as_f64().unwrap() - 0.97).abs() < 1e-5);
assert!(v["per_source"].is_array());
}
#[test]
fn audit_detail_empty_per_source_array_is_well_formed() {
// Defensive: should never happen in production (emit_fused requires a
// dedup entry), but the helper must not panic on an empty slice.
let detail = build_fusion_audit_detail("10.0.0.1", "unknown", 0.0, &[]);
let v: serde_json::Value = serde_json::from_str(&detail).unwrap();
assert_eq!(v["per_source"].as_array().unwrap().len(), 0);
}
#[test]
fn audit_detail_preserves_per_source_evidence_fields() {
let per_source = [
sample(DetectionSource::Suricata, 0.8, "brute-force"),
sample(DetectionSource::ML, 0.85, "Brute Force"),
];
let detail = build_fusion_audit_detail("1.2.3.4", "brute_force", 0.97, &per_source);
let v: serde_json::Value = serde_json::from_str(&detail).unwrap();
let arr = v["per_source"].as_array().unwrap();
assert_eq!(arr.len(), 2);
assert_eq!(arr[0]["source"], "Suricata");
assert_eq!(arr[0]["local_attack_type"], "brute-force");
assert!((arr[0]["confidence"].as_f64().unwrap() - 0.8).abs() < 1e-5);
assert_eq!(arr[1]["source"], "ML");
assert_eq!(arr[1]["local_attack_type"], "Brute Force");
}
#[test]
fn audit_constants_are_stable_wire_strings() {
// Downstream audit tooling filters on these exact strings — renaming
// is a breaking change to the WORM chain.
assert_eq!(FUSION_AUDIT_ACTOR, "FusionEngine");
assert_eq!(FUSION_AUDIT_ACTION, "fused_threat_emitted");
}
}

View File

@ -1,19 +1,19 @@
use std::sync::Arc;
use crate::core::ebpf::dns_filter::DnsFilter;
use crate::interface::port::repository::RepositoryPort;
use crate::interface::port::app_repo::AppRepo;
use crate::interface::port::dns_filter_api::DnsFilterPort;
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>,
db: Arc<dyn AppRepo>,
dns_filter: Arc<dyn DnsFilterPort>,
}
impl DnsFilterService {
pub fn new(db: Arc<dyn RepositoryPort>, dns_filter: Arc<DnsFilter>) -> Self {
pub fn new(db: Arc<dyn AppRepo>, dns_filter: Arc<dyn DnsFilterPort>) -> Self {
Self { db, dns_filter }
}
@ -30,10 +30,10 @@ impl DnsFilterService {
.and_then(|v| v.parse().ok())
.unwrap_or(1000);
if domains.len() > max_domains {
return Err(MiscError::ValidationError {
message: format!("too many domains (max {})", max_domains),
}
.into());
Err(MiscError::ValidationError(format!(
"too many domains (max {})",
max_domains
)))?;
}
// eBPF first
for domain in domains {

View File

@ -1,290 +0,0 @@
use std::collections::HashMap;
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddrV4, SocketAddrV6};
use aya::maps::{Array as AyaArray, HashMap as AyaHashMap, MapData};
use aya::{Ebpf, Pod};
use common::model::http_method::{HttpMethod, HttpMethodBitmap};
use common::model::ip_address::{AddrPortV4, AddrPortV6, IPv4, IPv6};
use common::model::placeholder::PlaceHolder;
use tokio::sync::RwLock;
use crate::model::error::Error;
use crate::model::error::ebpf::EbpfError;
use crate::model::ip_address::NativeConvert;
pub struct ProtocolFilter {
ipv4_http_service: RwLock<HttpServiceWrapper<AddrPortV4>>,
ipv6_http_service: RwLock<HttpServiceWrapper<AddrPortV6>>,
ssh_white_list_enable: RwLock<WhiteListControl>,
ipv4_ssh_service: RwLock<EntryMap<AddrPortV4>>,
ipv6_ssh_service: RwLock<EntryMap<AddrPortV6>>,
ipv4_ssh_white_list: RwLock<EntryMap<IPv4>>,
ipv6_ssh_white_list: RwLock<EntryMap<IPv6>>,
ipv4_ssh_black_list: RwLock<EntryMap<IPv4>>,
ipv6_ssh_black_list: RwLock<EntryMap<IPv6>>,
}
impl ProtocolFilter {
pub fn new(ebpf: &mut Ebpf) -> Result<Self, Error> {
let service = Self {
ipv4_http_service: RwLock::new(HttpServiceWrapper::new(ebpf, "IPV4_HTTP_SERVICE")?),
ipv6_http_service: RwLock::new(HttpServiceWrapper::new(ebpf, "IPV6_HTTP_SERVICE")?),
ssh_white_list_enable: RwLock::new(WhiteListControl::new(ebpf, "SSH_WHITE_LIST_ENABLE")?),
ipv4_ssh_service: RwLock::new(EntryMap::new(ebpf, "IPV4_SSH_SERVICE")?),
ipv6_ssh_service: RwLock::new(EntryMap::new(ebpf, "IPV6_SSH_SERVICE")?),
ipv4_ssh_white_list: RwLock::new(EntryMap::new(ebpf, "IPV4_SSH_WHITE_LIST")?),
ipv6_ssh_white_list: RwLock::new(EntryMap::new(ebpf, "IPV6_SSH_WHITE_LIST")?),
ipv4_ssh_black_list: RwLock::new(EntryMap::new(ebpf, "IPV4_SSH_BLACK_LIST")?),
ipv6_ssh_black_list: RwLock::new(EntryMap::new(ebpf, "IPV6_SSH_BLACK_LIST")?),
};
Ok(service)
}
pub async fn get_ipv4_http_service(&self) -> HashMap<SocketAddrV4, Vec<HttpMethod>> {
self.ipv4_http_service.read().await.get_http_method()
}
pub async fn get_ipv6_http_service(&self) -> HashMap<SocketAddrV6, Vec<HttpMethod>> {
self.ipv6_http_service.read().await.get_http_method()
}
pub async fn add_ipv4_http_service(
&self,
address: SocketAddrV4,
http_method: Vec<HttpMethod>,
) -> Result<(), Error> {
self.ipv4_http_service
.write()
.await
.add_http_service(address, http_method)
}
pub async fn add_ipv6_http_service(
&self,
address: SocketAddrV6,
http_method: Vec<HttpMethod>,
) -> Result<(), Error> {
self.ipv6_http_service
.write()
.await
.add_http_service(address, http_method)
}
pub async fn remove_ipv4_http_service(
&self,
address: SocketAddrV4,
removed_http_method: Vec<HttpMethod>,
) -> Result<(), Error> {
self.ipv4_http_service
.write()
.await
.remove_http_service(address, removed_http_method)
}
pub async fn remove_ipv6_http_service(
&self,
address: SocketAddrV6,
removed_http_method: Vec<HttpMethod>,
) -> Result<(), Error> {
self.ipv6_http_service
.write()
.await
.remove_http_service(address, removed_http_method)
}
pub async fn is_ssh_white_list_enable(&self) -> bool {
self.ssh_white_list_enable.read().await.is_white_list_enable()
}
pub async fn enable_ssh_white_list(&self) -> Result<(), Error> {
self.ssh_white_list_enable.write().await.enable_white_list()
}
pub async fn disable_ssh_white_list(&self) -> Result<(), Error> {
self.ssh_white_list_enable.write().await.disable_white_list()
}
pub async fn get_ipv4_ssh_service(&self) -> Vec<SocketAddrV4> {
self.ipv4_ssh_service.read().await.get_all()
}
pub async fn get_ipv6_ssh_service(&self) -> Vec<SocketAddrV6> {
self.ipv6_ssh_service.read().await.get_all()
}
pub async fn add_ipv4_ssh_service(&self, address: SocketAddrV4) -> Result<(), Error> {
self.ipv4_ssh_service.write().await.add(address)
}
pub async fn add_ipv6_ssh_service(&self, address: SocketAddrV6) -> Result<(), Error> {
self.ipv6_ssh_service.write().await.add(address)
}
pub async fn remove_ipv4_ssh_service(&self, address: SocketAddrV4) -> Result<(), Error> {
self.ipv4_ssh_service.write().await.remove(address)
}
pub async fn remove_ipv6_ssh_service(&self, address: SocketAddrV6) -> Result<(), Error> {
self.ipv6_ssh_service.write().await.remove(address)
}
pub async fn get_ipv4_ssh_white_list(&self) -> Vec<Ipv4Addr> {
self.ipv4_ssh_white_list.read().await.get_all()
}
pub async fn get_ipv6_ssh_white_list(&self) -> Vec<Ipv6Addr> {
self.ipv6_ssh_white_list.read().await.get_all()
}
pub async fn add_ipv4_ssh_white_list(&self, ip: Ipv4Addr) -> Result<(), Error> {
self.ipv4_ssh_white_list.write().await.add(ip)
}
pub async fn add_ipv6_ssh_white_list(&self, ip: Ipv6Addr) -> Result<(), Error> {
self.ipv6_ssh_white_list.write().await.add(ip)
}
pub async fn remove_ipv4_ssh_white_list(&self, ip: Ipv4Addr) -> Result<(), Error> {
self.ipv4_ssh_white_list.write().await.remove(ip)
}
pub async fn remove_ipv6_ssh_white_list(&self, ip: Ipv6Addr) -> Result<(), Error> {
self.ipv6_ssh_white_list.write().await.remove(ip)
}
pub async fn get_ipv4_ssh_black_list(&self) -> Vec<Ipv4Addr> {
self.ipv4_ssh_black_list.read().await.get_all()
}
pub async fn get_ipv6_ssh_black_list(&self) -> Vec<Ipv6Addr> {
self.ipv6_ssh_black_list.read().await.get_all()
}
pub async fn add_ipv4_ssh_black_list(&self, ip: Ipv4Addr) -> Result<(), Error> {
self.ipv4_ssh_black_list.write().await.add(ip)
}
pub async fn add_ipv6_ssh_black_list(&self, ip: Ipv6Addr) -> Result<(), Error> {
self.ipv6_ssh_black_list.write().await.add(ip)
}
pub async fn remove_ipv4_ssh_black_list(&self, ip: Ipv4Addr) -> Result<(), Error> {
self.ipv4_ssh_black_list.write().await.remove(ip)
}
pub async fn remove_ipv6_ssh_black_list(&self, ip: Ipv6Addr) -> Result<(), Error> {
self.ipv6_ssh_black_list.write().await.remove(ip)
}
}
struct WhiteListControl {
map: AyaArray<MapData, PlaceHolder>,
}
impl WhiteListControl {
fn new(ebpf: &mut Ebpf, map_name: &str) -> Result<Self, Error> {
let map = ebpf.take_map(map_name).ok_or(EbpfError::MapNotFound)?;
let map = AyaArray::try_from(map).map_err(EbpfError::MapOperationError)?;
Ok(Self { map })
}
fn is_white_list_enable(&self) -> bool {
match self.map.get(&0, 0) {
Ok(status) => status != 0,
Err(_) => false,
}
}
fn enable_white_list(&mut self) -> Result<(), Error> {
self.map.set(0, 1_u8, 0).map_err(EbpfError::MapOperationError)?;
Ok(())
}
fn disable_white_list(&mut self) -> Result<(), Error> {
self.map.set(0, 0_u8, 0).map_err(EbpfError::MapOperationError)?;
Ok(())
}
}
struct HttpServiceWrapper<T> {
map: AyaHashMap<MapData, T, HttpMethodBitmap>,
}
impl<T: NativeConvert + Pod> HttpServiceWrapper<T> {
fn new(ebpf: &mut Ebpf, map_name: &str) -> Result<Self, Error> {
let map = ebpf.take_map(map_name).ok_or(EbpfError::MapNotFound)?;
let map = AyaHashMap::try_from(map).map_err(EbpfError::MapOperationError)?;
Ok(Self { map })
}
fn get_http_method(&self) -> HashMap<T::Native, Vec<HttpMethod>> {
self.map
.iter()
.filter_map(Result::ok)
.map(|(key, value)| {
let address = key.into_native();
(address, HttpMethod::convert_from_bitmap(value))
})
.collect()
}
fn add_http_service(&mut self, address: T::Native, http_method: Vec<HttpMethod>) -> Result<(), Error> {
let address = T::from_native(address);
let ebpf_method = HttpMethod::convert_to_bitmap(http_method);
self.map
.insert(address, ebpf_method, 0)
.map_err(EbpfError::MapOperationError)?;
Ok(())
}
fn remove_http_service(&mut self, address: T::Native, removed_http_method: Vec<HttpMethod>) -> Result<(), Error> {
let address = T::from_native(address);
if let Ok(current_http_method) = self.map.get(&address, 0) {
let mut http_method = HttpMethod::convert_from_bitmap(current_http_method);
http_method.retain(|method| !removed_http_method.contains(method));
if http_method.is_empty() {
self.map.remove(&address).map_err(EbpfError::MapOperationError)?;
} else {
let new_http_method = HttpMethod::convert_to_bitmap(http_method);
self.map
.insert(address, new_http_method, 0)
.map_err(EbpfError::MapOperationError)?;
}
Ok(())
} else {
Err(EbpfError::IpDoesNotExist)?
}
}
}
struct EntryMap<T> {
map: AyaHashMap<MapData, T, PlaceHolder>,
}
impl<T: NativeConvert + Pod> EntryMap<T> {
fn new(ebpf: &mut Ebpf, map_name: &str) -> Result<Self, Error> {
let map = ebpf.take_map(map_name).ok_or(EbpfError::MapNotFound)?;
let map = AyaHashMap::try_from(map).map_err(EbpfError::MapOperationError)?;
Ok(Self { map })
}
fn get_all(&self) -> Vec<T::Native> {
self.map
.keys()
.filter_map(Result::ok)
.map(|key| key.into_native())
.collect()
}
fn add(&mut self, key: T::Native) -> Result<(), Error> {
let key = T::from_native(key);
self.map.insert(key, 0_u8, 0).map_err(EbpfError::MapOperationError)?;
Ok(())
}
fn remove(&mut self, key: T::Native) -> Result<(), Error> {
let key = T::from_native(key);
self.map.remove(&key).map_err(EbpfError::MapOperationError)?;
Ok(())
}
}

View File

@ -1,95 +0,0 @@
use aya::Ebpf;
use aya::maps::{Array, MapData};
use parking_lot::Mutex;
use crate::model::error::Error;
use crate::model::error::ebpf::EbpfError;
pub struct RateLimitConfig {
config_map: Mutex<Array<MapData, u64>>,
}
impl RateLimitConfig {
pub fn new(ebpf: &mut Ebpf) -> Result<Self, Error> {
let map = ebpf.take_map("RATE_LIMIT_CONFIG").ok_or(EbpfError::MapNotFound)?;
let config_map = Array::try_from(map).map_err(EbpfError::MapOperationError)?;
Ok(Self {
config_map: Mutex::new(config_map),
})
}
pub fn set_packet_rate(&self, rate: u64) -> Result<(), Error> {
self.config_map
.lock()
.set(0, rate, 0)
.map_err(EbpfError::MapOperationError)?;
Ok(())
}
pub fn set_syn_rate(&self, rate: u64) -> Result<(), Error> {
self.config_map
.lock()
.set(1, rate, 0)
.map_err(EbpfError::MapOperationError)?;
Ok(())
}
pub fn set_udp_rate(&self, rate: u64) -> Result<(), Error> {
self.config_map
.lock()
.set(2, rate, 0)
.map_err(EbpfError::MapOperationError)?;
Ok(())
}
pub fn set_dns_rate(&self, rate: u64) -> Result<(), Error> {
self.config_map
.lock()
.set(3, rate, 0)
.map_err(EbpfError::MapOperationError)?;
Ok(())
}
pub fn set_window_ns(&self, ns: u64) -> Result<(), Error> {
self.config_map
.lock()
.set(4, ns, 0)
.map_err(EbpfError::MapOperationError)?;
Ok(())
}
pub fn get_packet_rate(&self) -> Result<u64, Error> {
self.config_map
.lock()
.get(&0, 0)
.map_err(|e| EbpfError::MapOperationError(e).into())
}
pub fn get_syn_rate(&self) -> Result<u64, Error> {
self.config_map
.lock()
.get(&1, 0)
.map_err(|e| EbpfError::MapOperationError(e).into())
}
pub fn get_udp_rate(&self) -> Result<u64, Error> {
self.config_map
.lock()
.get(&2, 0)
.map_err(|e| EbpfError::MapOperationError(e).into())
}
pub fn get_dns_rate(&self) -> Result<u64, Error> {
self.config_map
.lock()
.get(&3, 0)
.map_err(|e| EbpfError::MapOperationError(e).into())
}
pub fn get_window_ns(&self) -> Result<u64, Error> {
self.config_map
.lock()
.get(&4, 0)
.map_err(|e| EbpfError::MapOperationError(e).into())
}
}

View File

@ -1,4 +1,6 @@
use crate::interface::port::repository::RepositoryPort;
use chrono::Local;
use crate::interface::port::setting::SettingRepo;
use crate::model::error::Error;
/// Generate an HTML weekly report email body.
@ -12,7 +14,7 @@ use crate::model::error::Error;
/// - `weekly_system_health` (JSON object with cpu, memory, disk fields)
///
/// If a key is missing the report uses empty/zero defaults.
pub fn generate_weekly_report(db: &dyn RepositoryPort) -> Result<String, Error> {
pub fn generate_weekly_report(db: &dyn SettingRepo) -> Result<String, Error> {
let threats_count = db
.get_setting("weekly_threats_count")?
.unwrap_or_else(|| "0".to_string());
@ -77,7 +79,7 @@ pub fn generate_weekly_report(db: &dyn RepositoryPort) -> Result<String, Error>
let mem = health["memory_percent"].as_f64().unwrap_or(0.0);
let disk = health["disk_percent"].as_f64().unwrap_or(0.0);
let now = chrono::Local::now().format("%Y-%m-%d %H:%M");
let now = Local::now().format("%Y-%m-%d %H:%M");
let html = format!(
r#"<!DOCTYPE html>

View File

@ -1,13 +1,19 @@
use crate::interface::port::repository::RepositoryPort;
use crate::interface::port::secret_store::SecretStorePort;
use crate::model::error::Error;
use crate::model::error::notification::NotificationError;
use std::sync::Arc;
use chrono::{Local, Weekday};
use lettre::message::header::ContentType;
use lettre::transport::smtp::authentication::Credentials;
use lettre::{Message, SmtpTransport, Transport};
use std::sync::Arc;
use macros::log;
use tokio::task::{JoinHandle, spawn_blocking};
use tokio::time::{self, Duration};
use tracing::{error, info, warn};
use super::report;
use crate::interface::port::secret_store::SecretStorePort;
use crate::interface::port::setting::SettingRepo;
use crate::model::error::Error;
use crate::model::error::notification::NotificationError;
use crate::model::log::system::SystemLog;
/// SMTP client wrapper that builds a `lettre::SmtpTransport` from Database
/// settings and sends an email.
@ -25,12 +31,8 @@ impl SmtpClient {
///
/// Returns `None` if any required setting (`smtp_host`, `smtp_port`,
/// `smtp_username`, `smtp_password`) is missing.
/// If a `SecretStorePort` is provided, reads the password from the secret store
/// (falling back to the settings table for backward compat before migration).
pub fn from_database(
db: &dyn RepositoryPort,
secrets: Option<&dyn SecretStorePort>,
) -> Result<Option<Self>, Error> {
/// If a `SecretStorePort` is provided, reads the password from the secret store.
pub fn from_database(db: &dyn SettingRepo, secrets: Option<&dyn SecretStorePort>) -> Result<Option<Self>, Error> {
let host = match db.get_setting("smtp_host")? {
Some(v) if !v.is_empty() => v,
_ => return Ok(None),
@ -44,8 +46,7 @@ impl SmtpClient {
_ => return Ok(None),
};
// Try secret store first, fall back to settings
let password = Self::resolve_smtp_password(db, secrets)?;
let password = Self::resolve_smtp_password(secrets)?;
let password = match password {
Some(v) if !v.is_empty() => v,
_ => return Ok(None),
@ -74,12 +75,9 @@ impl SmtpClient {
}))
}
/// Try to construct an `SmtpClient` from a SOAR port (which also provides `get_setting`).
/// Same logic as `from_database`, but accepts `&dyn SoarPort` instead of `&dyn RepositoryPort`.
pub fn from_soar_port(
db: &dyn crate::interface::port::soar::SoarPort,
secrets: Option<&dyn SecretStorePort>,
) -> Result<Option<Self>, Error> {
/// Try to construct an `SmtpClient` from any SettingRepo implementation.
/// Kept as a separate method name for call-site clarity (SOAR actions).
pub fn from_soar_port(db: &dyn SettingRepo, secrets: Option<&dyn SecretStorePort>) -> Result<Option<Self>, Error> {
let host = match db.get_setting("smtp_host")? {
Some(v) if !v.is_empty() => v,
_ => return Ok(None),
@ -93,13 +91,9 @@ impl SmtpClient {
_ => return Ok(None),
};
// Try secret store first, fall back to settings via SoarPort
let password = match secrets.and_then(|ss| ss.get_secret("smtp_password").ok().flatten()) {
Some(pw) if !pw.is_empty() => pw,
_ => match db.get_setting("smtp_password")? {
Some(v) if !v.is_empty() && v != "__encrypted__" => v,
_ => return Ok(None),
},
_ => return Ok(None),
};
let port: u16 = port_str.parse().unwrap_or(587);
@ -123,32 +117,20 @@ impl SmtpClient {
}
/// Resolve SMTP password: try secret store first, fall back to settings.
fn resolve_smtp_password(
db: &dyn RepositoryPort,
secrets: Option<&dyn SecretStorePort>,
) -> Result<Option<String>, Error> {
if let Some(ss) = secrets
&& let Some(pw) = ss.get_secret("smtp_password")?
&& !pw.is_empty()
{
return Ok(Some(pw));
}
// Fallback: read from settings (pre-migration or no secret store)
let val = db.get_setting("smtp_password")?;
match val {
Some(ref v) if v == "__encrypted__" => Ok(None),
other => Ok(other),
fn resolve_smtp_password(secrets: Option<&dyn SecretStorePort>) -> Result<Option<String>, Error> {
match secrets {
Some(ss) => Ok(ss.get_secret("smtp_password")?.filter(|pw| !pw.is_empty())),
None => Ok(None),
}
}
/// 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.sender.parse().map_err(|e| NotificationError::InvalidAddress {
reason: format!("invalid from address: {e}"),
})?;
let to_addr = to.parse().map_err(|e| NotificationError::InvalidAddress {
reason: format!("invalid to address: {e}"),
})?;
let from_addr = self
.sender
.parse()
.map_err(|e| NotificationError::InvalidAddress("from", e))?;
let to_addr = to.parse().map_err(|e| NotificationError::InvalidAddress("to", e))?;
let email = Message::builder()
.from(from_addr)
@ -156,7 +138,7 @@ impl SmtpClient {
.subject(subject)
.header(ContentType::TEXT_HTML)
.body(html_body.to_string())
.map_err(|e| NotificationError::MessageBuildFailed { reason: e.to_string() })?;
.map_err(NotificationError::MessageBuildFailed)?;
let creds = Credentials::new(self.username.clone(), self.password.clone());
@ -164,7 +146,7 @@ impl SmtpClient {
465 => {
// Implicit TLS (SMTPS)
SmtpTransport::relay(&self.host)
.map_err(|e| NotificationError::SmtpConnectionFailed { reason: e.to_string() })?
.map_err(NotificationError::SmtpConnectionFailed)?
.port(self.port)
.credentials(creds)
.build()
@ -172,7 +154,7 @@ impl SmtpClient {
25 | 587 => {
// STARTTLS (standard submission ports)
SmtpTransport::starttls_relay(&self.host)
.map_err(|e| NotificationError::SmtpConnectionFailed { reason: e.to_string() })?
.map_err(NotificationError::SmtpConnectionFailed)?
.port(self.port)
.credentials(creds)
.build()
@ -186,9 +168,7 @@ impl SmtpClient {
}
};
mailer
.send(&email)
.map_err(|e| NotificationError::SmtpSendFailed { reason: e.to_string() })?;
mailer.send(&email).map_err(NotificationError::SmtpSendFailed)?;
Ok(())
}
@ -197,21 +177,21 @@ impl SmtpClient {
/// Scheduler that checks once per hour whether it is time to send the weekly
/// report (Monday 08:00 local time) and dispatches it via SMTP.
pub struct ReportScheduler {
db: Arc<dyn RepositoryPort>,
db: Arc<dyn SettingRepo>,
secrets: Option<Arc<dyn SecretStorePort>>,
}
impl ReportScheduler {
pub fn new(db: Arc<dyn RepositoryPort>, secrets: Option<Arc<dyn SecretStorePort>>) -> Self {
pub fn new(db: Arc<dyn SettingRepo>, secrets: Option<Arc<dyn SecretStorePort>>) -> Self {
Self { db, secrets }
}
/// Spawn a background tokio task that runs the weekly check loop.
pub fn run(&self) -> tokio::task::JoinHandle<()> {
pub fn run(&self) -> JoinHandle<()> {
let db = Arc::clone(&self.db);
let secrets = self.secrets.clone();
tokio::spawn(async move {
info!("Weekly report scheduler started");
log!(SystemLog::WeeklyReportSchedulerStarted);
let mut interval = time::interval(Duration::from_secs(3600));
loop {
interval.tick().await;
@ -220,19 +200,16 @@ impl ReportScheduler {
continue;
}
info!("Weekly report window reached — preparing report");
log!(SystemLog::WeeklyReportWindowReached);
let smtp = match SmtpClient::from_database(&*db, secrets.as_deref()) {
Ok(Some(client)) => client,
Ok(None) => {
warn!(
"SMTP is not configured (missing smtp_host/port/username/password). \
Skipping weekly report."
);
log!(SystemLog::SmtpNotConfigured);
continue;
}
Err(e) => {
error!("Failed to read SMTP settings: {e}");
log!(SystemLog::SmtpSettingsReadFailed(e.to_string()));
continue;
}
};
@ -240,26 +217,26 @@ impl ReportScheduler {
let recipient = match db.get_setting("smtp_recipient") {
Ok(Some(r)) if !r.is_empty() => r,
_ => {
warn!("No smtp_recipient configured. Skipping weekly report.");
log!(SystemLog::SmtpRecipientMissing);
continue;
}
};
let html = match super::report::generate_weekly_report(&*db) {
let html = match report::generate_weekly_report(&*db) {
Ok(h) => h,
Err(e) => {
error!("Failed to generate weekly report: {e}");
log!(SystemLog::WeeklyReportGenerationFailed(e.to_string()));
continue;
}
};
let subject = format!("NetGuardia Weekly Report — {}", chrono::Local::now().format("%Y-%m-%d"));
let send_result = tokio::task::spawn_blocking(move || smtp.send(&recipient, &subject, &html)).await;
let subject = format!("NetGuardia Weekly Report — {}", Local::now().format("%Y-%m-%d"));
let send_result = spawn_blocking(move || smtp.send(&recipient, &subject, &html)).await;
match send_result {
Ok(Ok(())) => info!("Weekly report sent successfully"),
Ok(Err(e)) => error!("Failed to send weekly report: {e}"),
Err(e) => error!("Send task panicked: {e}"),
Ok(Ok(())) => log!(SystemLog::WeeklyReportSent),
Ok(Err(e)) => log!(SystemLog::WeeklyReportSendFailed(e.to_string())),
Err(e) => log!(SystemLog::WeeklyReportSendPanicked(e.to_string())),
}
}
})
@ -270,6 +247,6 @@ impl ReportScheduler {
/// hour (i.e. Monday, hour == 8).
fn is_send_window() -> bool {
use chrono::{Datelike, Timelike};
let now = chrono::Local::now();
now.weekday() == chrono::Weekday::Mon && now.hour() == 8
let now = Local::now();
now.weekday() == Weekday::Mon && now.hour() == 8
}

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