Add multi-source detection consensus, custom model uploads, and live log streaming (#19)
BIN
.github/images/accessControl.png
vendored
|
Before Width: | Height: | Size: 71 KiB |
BIN
.github/images/aiDetection.png
vendored
|
Before Width: | Height: | Size: 194 KiB |
BIN
.github/images/architecture.png
vendored
Normal file
|
After Width: | Height: | Size: 163 KiB |
105
.github/images/architecture.svg
vendored
Normal 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 & 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 & 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 |
BIN
.github/images/dashboard.png
vendored
|
Before Width: | Height: | Size: 98 KiB |
BIN
.github/images/index.png
vendored
|
Before Width: | Height: | Size: 108 KiB |
BIN
.github/images/map.png
vendored
|
Before Width: | Height: | Size: 187 KiB |
BIN
.github/images/statistics.png
vendored
|
Before Width: | Height: | Size: 122 KiB |
BIN
.github/images/ui/access-control.png
vendored
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
.github/images/ui/account-management.png
vendored
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
.github/images/ui/api-keys.png
vendored
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
.github/images/ui/audit-log.png
vendored
Normal file
|
After Width: | Height: | Size: 223 KiB |
BIN
.github/images/ui/auto-response.png
vendored
Normal file
|
After Width: | Height: | Size: 133 KiB |
BIN
.github/images/ui/detection.png
vendored
Normal file
|
After Width: | Height: | Size: 140 KiB |
BIN
.github/images/ui/dns-filter.png
vendored
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
.github/images/ui/drop-monitor.png
vendored
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
.github/images/ui/flow-trace.png
vendored
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
.github/images/ui/geoip-block.png
vendored
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
.github/images/ui/logs.png
vendored
Normal file
|
After Width: | Height: | Size: 267 KiB |
BIN
.github/images/ui/map.png
vendored
Normal file
|
After Width: | Height: | Size: 213 KiB |
BIN
.github/images/ui/protocol-filter.png
vendored
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
.github/images/ui/rate-limit.png
vendored
Normal file
|
After Width: | Height: | Size: 87 KiB |
BIN
.github/images/ui/security-report.png
vendored
Normal file
|
After Width: | Height: | Size: 122 KiB |
BIN
.github/images/ui/statistics.png
vendored
Normal file
|
After Width: | Height: | Size: 182 KiB |
BIN
.github/images/ui/system-settings.png
vendored
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
.github/images/ui/system-status.png
vendored
Normal file
|
After Width: | Height: | Size: 151 KiB |
8
.gitignore
vendored
@ -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
@ -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
@ -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
|
||||

|
||||
- Real-time control system occupancy rate
|
||||
## Architecture
|
||||
|
||||
### Dashboard Overview
|
||||

|
||||
- Real-time network traffic monitoring and visualization
|
||||
- Recent traffic statistics and trend analysis
|
||||

|
||||
|
||||
### Detailed Traffic Statistics
|
||||

|
||||
- Detailed traffic usage information per IP address
|
||||
## Requirements
|
||||
|
||||
### Network Access Control
|
||||

|
||||
- 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 |
|
||||
|
||||
[//]: # ()
|
||||
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).
|
||||
|
||||
@ -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"
|
||||
@ -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()
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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
@ -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
|
||||
@ -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 }
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(())
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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! {
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
309
net-guardia/src/adapter/ebpf/protocol_filter.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
113
net-guardia/src/adapter/ebpf/rate_limit.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
@ -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()})),
|
||||
}
|
||||
|
||||
@ -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})),
|
||||
|
||||
@ -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(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
26
net-guardia/src/adapter/http/byo.rs
Normal 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,
|
||||
}))
|
||||
}
|
||||
@ -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()))
|
||||
}
|
||||
|
||||
143
net-guardia/src/adapter/http/flow_trace.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
226
net-guardia/src/adapter/http/fusion.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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!({
|
||||
|
||||
@ -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,
|
||||
}))
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
890
net-guardia/src/adapter/http/model_upload.rs
Normal 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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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!({
|
||||
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
pub mod access_control_adapter;
|
||||
pub mod ebpf;
|
||||
pub mod http;
|
||||
pub mod persistence;
|
||||
pub mod telegram;
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
_ => {}
|
||||
}
|
||||
|
||||
192
net-guardia/src/adapter/websocket/fusion_websocket.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
195
net-guardia/src/core/auth/csrf.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
@ -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"})))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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());
|
||||
}
|
||||
};
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
pub mod csrf;
|
||||
pub mod extractor;
|
||||
pub mod https_redirect;
|
||||
pub mod jwt;
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
135
net-guardia/src/core/detection/fusion_math.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
223
net-guardia/src/core/detection/metrics.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -1,2 +1,4 @@
|
||||
pub mod beaconing;
|
||||
pub mod fusion_math;
|
||||
pub mod metrics;
|
||||
pub mod orchestrator;
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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(())
|
||||
}
|
||||
}
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||