Compare commits
2 Commits
449b1a5ac6
...
2a7f62a461
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a7f62a461 | |||
| 6c5641c3d8 |
BIN
.github/images/ui/access-control.png
vendored
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 86 KiB |
BIN
.github/images/ui/account-management.png
vendored
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 86 KiB |
BIN
.github/images/ui/api-keys.png
vendored
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 75 KiB |
BIN
.github/images/ui/audit-log.png
vendored
|
Before Width: | Height: | Size: 223 KiB After Width: | Height: | Size: 302 KiB |
BIN
.github/images/ui/auto-response-block-rules.png
vendored
Normal file
|
After Width: | Height: | Size: 101 KiB |
BIN
.github/images/ui/auto-response-history.png
vendored
Normal file
|
After Width: | Height: | Size: 185 KiB |
BIN
.github/images/ui/auto-response.png
vendored
|
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 126 KiB |
BIN
.github/images/ui/detection-details.png
vendored
Normal file
|
After Width: | Height: | Size: 174 KiB |
BIN
.github/images/ui/detection.png
vendored
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 133 KiB |
BIN
.github/images/ui/dns-filter.png
vendored
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 71 KiB |
BIN
.github/images/ui/drop-monitor.png
vendored
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 72 KiB |
BIN
.github/images/ui/flow-trace.png
vendored
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 84 KiB |
BIN
.github/images/ui/geoip-block.png
vendored
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 74 KiB |
BIN
.github/images/ui/global-attack-map.png
vendored
Normal file
|
After Width: | Height: | Size: 207 KiB |
BIN
.github/images/ui/group-management.png
vendored
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
.github/images/ui/logs.png
vendored
|
Before Width: | Height: | Size: 267 KiB After Width: | Height: | Size: 283 KiB |
BIN
.github/images/ui/map.png
vendored
|
Before Width: | Height: | Size: 213 KiB After Width: | Height: | Size: 229 KiB |
BIN
.github/images/ui/overview.png
vendored
Normal file
|
After Width: | Height: | Size: 195 KiB |
BIN
.github/images/ui/protocol-filter.png
vendored
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 75 KiB |
BIN
.github/images/ui/rate-limit.png
vendored
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 94 KiB |
BIN
.github/images/ui/security-report.png
vendored
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 125 KiB |
BIN
.github/images/ui/statistics.png
vendored
|
Before Width: | Height: | Size: 182 KiB After Width: | Height: | Size: 196 KiB |
BIN
.github/images/ui/system-settings-detection.png
vendored
Normal file
|
After Width: | Height: | Size: 157 KiB |
BIN
.github/images/ui/system-settings-network.png
vendored
Normal file
|
After Width: | Height: | Size: 133 KiB |
BIN
.github/images/ui/system-settings-notifications.png
vendored
Normal file
|
After Width: | Height: | Size: 118 KiB |
BIN
.github/images/ui/system-settings-soar.png
vendored
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
.github/images/ui/system-settings-system.png
vendored
Normal file
|
After Width: | Height: | Size: 127 KiB |
BIN
.github/images/ui/system-settings-threat-analysis.png
vendored
Normal file
|
After Width: | Height: | Size: 128 KiB |
BIN
.github/images/ui/system-settings.png
vendored
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 114 KiB |
BIN
.github/images/ui/system-status.png
vendored
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 140 KiB |
14
.github/workflows/ci.yml
vendored
@ -18,8 +18,17 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Resolve frontend submodule commit
|
||||
id: frontend-ref
|
||||
run: echo "sha=$(git rev-parse HEAD:net-guardia-frontend)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout frontend submodule
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
repository: DaLaw2/NetGuardia-FrontEnd
|
||||
ref: ${{ steps.frontend-ref.outputs.sha }}
|
||||
path: net-guardia-frontend
|
||||
token: ${{ secrets.SUBMODULE_PAT }}
|
||||
|
||||
- name: Install system dependencies
|
||||
@ -89,9 +98,6 @@ jobs:
|
||||
run: npm test
|
||||
working-directory: net-guardia-frontend
|
||||
|
||||
- name: Trainer Python compile check
|
||||
run: python3 -m compileall -q net-guardia-trainer/src
|
||||
|
||||
integration-test:
|
||||
name: Integration Test (placeholder)
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
1135
Cargo.lock
generated
35
Cargo.toml
@ -1,11 +1,11 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["net-guardia", "common", "macros", "ingress-ebpf", "egress-ebpf", "mcp-server", "cli"]
|
||||
default-members = ["net-guardia", "common", "mcp-server", "cli"]
|
||||
members = ["net-guardia", "net-guardia-abi", "macros", "ingress-ebpf", "egress-ebpf", "net-guardia-cli"]
|
||||
default-members = ["net-guardia", "net-guardia-abi", "net-guardia-cli"]
|
||||
|
||||
[workspace.dependencies]
|
||||
# Local crates
|
||||
common = { path = "common" }
|
||||
net-guardia-abi = { path = "net-guardia-abi" }
|
||||
macros = { path = "macros" }
|
||||
|
||||
# eBPF - kernel side (pinned: aya-ebpf 0.1.2 was yanked, see aya-rs/aya#1400)
|
||||
@ -15,7 +15,7 @@ aya-log-ebpf = { version = "=0.1.0", default-features = false }
|
||||
# eBPF - userspace side
|
||||
aya = { version = "0.13.1", default-features = false }
|
||||
aya-log = { version = "0.2.1", default-features = false }
|
||||
network-types = "0.1.0"
|
||||
network-types = { version = "0.2.0", default-features = false }
|
||||
|
||||
# XDP
|
||||
xsk-rs = { version = "0.8.0", default-features = false }
|
||||
@ -27,7 +27,8 @@ 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", "process", "io-util", "fs", "signal"] }
|
||||
tokio = { version = "1.50.0", features = ["rt-multi-thread", "macros", "sync", "time", "process", "io-util", "io-std", "fs", "signal"] }
|
||||
tokio-util = { version = "0.7", features = ["io"] }
|
||||
|
||||
# Web framework
|
||||
actix = "0.13.5"
|
||||
@ -36,7 +37,8 @@ actix-cors = "0.7.1"
|
||||
actix-ws = "0.4.0"
|
||||
actix-multipart = "0.7"
|
||||
actix-files = "0.6"
|
||||
tokio-tungstenite = "0.28.0"
|
||||
tokio-tungstenite = "0.29.0"
|
||||
zip = "8.6.0"
|
||||
|
||||
# Logging / tracing
|
||||
tracing = "0.1.44"
|
||||
@ -54,15 +56,14 @@ futures-util = "0.3.32"
|
||||
crossbeam = "0.8.4"
|
||||
|
||||
# System
|
||||
sysinfo = "0.38.4"
|
||||
maxminddb = "0.27.3"
|
||||
sysinfo = "0.39.0"
|
||||
maxminddb = "0.28.1"
|
||||
ipnetwork = "0.21.1"
|
||||
lru = "0.16.3"
|
||||
lru = "0.18.0"
|
||||
rusqlite = { version = "0.39", features = ["bundled-sqlcipher"] }
|
||||
async-sqlite = { version = "0.5.7", default-features = false, features = ["bundled-sqlcipher"] }
|
||||
jsonwebtoken = "9"
|
||||
argon2 = "0.5"
|
||||
rand = "0.9"
|
||||
rand = "0.10.1"
|
||||
ed25519-dalek = { version = "2", features = ["std", "rand_core"] }
|
||||
base64 = "0.22"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
@ -73,17 +74,17 @@ url = "2.5.8"
|
||||
toml = "1.0.7"
|
||||
lettre = { version = "0.11", default-features = false, features = ["builder", "hostname", "smtp-transport", "tokio1-rustls-tls"] }
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock", "std"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
reqwest = { version = "0.13.3", default-features = false, features = ["json", "rustls"] }
|
||||
async-trait = "0.1"
|
||||
dashmap = "6"
|
||||
arc-swap = "1"
|
||||
moka = { version = "0.12", features = ["sync"] }
|
||||
notify = "7"
|
||||
sha2 = "0.10"
|
||||
hmac = "0.12"
|
||||
notify = "8.2.0"
|
||||
sha2 = "0.11.0"
|
||||
hmac = "0.13.0"
|
||||
aes-gcm = "0.10"
|
||||
hkdf = "0.12"
|
||||
sd-notify = "0.4"
|
||||
hkdf = "0.13.0"
|
||||
sd-notify = "0.5.0"
|
||||
|
||||
# Build dependencies
|
||||
cargo_metadata = { version = "0.23.1", default-features = false }
|
||||
|
||||
112
README.md
@ -10,16 +10,65 @@ Inline network security platform built on eBPF/XDP. Combines ONNX-based ML, temp
|
||||
- **Frontend** — Vue 3 + Pinia + Vue-i18n (en / zh-TW / zh-CN / ja)
|
||||
- **Architecture** — hexagonal-ish Rust workspace: `domain/` · `interface/` · `core/` · `adapter/` · `infrastructure/`
|
||||
|
||||
## Capabilities
|
||||
|
||||
NetGuardia sits inline between network segments, observes traffic, detects threats, and applies policy or automated response from one control plane.
|
||||
|
||||
### Traffic Visibility
|
||||
|
||||
- Live security overview with threat counts, traffic rate, system health, recent alerts, and SOAR activity.
|
||||
- Per-IP traffic statistics for bytes, packets, last-seen time, direction, address family, and source/destination views.
|
||||
- Network and attack maps for geographic flow visualization and attack-source distribution.
|
||||
- Flow trace recording for offline training, incident review, and audit workflows.
|
||||
|
||||
### Threat Detection
|
||||
|
||||
- ML-assisted traffic detection using ONNX runtime models and a pipeline adapter.
|
||||
- Multi-source fusion across ML signals, temporal beaconing, correlation heuristics, and Suricata `eve.json` alerts.
|
||||
- Alert details with confidence, anomaly score, classifier score, connection metadata, protocol, source, and destination.
|
||||
- Drift detection and audit events for model behavior changes.
|
||||
- BYO model upload, validation, promotion, and offline mode for custom pipeline bundles.
|
||||
|
||||
### Inline Enforcement
|
||||
|
||||
- IPv4 and IPv6 access-control lists with blacklist and whitelist support.
|
||||
- GeoIP country blocking for region-based policy.
|
||||
- DNS blacklist filtering for suspicious domains.
|
||||
- HTTP and SSH protocol/service access rules.
|
||||
- Packet, SYN, UDP, and DNS rate limits for DDoS-oriented controls.
|
||||
- Real-time drop monitor for intercepted packet events.
|
||||
|
||||
### Automated Response
|
||||
|
||||
- SOAR playbooks with triggers, conditions, cooldowns, and response actions.
|
||||
- Dry-run mode for testing playbook behavior before enabling automation.
|
||||
- Execution history for automated actions such as IP blocks.
|
||||
- SOAR whitelist and active auto-block tracking.
|
||||
|
||||
### Operations And Administration
|
||||
|
||||
- WORM-style audit log with chain verification for administrative and detection events.
|
||||
- Live log stream with level filtering, search, follow/pause, and archived log download.
|
||||
- Security report with threat breakdown, SOAR summary, system health, PDF download, and email delivery.
|
||||
- User, group, RBAC permission, and API key management.
|
||||
- System health dashboard for CPU, memory, temperature, OS, NIC counters, engine mode, and boot time.
|
||||
- Runtime settings for general mode, network interfaces, notifications, detection, threat analysis, SOAR, and system integrations.
|
||||
|
||||
## Screens
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><img src=".github/images/ui/overview.png" alt="Security overview"/><br><sub>Security overview with live posture, trends, threats, and SOAR activity</sub></td>
|
||||
<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>
|
||||
<td><img src=".github/images/ui/map.png" alt="Network traffic map"/><br><sub>Live geographic flow map</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/global-attack-map.png" alt="Global attack map"/><br><sub>Global attack-source distribution</sub></td>
|
||||
<td><img src=".github/images/ui/drop-monitor.png" alt="Drop monitor"/><br><sub>Real-time drop monitor</sub></td>
|
||||
<td><img src=".github/images/ui/detection.png" alt="Threat detection"/><br><sub>Threat detection alert queue</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src=".github/images/ui/detection-details.png" alt="Alert details"/><br><sub>Alert details with model scores and connection metadata</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>
|
||||
@ -29,48 +78,33 @@ Inline network security platform built on eBPF/XDP. Combines ONNX-based ML, temp
|
||||
<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/auto-response.png" alt="SOAR playbooks"/><br><sub>SOAR playbook management and dry-run</sub></td>
|
||||
<td><img src=".github/images/ui/auto-response-history.png" alt="SOAR execution history"/><br><sub>SOAR execution history</sub></td>
|
||||
<td><img src=".github/images/ui/auto-response-block-rules.png" alt="SOAR block rules"/><br><sub>SOAR whitelist and active auto-blocks</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<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>
|
||||
<td><img src=".github/images/ui/group-management.png" alt="Groups"/><br><sub>Built-in and custom permission groups</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src=".github/images/ui/api-keys.png" alt="API keys"/><br><sub>API keys</sub></td>
|
||||
<td><img src=".github/images/ui/audit-log.png" alt="Audit log"/><br><sub>WORM-chained audit log</sub></td>
|
||||
<td><img src=".github/images/ui/logs.png" alt="Logs"/><br><sub>Live + archived logs</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<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/flow-trace.png" alt="Flow trace"/><br><sub>Rotated flow recording</sub></td>
|
||||
<td><img src=".github/images/ui/system-settings.png" alt="System settings"/><br><sub>Mode / theme / HTTP / engine</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src=".github/images/ui/system-settings-network.png" alt="Network settings"/><br><sub>Network interfaces, XDP, and pipeline settings</sub></td>
|
||||
<td><img src=".github/images/ui/system-settings-notifications.png" alt="Notification settings"/><br><sub>SMTP and Telegram notification settings</sub></td>
|
||||
<td><img src=".github/images/ui/system-settings-detection.png" alt="Detection settings"/><br><sub>ML model status, inference, and BYO upload controls</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src=".github/images/ui/system-settings-threat-analysis.png" alt="Threat analysis settings"/><br><sub>Beaconing and correlation thresholds</sub></td>
|
||||
<td><img src=".github/images/ui/system-settings-soar.png" alt="SOAR settings"/><br><sub>SOAR limits, TTL, and DNS action settings</sub></td>
|
||||
<td><img src=".github/images/ui/system-settings-system.png" alt="System integration settings"/><br><sub>Suricata and GeoIP integration settings</sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Architecture
|
||||
|
||||

|
||||
|
||||
## Requirements
|
||||
|
||||
Linux kernel with eBPF **and** a NIC driver that implements AF_XDP on that kernel. No single "minimum kernel" — it depends on the NIC.
|
||||
|
||||
| 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.
|
||||
|
||||
## Build
|
||||
|
||||
```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,153 +0,0 @@
|
||||
use core::mem::size_of;
|
||||
|
||||
use aya_ebpf::helpers::bpf_ktime_get_ns;
|
||||
use network_types::eth::{EthHdr, EtherType};
|
||||
use network_types::ip::{IpProto, Ipv4Hdr, Ipv6Hdr};
|
||||
use network_types::tcp::TcpHdr;
|
||||
use network_types::udp::UdpHdr;
|
||||
|
||||
use crate::define::offset::*;
|
||||
use crate::model::parsed_packet::ParsedPacket;
|
||||
|
||||
#[allow(clippy::result_unit_err, clippy::not_unsafe_ptr_arg_deref)]
|
||||
pub fn parse_packet(start: usize, end: usize, target: *mut ParsedPacket) -> Result<(), ()> {
|
||||
unsafe {
|
||||
if start + ETHER_HEADER_END > end {
|
||||
return Err(());
|
||||
}
|
||||
let eth = &*((start + ETHER_HEADER_START) as *const EthHdr);
|
||||
let ether_type = eth.ether_type().map_err(|_| ())?;
|
||||
match ether_type {
|
||||
EtherType::Ipv4 => parse_ipv4_packet(start, end, target),
|
||||
EtherType::Ipv6 => parse_ipv6_packet(start, end, target),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
unsafe fn parse_ipv4_packet(start: usize, end: usize, target: *mut ParsedPacket) -> Result<(), ()> {
|
||||
if start + IPV4_HEADER_END > end {
|
||||
return Err(());
|
||||
}
|
||||
|
||||
unsafe {
|
||||
let ipv4 = &*((start + IPV4_HEADER_START) as *const Ipv4Hdr);
|
||||
let ipv4_header_len = parse_ipv4_header_len(start, end)?;
|
||||
let l4_start = IPV4_HEADER_START + ipv4_header_len;
|
||||
let packet_length = (end - start) as u32;
|
||||
|
||||
let t = &mut *target;
|
||||
t.timestamp_ns = bpf_ktime_get_ns();
|
||||
core::ptr::copy_nonoverlapping(ipv4.src_addr.as_ptr(), t.src_ip.as_mut_ptr(), 4);
|
||||
core::ptr::copy_nonoverlapping(ipv4.dst_addr.as_ptr(), t.dst_ip.as_mut_ptr(), 4);
|
||||
t.packet_length = packet_length;
|
||||
t.ip_version = 4;
|
||||
t.protocol = ipv4.proto;
|
||||
|
||||
let (src_port, dst_port, tcp_flags, l4_header_len) = match ipv4.proto {
|
||||
IpProto::Tcp => parse_tcp(start, end, l4_start)?,
|
||||
IpProto::Udp => parse_udp(start, end, l4_start)?,
|
||||
_ => (0, 0, 0, 0),
|
||||
};
|
||||
|
||||
t.payload_length = packet_length.saturating_sub((l4_start + l4_header_len) as u32);
|
||||
t.src_port = src_port;
|
||||
t.dst_port = dst_port;
|
||||
t.tcp_flags = tcp_flags;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
unsafe fn parse_ipv6_packet(start: usize, end: usize, target: *mut ParsedPacket) -> Result<(), ()> {
|
||||
if start + IPV6_HEADER_END > end {
|
||||
return Err(());
|
||||
}
|
||||
|
||||
unsafe {
|
||||
let ipv6 = &*((start + IPV6_HEADER_START) as *const Ipv6Hdr);
|
||||
let packet_length = (end - start) as u32;
|
||||
|
||||
let t = &mut *target;
|
||||
t.timestamp_ns = bpf_ktime_get_ns();
|
||||
core::ptr::copy_nonoverlapping(ipv6.src_addr.as_ptr(), t.src_ip.as_mut_ptr(), 16);
|
||||
core::ptr::copy_nonoverlapping(ipv6.dst_addr.as_ptr(), t.dst_ip.as_mut_ptr(), 16);
|
||||
t.packet_length = packet_length;
|
||||
t.ip_version = 6;
|
||||
t.protocol = ipv6.next_hdr;
|
||||
|
||||
let (src_port, dst_port, tcp_flags, l4_header_len) = match ipv6.next_hdr {
|
||||
IpProto::Tcp => parse_tcp(start, end, IPV6_TCP_HEADER_START)?,
|
||||
IpProto::Udp => parse_udp(start, end, IPV6_UDP_HEADER_START)?,
|
||||
_ => (0, 0, 0, 0),
|
||||
};
|
||||
|
||||
t.payload_length = packet_length.saturating_sub((IPV6_HEADER_END + l4_header_len) as u32);
|
||||
t.src_port = src_port;
|
||||
t.dst_port = dst_port;
|
||||
t.tcp_flags = tcp_flags;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
#[allow(clippy::manual_range_contains)]
|
||||
unsafe fn parse_ipv4_header_len(start: usize, end: usize) -> Result<usize, ()> {
|
||||
if start + IPV4_HEADER_START + 1 > end {
|
||||
return Err(());
|
||||
}
|
||||
|
||||
let version_ihl = unsafe { *((start + IPV4_HEADER_START) as *const u8) };
|
||||
let version = version_ihl >> 4;
|
||||
let ihl = (version_ihl & 0x0f) as usize;
|
||||
if version != 4 || ihl < 5 || ihl > 15 {
|
||||
return Err(());
|
||||
}
|
||||
|
||||
let header_len = ihl * 4;
|
||||
if start + IPV4_HEADER_START + header_len > end {
|
||||
return Err(());
|
||||
}
|
||||
|
||||
Ok(header_len)
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
#[allow(clippy::manual_range_contains)]
|
||||
unsafe fn parse_tcp(start: usize, end: usize, tcp_start: usize) -> Result<(u16, u16, u8, usize), ()> {
|
||||
if start + tcp_start + size_of::<TcpHdr>() > end {
|
||||
return Err(());
|
||||
}
|
||||
|
||||
unsafe {
|
||||
let tcp = &*((start + tcp_start) as *const TcpHdr);
|
||||
let data_offset = (*((start + tcp_start + 12) as *const u8) >> 4) as usize;
|
||||
if data_offset < 5 || data_offset > 15 {
|
||||
return Err(());
|
||||
}
|
||||
let header_len = data_offset * 4;
|
||||
if start + tcp_start + header_len > end {
|
||||
return Err(());
|
||||
}
|
||||
let flags = *((start + tcp_start + 13) as *const u8);
|
||||
Ok((
|
||||
u16::from_be_bytes(tcp.source),
|
||||
u16::from_be_bytes(tcp.dest),
|
||||
flags,
|
||||
header_len,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
unsafe fn parse_udp(start: usize, end: usize, udp_start: usize) -> Result<(u16, u16, u8, usize), ()> {
|
||||
if start + udp_start + size_of::<UdpHdr>() > end {
|
||||
return Err(());
|
||||
}
|
||||
|
||||
let udp = unsafe { &*((start + udp_start) as *const UdpHdr) };
|
||||
Ok((udp.src_port(), udp.dst_port(), 0u8, 8usize))
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
pub type PlaceHolder = u8;
|
||||
@ -25,14 +25,13 @@ RUN dnf install -y epel-release && \
|
||||
openssl-devel \
|
||||
&& dnf clean all
|
||||
|
||||
# Rust toolchain
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y \
|
||||
&& /root/.cargo/bin/rustup toolchain install nightly \
|
||||
&& /root/.cargo/bin/rustup component add rust-src --toolchain nightly
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain 1.95.0 \
|
||||
&& /root/.cargo/bin/rustup component add clippy rustfmt --toolchain 1.95.0 \
|
||||
&& /root/.cargo/bin/rustup toolchain install nightly --component rust-src \
|
||||
&& /root/.cargo/bin/rustup default 1.95.0
|
||||
ENV PATH="/root/.cargo/bin:${PATH}"
|
||||
|
||||
# bpf-linker for aya eBPF compilation
|
||||
RUN cargo install bpf-linker
|
||||
RUN cargo install bpf-linker --version 0.10.3 --locked
|
||||
|
||||
RUN ln -s /usr/bin/node-24 /usr/local/bin/node && \
|
||||
ln -s /usr/bin/npm-24 /usr/local/bin/npm && \
|
||||
|
||||
@ -15,6 +15,9 @@ services:
|
||||
memlock:
|
||||
soft: -1
|
||||
hard: -1
|
||||
environment:
|
||||
NETGUARDIA_DB_KEY: netguardia-dev-db-key
|
||||
NETGUARDIA_SECRETS_KEY: netguardia-dev-secrets-key
|
||||
dns:
|
||||
- 10.10.3.1
|
||||
- 8.8.8.8
|
||||
|
||||
1
deploy/scripts/dev.sh
Normal file → Executable file
@ -506,6 +506,7 @@ main() {
|
||||
: >"$LOG_FILE"
|
||||
info "Compose log: $LOG_FILE"
|
||||
run_logged "Building containers" "${COMPOSE_CMD[@]}" build
|
||||
generate_compose_file
|
||||
run_logged "Starting containers" "${COMPOSE_CMD[@]}" up -d
|
||||
|
||||
info "Containers running"
|
||||
|
||||
@ -4,10 +4,9 @@ version = "1.0.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
common = { workspace = true, features = ["kernel"] }
|
||||
net-guardia-abi = { workspace = true, features = ["kernel"] }
|
||||
aya-ebpf = { workspace = true }
|
||||
aya-log-ebpf = { workspace = true }
|
||||
network-types = { workspace = true }
|
||||
|
||||
[build-dependencies]
|
||||
which = { workspace = true }
|
||||
|
||||
@ -1,7 +1,4 @@
|
||||
fn main() {
|
||||
// bpf-linker path is resolved and injected by net-guardia/build.rs
|
||||
// via CARGO_TARGET_BPFEB_UNKNOWN_NONE_LINKER env var.
|
||||
// This build.rs only needs to exist for cargo to run it.
|
||||
if let Ok(linker) = which::which("bpf-linker") {
|
||||
println!("cargo:rerun-if-changed={}", linker.display());
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
#![no_std]
|
||||
#![cfg_attr(any(target_arch = "bpf", target_os = "none"), no_std)]
|
||||
#![no_main]
|
||||
|
||||
use aya_ebpf::bindings::xdp_action;
|
||||
@ -7,9 +7,9 @@ use aya_ebpf::maps::{Array, XskMap};
|
||||
use aya_ebpf::programs::XdpContext;
|
||||
#[allow(unused_imports)]
|
||||
use aya_log_ebpf::info;
|
||||
use common::ebpf::parsing;
|
||||
use common::ebpf::symmetric_hash::symmetric_queue_id;
|
||||
use common::model::parsed_packet::ParsedPacket;
|
||||
use net_guardia_abi::ebpf::parsing;
|
||||
use net_guardia_abi::ebpf::symmetric_hash::symmetric_queue_id;
|
||||
use net_guardia_abi::model::parsed_packet::ParsedPacket;
|
||||
|
||||
#[map]
|
||||
static NUM_QUEUES: Array<u32> = Array::with_max_entries(1, 0);
|
||||
@ -30,13 +30,13 @@ pub fn net_guardia(ctx: XdpContext) -> u32 {
|
||||
unsafe fn compute_symmetric_queue_id(ctx: &XdpContext) -> Option<u32> {
|
||||
unsafe {
|
||||
let mut pkt = core::mem::zeroed::<ParsedPacket>();
|
||||
parsing::parse_packet(ctx.data(), ctx.data_end(), &mut pkt).ok()?;
|
||||
parsing::parse_packet(ctx.data(), ctx.data_end(), &mut pkt)?;
|
||||
let num_q = *NUM_QUEUES.get(0)?;
|
||||
symmetric_queue_id(&pkt, num_q)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
#[cfg(all(not(test), any(target_arch = "bpf", target_os = "none")))]
|
||||
#[panic_handler]
|
||||
fn panic(_info: &core::panic::PanicInfo) -> ! {
|
||||
unsafe { core::hint::unreachable_unchecked() }
|
||||
|
||||
@ -4,7 +4,7 @@ version = "1.0.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
common = { workspace = true, features = ["kernel"] }
|
||||
net-guardia-abi = { workspace = true, features = ["kernel"] }
|
||||
aya-ebpf = { workspace = true }
|
||||
aya-log-ebpf = { workspace = true }
|
||||
network-types = { workspace = true }
|
||||
|
||||
@ -1,7 +1,4 @@
|
||||
fn main() {
|
||||
// bpf-linker path is resolved and injected by net-guardia/build.rs
|
||||
// via CARGO_TARGET_BPFEL_UNKNOWN_NONE_LINKER env var.
|
||||
// This build.rs only needs to exist for cargo to run it.
|
||||
if let Ok(linker) = which::which("bpf-linker") {
|
||||
println!("cargo:rerun-if-changed={}", linker.display());
|
||||
}
|
||||
|
||||
@ -2,10 +2,10 @@ use aya_ebpf::macros::map;
|
||||
use aya_ebpf::maps::HashMap;
|
||||
use aya_ebpf::maps::LpmTrie;
|
||||
use aya_ebpf::maps::lpm_trie::Key;
|
||||
use common::define::setting::{MAX_GEO_ENTRIES, MAX_RULES};
|
||||
use common::model::ip_address::{IPv4, IPv6};
|
||||
use common::model::parsed_packet::ParsedPacket;
|
||||
use common::model::port_rule::PortRule;
|
||||
use net_guardia_abi::define::setting::{MAX_GEO_ENTRIES, MAX_RULES};
|
||||
use net_guardia_abi::model::ip_address::{IPv4, IPv6};
|
||||
use net_guardia_abi::model::parsed_packet::ParsedPacket;
|
||||
use net_guardia_abi::model::port_rule::PortRule;
|
||||
|
||||
#[map]
|
||||
static IPV4_SRC_WHITELIST: HashMap<IPv4, PortRule> = HashMap::with_max_entries(MAX_RULES as u32, 0);
|
||||
@ -29,32 +29,30 @@ static GEO_BLOCK_V4: LpmTrie<u32, u8> = LpmTrie::with_max_entries(MAX_GEO_ENTRIE
|
||||
static GEO_BLOCK_V6: LpmTrie<u128, u8> = LpmTrie::with_max_entries(MAX_GEO_ENTRIES, 0);
|
||||
|
||||
pub fn ipv4_is_geo_blocked(pkt: &ParsedPacket) -> bool {
|
||||
// from_ne_bytes so memory layout = raw packet bytes (network order).
|
||||
// Matches userspace insertion which uses to_bits().to_be() (same memory layout).
|
||||
let src_ip = u32::from_ne_bytes([pkt.src_ip[0], pkt.src_ip[1], pkt.src_ip[2], pkt.src_ip[3]]);
|
||||
let key = Key::new(32, src_ip);
|
||||
unsafe { GEO_BLOCK_V4.get(&key).is_some() }
|
||||
GEO_BLOCK_V4.get(&key).is_some()
|
||||
}
|
||||
|
||||
pub fn ipv6_is_geo_blocked(pkt: &ParsedPacket) -> bool {
|
||||
let src_ip = u128::from_ne_bytes(pkt.src_ip);
|
||||
let key = Key::new(128, src_ip);
|
||||
unsafe { GEO_BLOCK_V6.get(&key).is_some() }
|
||||
GEO_BLOCK_V6.get(&key).is_some()
|
||||
}
|
||||
|
||||
pub fn ipv4_is_whitelisted(pkt: &ParsedPacket) -> bool {
|
||||
let src_ip = pkt.src_ip_v4();
|
||||
let dst_ip = pkt.dst_ip_v4();
|
||||
unsafe {
|
||||
if let Some(rule) = IPV4_SRC_WHITELIST.get(&src_ip) {
|
||||
if rule.contains(pkt.src_port) {
|
||||
return true;
|
||||
}
|
||||
if let Some(rule) = IPV4_SRC_WHITELIST.get(&src_ip)
|
||||
&& rule.contains(pkt.src_port)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if let Some(rule) = IPV4_DST_WHITELIST.get(&dst_ip) {
|
||||
if rule.contains(pkt.dst_port) {
|
||||
return true;
|
||||
}
|
||||
if let Some(rule) = IPV4_DST_WHITELIST.get(&dst_ip)
|
||||
&& rule.contains(pkt.dst_port)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
@ -64,15 +62,15 @@ pub fn ipv6_is_whitelisted(pkt: &ParsedPacket) -> bool {
|
||||
let src_ip = pkt.src_ip_v6();
|
||||
let dst_ip = pkt.dst_ip_v6();
|
||||
unsafe {
|
||||
if let Some(rule) = IPV6_SRC_WHITELIST.get(&src_ip) {
|
||||
if rule.contains(pkt.src_port) {
|
||||
return true;
|
||||
}
|
||||
if let Some(rule) = IPV6_SRC_WHITELIST.get(&src_ip)
|
||||
&& rule.contains(pkt.src_port)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if let Some(rule) = IPV6_DST_WHITELIST.get(&dst_ip) {
|
||||
if rule.contains(pkt.dst_port) {
|
||||
return true;
|
||||
}
|
||||
if let Some(rule) = IPV6_DST_WHITELIST.get(&dst_ip)
|
||||
&& rule.contains(pkt.dst_port)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
@ -82,15 +80,15 @@ pub fn ipv4_is_blacklisted(pkt: &ParsedPacket) -> bool {
|
||||
let src_ip = pkt.src_ip_v4();
|
||||
let dst_ip = pkt.dst_ip_v4();
|
||||
unsafe {
|
||||
if let Some(rule) = IPV4_SRC_BLACKLIST.get(&src_ip) {
|
||||
if rule.contains(pkt.src_port) {
|
||||
return true;
|
||||
}
|
||||
if let Some(rule) = IPV4_SRC_BLACKLIST.get(&src_ip)
|
||||
&& rule.contains(pkt.src_port)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if let Some(rule) = IPV4_DST_BLACKLIST.get(&dst_ip) {
|
||||
if rule.contains(pkt.dst_port) {
|
||||
return true;
|
||||
}
|
||||
if let Some(rule) = IPV4_DST_BLACKLIST.get(&dst_ip)
|
||||
&& rule.contains(pkt.dst_port)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
@ -100,15 +98,15 @@ pub fn ipv6_is_blacklisted(pkt: &ParsedPacket) -> bool {
|
||||
let src_ip = pkt.src_ip_v6();
|
||||
let dst_ip = pkt.dst_ip_v6();
|
||||
unsafe {
|
||||
if let Some(rule) = IPV6_SRC_BLACKLIST.get(&src_ip) {
|
||||
if rule.contains(pkt.src_port) {
|
||||
return true;
|
||||
}
|
||||
if let Some(rule) = IPV6_SRC_BLACKLIST.get(&src_ip)
|
||||
&& rule.contains(pkt.src_port)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if let Some(rule) = IPV6_DST_BLACKLIST.get(&dst_ip) {
|
||||
if rule.contains(pkt.dst_port) {
|
||||
return true;
|
||||
}
|
||||
if let Some(rule) = IPV6_DST_BLACKLIST.get(&dst_ip)
|
||||
&& rule.contains(pkt.dst_port)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
use core::slice;
|
||||
|
||||
use aya_ebpf::macros::map;
|
||||
use aya_ebpf::maps::{Array, HashMap};
|
||||
use common::define::setting::MAX_RULES;
|
||||
use common::define::tcp_flags::*;
|
||||
use common::model::http_method::HttpMethodBitmap;
|
||||
use common::model::ip_address::*;
|
||||
use common::model::parsed_packet::ParsedPacket;
|
||||
use common::model::placeholder::PlaceHolder;
|
||||
use net_guardia_abi::define::setting::MAX_RULES;
|
||||
use net_guardia_abi::define::tcp_flags::*;
|
||||
use net_guardia_abi::model::empty::EmptyMapValue;
|
||||
use net_guardia_abi::model::http_method::HttpMethodBitmap;
|
||||
use net_guardia_abi::model::ip_address::*;
|
||||
use net_guardia_abi::model::parsed_packet::ParsedPacket;
|
||||
use network_types::ip::IpProto;
|
||||
|
||||
#[map]
|
||||
@ -13,19 +15,19 @@ static IPV4_HTTP_SERVICE: HashMap<AddrPortV4, HttpMethodBitmap> = HashMap::with_
|
||||
#[map]
|
||||
static IPV6_HTTP_SERVICE: HashMap<AddrPortV6, HttpMethodBitmap> = HashMap::with_max_entries(MAX_RULES as u32, 0);
|
||||
#[map]
|
||||
static SSH_WHITE_LIST_ENABLE: Array<PlaceHolder> = Array::with_max_entries(1, 0);
|
||||
static SSH_WHITE_LIST_ENABLE: Array<EmptyMapValue> = Array::with_max_entries(1, 0);
|
||||
#[map]
|
||||
static IPV4_SSH_SERVICE: HashMap<AddrPortV4, PlaceHolder> = HashMap::with_max_entries(MAX_RULES as u32, 0);
|
||||
static IPV4_SSH_SERVICE: HashMap<AddrPortV4, EmptyMapValue> = HashMap::with_max_entries(MAX_RULES as u32, 0);
|
||||
#[map]
|
||||
static IPV6_SSH_SERVICE: HashMap<AddrPortV6, PlaceHolder> = HashMap::with_max_entries(MAX_RULES as u32, 0);
|
||||
static IPV6_SSH_SERVICE: HashMap<AddrPortV6, EmptyMapValue> = HashMap::with_max_entries(MAX_RULES as u32, 0);
|
||||
#[map]
|
||||
static IPV4_SSH_WHITE_LIST: HashMap<IPv4, PlaceHolder> = HashMap::with_max_entries(MAX_RULES as u32, 0);
|
||||
static IPV4_SSH_WHITE_LIST: HashMap<IPv4, EmptyMapValue> = HashMap::with_max_entries(MAX_RULES as u32, 0);
|
||||
#[map]
|
||||
static IPV6_SSH_WHITE_LIST: HashMap<IPv6, PlaceHolder> = HashMap::with_max_entries(MAX_RULES as u32, 0);
|
||||
static IPV6_SSH_WHITE_LIST: HashMap<IPv6, EmptyMapValue> = HashMap::with_max_entries(MAX_RULES as u32, 0);
|
||||
#[map]
|
||||
static IPV4_SSH_BLACK_LIST: HashMap<IPv4, PlaceHolder> = HashMap::with_max_entries(MAX_RULES as u32, 0);
|
||||
static IPV4_SSH_BLACK_LIST: HashMap<IPv4, EmptyMapValue> = HashMap::with_max_entries(MAX_RULES as u32, 0);
|
||||
#[map]
|
||||
static IPV6_SSH_BLACK_LIST: HashMap<IPv6, PlaceHolder> = HashMap::with_max_entries(MAX_RULES as u32, 0);
|
||||
static IPV6_SSH_BLACK_LIST: HashMap<IPv6, EmptyMapValue> = HashMap::with_max_entries(MAX_RULES as u32, 0);
|
||||
|
||||
pub fn ipv4_service_rule_violation(start: usize, end: usize, pkt: &ParsedPacket) -> bool {
|
||||
let source = pkt.src_addr_v4();
|
||||
@ -51,7 +53,7 @@ fn http_service_violation<K>(
|
||||
) -> bool {
|
||||
match map.get_ptr_mut(destination) {
|
||||
Some(allow_method) => {
|
||||
if !matches!(pkt.protocol, IpProto::Tcp) {
|
||||
if pkt.protocol != IpProto::Tcp as u8 {
|
||||
return false;
|
||||
}
|
||||
if pkt.tcp_flags & (TCP_SYN | TCP_RST | TCP_FIN) != 0 {
|
||||
@ -64,15 +66,17 @@ fn http_service_violation<K>(
|
||||
return false;
|
||||
}
|
||||
let l4_offset = match pkt.ip_version {
|
||||
4 => 14 + ((unsafe { *((start + 14) as *const u8) } & 0x0F) as usize) * 4,
|
||||
6 => 14 + 40,
|
||||
value if value == IpVersion::V4.as_u8() => {
|
||||
14 + ((unsafe { *((start + 14) as *const u8) } & 0x0F) as usize) * 4
|
||||
}
|
||||
value if value == IpVersion::V6.as_u8() => 14 + 40,
|
||||
_ => return false,
|
||||
};
|
||||
if start + l4_offset + 13 > end {
|
||||
return false;
|
||||
}
|
||||
let doff = (unsafe { *((start + l4_offset + 12) as *const u8) } >> 4) as usize;
|
||||
if doff < 5 || doff > 15 {
|
||||
if !(5..=15).contains(&doff) {
|
||||
return false;
|
||||
}
|
||||
let payload_offset = l4_offset + doff * 4;
|
||||
@ -90,7 +94,7 @@ fn get_http_request_method(start: usize, end: usize, offset: usize) -> Option<Ht
|
||||
if start + offset + 8 > end {
|
||||
return None;
|
||||
}
|
||||
let data = unsafe { core::slice::from_raw_parts((start + offset) as *const u8, 8) };
|
||||
let data = unsafe { slice::from_raw_parts((start + offset) as *const u8, 8) };
|
||||
match &data[..4] {
|
||||
b"GET " => Some(1 << 0),
|
||||
b"POST" if &data[4..5] == b" " => Some(1 << 1),
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
use aya_ebpf::helpers::bpf_ktime_get_ns;
|
||||
use aya_ebpf::macros::map;
|
||||
use aya_ebpf::maps::{Array, LruHashMap};
|
||||
use common::define::drop_reason::*;
|
||||
use common::define::rate_limit::*;
|
||||
use common::define::setting::*;
|
||||
use common::define::tcp_flags::*;
|
||||
use common::model::ip_address::{IPv4, IPv6};
|
||||
use common::model::parsed_packet::ParsedPacket;
|
||||
use common::model::rate_limit::RateState;
|
||||
use net_guardia_abi::define::drop_reason::*;
|
||||
use net_guardia_abi::define::rate_limit::*;
|
||||
use net_guardia_abi::define::setting::*;
|
||||
use net_guardia_abi::define::tcp_flags::*;
|
||||
use net_guardia_abi::model::ip_address::{IPv4, IPv6, IpVersion};
|
||||
use net_guardia_abi::model::parsed_packet::ParsedPacket;
|
||||
use net_guardia_abi::model::rate_limit::RateState;
|
||||
use network_types::ip::IpProto;
|
||||
|
||||
#[map]
|
||||
@ -31,26 +31,24 @@ static IPV6_DNS_RATE_MAP: LruHashMap<IPv6, RateState> = LruHashMap::with_max_ent
|
||||
|
||||
pub fn should_drop(pkt: &ParsedPacket) -> Option<u8> {
|
||||
match pkt.ip_version {
|
||||
4 => ipv4_should_drop(pkt),
|
||||
6 => ipv6_should_drop(pkt),
|
||||
value if value == IpVersion::V4.as_u8() => ipv4_should_drop(pkt),
|
||||
value if value == IpVersion::V6.as_u8() => ipv6_should_drop(pkt),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn get_config(index: u32, default: u64) -> u64 {
|
||||
unsafe {
|
||||
RATE_LIMIT_CONFIG
|
||||
.get(index)
|
||||
.copied()
|
||||
.filter(|&v| v > 0)
|
||||
.unwrap_or(default)
|
||||
}
|
||||
RATE_LIMIT_CONFIG
|
||||
.get(index)
|
||||
.copied()
|
||||
.filter(|&v| v > 0)
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn is_syn_only(pkt: &ParsedPacket) -> bool {
|
||||
matches!(pkt.protocol, IpProto::Tcp) && (pkt.tcp_flags & TCP_SYN != 0) && (pkt.tcp_flags & TCP_ACK == 0)
|
||||
pkt.protocol == IpProto::Tcp as u8 && (pkt.tcp_flags & TCP_SYN != 0) && (pkt.tcp_flags & TCP_ACK == 0)
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
@ -93,40 +91,41 @@ fn ipv4_should_drop(pkt: &ParsedPacket) -> Option<u8> {
|
||||
return Some(DROP_REASON_RATE_LIMIT_PKT);
|
||||
}
|
||||
|
||||
if is_syn_only(pkt) {
|
||||
if check_rate(
|
||||
if is_syn_only(pkt)
|
||||
&& check_rate(
|
||||
&IPV4_SYN_RATE_MAP,
|
||||
&src_ip,
|
||||
now,
|
||||
window,
|
||||
get_config(CFG_SYN_RATE, DEFAULT_SYN_RATE),
|
||||
) {
|
||||
return Some(DROP_REASON_RATE_LIMIT_SYN);
|
||||
}
|
||||
)
|
||||
{
|
||||
return Some(DROP_REASON_RATE_LIMIT_SYN);
|
||||
}
|
||||
|
||||
if matches!(pkt.protocol, IpProto::Udp) {
|
||||
if check_rate(
|
||||
if pkt.protocol == IpProto::Udp as u8
|
||||
&& check_rate(
|
||||
&IPV4_UDP_RATE_MAP,
|
||||
&src_ip,
|
||||
now,
|
||||
window,
|
||||
get_config(CFG_UDP_RATE, DEFAULT_UDP_RATE),
|
||||
) {
|
||||
return Some(DROP_REASON_RATE_LIMIT_UDP);
|
||||
}
|
||||
)
|
||||
{
|
||||
return Some(DROP_REASON_RATE_LIMIT_UDP);
|
||||
}
|
||||
|
||||
if matches!(pkt.protocol, IpProto::Udp) && pkt.dst_port == 53 {
|
||||
if check_rate(
|
||||
if pkt.protocol == IpProto::Udp as u8
|
||||
&& pkt.dst_port == 53
|
||||
&& check_rate(
|
||||
&IPV4_DNS_RATE_MAP,
|
||||
&src_ip,
|
||||
now,
|
||||
window,
|
||||
get_config(CFG_DNS_RATE, DEFAULT_DNS_RATE),
|
||||
) {
|
||||
return Some(DROP_REASON_RATE_LIMIT_DNS);
|
||||
}
|
||||
)
|
||||
{
|
||||
return Some(DROP_REASON_RATE_LIMIT_DNS);
|
||||
}
|
||||
|
||||
None
|
||||
@ -148,40 +147,41 @@ fn ipv6_should_drop(pkt: &ParsedPacket) -> Option<u8> {
|
||||
return Some(DROP_REASON_RATE_LIMIT_PKT);
|
||||
}
|
||||
|
||||
if is_syn_only(pkt) {
|
||||
if check_rate(
|
||||
if is_syn_only(pkt)
|
||||
&& check_rate(
|
||||
&IPV6_SYN_RATE_MAP,
|
||||
&src_ip,
|
||||
now,
|
||||
window,
|
||||
get_config(CFG_SYN_RATE, DEFAULT_SYN_RATE),
|
||||
) {
|
||||
return Some(DROP_REASON_RATE_LIMIT_SYN);
|
||||
}
|
||||
)
|
||||
{
|
||||
return Some(DROP_REASON_RATE_LIMIT_SYN);
|
||||
}
|
||||
|
||||
if matches!(pkt.protocol, IpProto::Udp) {
|
||||
if check_rate(
|
||||
if pkt.protocol == IpProto::Udp as u8
|
||||
&& check_rate(
|
||||
&IPV6_UDP_RATE_MAP,
|
||||
&src_ip,
|
||||
now,
|
||||
window,
|
||||
get_config(CFG_UDP_RATE, DEFAULT_UDP_RATE),
|
||||
) {
|
||||
return Some(DROP_REASON_RATE_LIMIT_UDP);
|
||||
}
|
||||
)
|
||||
{
|
||||
return Some(DROP_REASON_RATE_LIMIT_UDP);
|
||||
}
|
||||
|
||||
if matches!(pkt.protocol, IpProto::Udp) && pkt.dst_port == 53 {
|
||||
if check_rate(
|
||||
if pkt.protocol == IpProto::Udp as u8
|
||||
&& pkt.dst_port == 53
|
||||
&& check_rate(
|
||||
&IPV6_DNS_RATE_MAP,
|
||||
&src_ip,
|
||||
now,
|
||||
window,
|
||||
get_config(CFG_DNS_RATE, DEFAULT_DNS_RATE),
|
||||
) {
|
||||
return Some(DROP_REASON_RATE_LIMIT_DNS);
|
||||
}
|
||||
)
|
||||
{
|
||||
return Some(DROP_REASON_RATE_LIMIT_DNS);
|
||||
}
|
||||
|
||||
None
|
||||
|
||||
@ -1,19 +1,21 @@
|
||||
#![no_std]
|
||||
#![cfg_attr(any(target_arch = "bpf", target_os = "none"), no_std)]
|
||||
#![no_main]
|
||||
mod action;
|
||||
|
||||
use aya_ebpf::bindings::xdp_action;
|
||||
use aya_ebpf::helpers::bpf_ktime_get_ns;
|
||||
use aya_ebpf::macros::{map, xdp};
|
||||
use aya_ebpf::maps::{Array, PerCpuArray, ProgramArray, RingBuf, XskMap};
|
||||
use aya_ebpf::programs::XdpContext;
|
||||
#[allow(unused_imports)]
|
||||
use aya_log_ebpf::info;
|
||||
use common::define::drop_reason::*;
|
||||
use common::define::pipeline::*;
|
||||
use common::ebpf::parsing;
|
||||
use common::ebpf::symmetric_hash::symmetric_queue_id;
|
||||
use common::model::drop_event::DropEvent;
|
||||
use common::model::parsed_packet::ParsedPacket;
|
||||
use net_guardia_abi::define::drop_reason::*;
|
||||
use net_guardia_abi::define::pipeline::*;
|
||||
use net_guardia_abi::ebpf::parsing;
|
||||
use net_guardia_abi::ebpf::symmetric_hash::symmetric_queue_id;
|
||||
use net_guardia_abi::model::drop_event::DropEvent;
|
||||
use net_guardia_abi::model::ip_address::IpVersion;
|
||||
use net_guardia_abi::model::parsed_packet::ParsedPacket;
|
||||
|
||||
use crate::action::{access_control, protocol_filter, rate_limit};
|
||||
|
||||
@ -40,30 +42,32 @@ pub fn net_guardia(ctx: XdpContext) -> u32 {
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
unsafe fn chain_next(ctx: &XdpContext, current_id: u32) {
|
||||
fn chain_next(ctx: &XdpContext, current_id: u32) {
|
||||
unsafe {
|
||||
if let Some(&next_slot) = NEXT_STAGE.get(current_id) {
|
||||
if next_slot != STAGE_NONE {
|
||||
let _ = PROGRAM_ARRAY.tail_call(ctx, next_slot);
|
||||
}
|
||||
if let Some(&next_slot) = NEXT_STAGE.get(current_id)
|
||||
&& next_slot != STAGE_NONE
|
||||
{
|
||||
let _ = PROGRAM_ARRAY.tail_call(ctx, next_slot);
|
||||
}
|
||||
let _ = PROGRAM_ARRAY.tail_call(ctx, STAGE_TRANSMISSION);
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
unsafe fn emit_drop_event(pkt: &ParsedPacket, reason: u8) {
|
||||
fn emit_drop_event(pkt: &ParsedPacket, reason: u8) {
|
||||
if let Some(mut entry) = DROP_EVENTS.reserve::<DropEvent>(0) {
|
||||
let event = entry.as_mut_ptr();
|
||||
(*event).timestamp_ns = aya_ebpf::helpers::bpf_ktime_get_ns();
|
||||
(*event).src_ip = pkt.src_ip;
|
||||
(*event).dst_ip = pkt.dst_ip;
|
||||
(*event).src_port = pkt.src_port;
|
||||
(*event).dst_port = pkt.dst_port;
|
||||
(*event).protocol = pkt.protocol as u8;
|
||||
(*event).reason = reason;
|
||||
(*event).ip_version = pkt.ip_version;
|
||||
(*event)._pad = 0;
|
||||
unsafe {
|
||||
let event = &mut *entry.as_mut_ptr();
|
||||
event.timestamp_ns = bpf_ktime_get_ns();
|
||||
event.src_ip = pkt.src_ip;
|
||||
event.dst_ip = pkt.dst_ip;
|
||||
event.src_port = pkt.src_port;
|
||||
event.dst_port = pkt.dst_port;
|
||||
event.protocol = pkt.protocol;
|
||||
event.reason = reason;
|
||||
event.ip_version = pkt.ip_version;
|
||||
event._pad = 0;
|
||||
}
|
||||
entry.submit(0);
|
||||
}
|
||||
}
|
||||
@ -73,7 +77,10 @@ unsafe fn packet_intake(ctx: &XdpContext) {
|
||||
let Some(ptr) = PARSED_PACKET.get_ptr_mut(0) else {
|
||||
return;
|
||||
};
|
||||
if parsing::parse_packet(ctx.data(), ctx.data_end(), ptr).is_ok() {
|
||||
if unsafe { parsing::parse_packet(ctx.data(), ctx.data_end(), ptr).is_some() } {
|
||||
unsafe {
|
||||
(*ptr).timestamp_ns = bpf_ktime_get_ns();
|
||||
}
|
||||
chain_next(ctx, STAGE_ENTRY);
|
||||
}
|
||||
}
|
||||
@ -97,7 +104,7 @@ unsafe fn try_access_control(ctx: &XdpContext) -> Result<u32, ()> {
|
||||
let ptr = PARSED_PACKET.get_ptr(0).ok_or(())?;
|
||||
let pkt = &*ptr;
|
||||
match pkt.ip_version {
|
||||
4 => {
|
||||
value if value == IpVersion::V4.as_u8() => {
|
||||
if access_control::ipv4_is_whitelisted(pkt) {
|
||||
let _ = PROGRAM_ARRAY.tail_call(ctx, STAGE_TRANSMISSION);
|
||||
return Err(());
|
||||
@ -111,7 +118,7 @@ unsafe fn try_access_control(ctx: &XdpContext) -> Result<u32, ()> {
|
||||
return Ok(xdp_action::XDP_DROP);
|
||||
}
|
||||
}
|
||||
6 => {
|
||||
value if value == IpVersion::V6.as_u8() => {
|
||||
if access_control::ipv6_is_whitelisted(pkt) {
|
||||
let _ = PROGRAM_ARRAY.tail_call(ctx, STAGE_TRANSMISSION);
|
||||
return Err(());
|
||||
@ -181,17 +188,17 @@ unsafe fn try_protocol_filter(ctx: &XdpContext) -> Result<u32, ()> {
|
||||
let pkt = &*ptr;
|
||||
|
||||
match pkt.ip_version {
|
||||
4 => {
|
||||
value if value == IpVersion::V4.as_u8() => {
|
||||
if protocol_filter::ipv4_service_rule_violation(start, end, pkt) {
|
||||
emit_drop_event(pkt, DROP_REASON_PROTOCOL_FILTER);
|
||||
return Ok(xdp_action::XDP_DROP);
|
||||
}
|
||||
}
|
||||
6 => {
|
||||
if protocol_filter::ipv6_service_rule_violation(start, end, pkt) {
|
||||
emit_drop_event(pkt, DROP_REASON_PROTOCOL_FILTER);
|
||||
return Ok(xdp_action::XDP_DROP);
|
||||
}
|
||||
value
|
||||
if value == IpVersion::V6.as_u8() && protocol_filter::ipv6_service_rule_violation(start, end, pkt) =>
|
||||
{
|
||||
emit_drop_event(pkt, DROP_REASON_PROTOCOL_FILTER);
|
||||
return Ok(xdp_action::XDP_DROP);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@ -218,7 +225,7 @@ pub fn transmission(ctx: XdpContext) -> u32 {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
#[cfg(all(not(test), any(target_arch = "bpf", target_os = "none")))]
|
||||
#[panic_handler]
|
||||
fn panic(_info: &core::panic::PanicInfo) -> ! {
|
||||
unsafe { core::hint::unreachable_unchecked() }
|
||||
|
||||
@ -4,9 +4,8 @@ use proc_macro::TokenStream;
|
||||
use proc_macro2::TokenStream as TokenStream2;
|
||||
use quote::{format_ident, quote};
|
||||
use syn::parse::{Parse, ParseStream};
|
||||
use syn::{Fields, Ident, ItemStruct, LitBool, LitStr, Result, Token, Type};
|
||||
|
||||
// ── Attribute parsing ──────────────────────────────────────────────
|
||||
use syn::spanned::Spanned;
|
||||
use syn::{Error, Fields, Ident, ItemStruct, LitBool, LitStr, Result, Token, Type};
|
||||
|
||||
struct StructAttr {
|
||||
default_section: Option<String>,
|
||||
@ -32,8 +31,6 @@ impl Parse for StructAttr {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Field model ────────────────────────────────────────────────────
|
||||
|
||||
enum ConfigField {
|
||||
Setting(SettingField),
|
||||
Flatten(FlattenField),
|
||||
@ -71,13 +68,16 @@ struct MappedParent {
|
||||
settings: Vec<MappedSetting>,
|
||||
}
|
||||
|
||||
// ── Parsing ────────────────────────────────────────────────────────
|
||||
|
||||
fn parse_struct_mapped_settings(input: &mut ItemStruct, default_section: &Option<String>) -> Vec<MappedSetting> {
|
||||
fn parse_struct_mapped_settings(
|
||||
input: &mut ItemStruct,
|
||||
default_section: &Option<String>,
|
||||
) -> Result<Vec<MappedSetting>> {
|
||||
let mut mapped = Vec::new();
|
||||
input.attrs.retain(|attr| {
|
||||
let mut retained = Vec::new();
|
||||
for attr in input.attrs.drain(..) {
|
||||
if !attr.path().is_ident("setting") {
|
||||
return true;
|
||||
retained.push(attr);
|
||||
continue;
|
||||
}
|
||||
let mut key = None;
|
||||
let mut default = None;
|
||||
@ -86,7 +86,7 @@ fn parse_struct_mapped_settings(input: &mut ItemStruct, default_section: &Option
|
||||
let mut section = None;
|
||||
let mut api = true;
|
||||
|
||||
let _ = attr.parse_nested_meta(|meta| {
|
||||
attr.parse_nested_meta(|meta| {
|
||||
if meta.path.is_ident("key") {
|
||||
let val: LitStr = meta.value()?.parse()?;
|
||||
key = Some(val.value());
|
||||
@ -107,12 +107,12 @@ fn parse_struct_mapped_settings(input: &mut ItemStruct, default_section: &Option
|
||||
api = val.value();
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
})?;
|
||||
|
||||
if let (Some(key), Some(default), Some(path)) = (key, default, path) {
|
||||
let (parent, sub_field) = path
|
||||
.split_once('.')
|
||||
.expect("#[setting] `path` must be `parent.sub_field`");
|
||||
.ok_or_else(|| Error::new(attr.span(), "#[setting] `path` must be `parent.sub_field`"))?;
|
||||
mapped.push(MappedSetting {
|
||||
key,
|
||||
default,
|
||||
@ -122,16 +122,18 @@ fn parse_struct_mapped_settings(input: &mut ItemStruct, default_section: &Option
|
||||
section: section.or_else(|| default_section.clone()),
|
||||
api,
|
||||
});
|
||||
false
|
||||
} else {
|
||||
true
|
||||
retained.push(attr);
|
||||
}
|
||||
});
|
||||
mapped
|
||||
}
|
||||
input.attrs = retained;
|
||||
Ok(mapped)
|
||||
}
|
||||
|
||||
fn parse_field(field: &mut syn::Field, default_section: &Option<String>) -> Option<ConfigField> {
|
||||
let idx = field.attrs.iter().position(|a| a.path().is_ident("setting"))?;
|
||||
fn parse_field(field: &mut syn::Field, default_section: &Option<String>) -> Result<Option<ConfigField>> {
|
||||
let Some(idx) = field.attrs.iter().position(|a| a.path().is_ident("setting")) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let attr = field.attrs.remove(idx);
|
||||
|
||||
let mut is_flatten = false;
|
||||
@ -161,29 +163,32 @@ fn parse_field(field: &mut syn::Field, default_section: &Option<String>) -> Opti
|
||||
api = val.value();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap_or_else(|e| panic!("invalid #[setting]: {e}"));
|
||||
})?;
|
||||
|
||||
let ident = field.ident.clone().expect("named field");
|
||||
let ident = field
|
||||
.ident
|
||||
.clone()
|
||||
.ok_or_else(|| Error::new(field.span(), "#[setting] only supports named fields"))?;
|
||||
let ty = field.ty.clone();
|
||||
|
||||
if is_flatten {
|
||||
return Some(ConfigField::Flatten(FlattenField { ident, ty }));
|
||||
return Ok(Some(ConfigField::Flatten(FlattenField { ident, ty })));
|
||||
}
|
||||
|
||||
Some(ConfigField::Setting(SettingField {
|
||||
let key = key.ok_or_else(|| Error::new(attr.span(), "#[setting] requires `key`"))?;
|
||||
let default = default.ok_or_else(|| Error::new(attr.span(), "#[setting] requires `default`"))?;
|
||||
|
||||
Ok(Some(ConfigField::Setting(SettingField {
|
||||
ident,
|
||||
ty,
|
||||
key: key.expect("#[setting] requires `key`"),
|
||||
default: default.expect("#[setting] requires `default`"),
|
||||
key,
|
||||
default,
|
||||
default_debug,
|
||||
section: section.or_else(|| default_section.clone()),
|
||||
api,
|
||||
}))
|
||||
})))
|
||||
}
|
||||
|
||||
// ── Type detection ─────────────────────────────────────────────────
|
||||
|
||||
fn is_type(ty: &Type, name: &str) -> bool {
|
||||
matches!(ty, Type::Path(tp) if tp.path.is_ident(name))
|
||||
}
|
||||
@ -197,40 +202,47 @@ fn is_vec_string(ty: &Type) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
// ── Code generation: defaults() ────────────────────────────────────
|
||||
fn parse_default_tokens(ty: &Type, default: &str) -> Result<TokenStream2> {
|
||||
default.parse::<TokenStream2>().map_err(|err| {
|
||||
Error::new(
|
||||
ty.span(),
|
||||
format!("invalid #[setting] default literal `{default}`: {err}"),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn make_default_val(ty: &Type, default: &str) -> TokenStream2 {
|
||||
fn make_default_val(ty: &Type, default: &str) -> Result<TokenStream2> {
|
||||
if is_type(ty, "String") {
|
||||
quote! { #default.to_string() }
|
||||
Ok(quote! { #default.to_string() })
|
||||
} else if is_type(ty, "bool") {
|
||||
let val = default == "true" || default == "1";
|
||||
quote! { #val }
|
||||
Ok(quote! { #val })
|
||||
} else if is_vec_string(ty) {
|
||||
if default.is_empty() {
|
||||
quote! { Vec::new() }
|
||||
Ok(quote! { Vec::new() })
|
||||
} else {
|
||||
let items: Vec<&str> = default.split(',').map(|v| v.trim()).collect();
|
||||
quote! { vec![#(#items.to_string()),*] }
|
||||
Ok(quote! { vec![#(#items.to_string()),*] })
|
||||
}
|
||||
} else {
|
||||
// SAFETY: literal default, validated by tests
|
||||
quote! { #default.parse().unwrap() }
|
||||
let value = parse_default_tokens(ty, default)?;
|
||||
Ok(quote! { #value })
|
||||
}
|
||||
}
|
||||
|
||||
fn gen_default(f: &SettingField) -> TokenStream2 {
|
||||
fn gen_default(f: &SettingField) -> Result<TokenStream2> {
|
||||
let ident = &f.ident;
|
||||
let ty = &f.ty;
|
||||
|
||||
match &f.default_debug {
|
||||
Some(dbg) => {
|
||||
let release_val = make_default_val(ty, &f.default);
|
||||
let debug_val = make_default_val(ty, dbg);
|
||||
quote! { #ident: if cfg!(debug_assertions) { #debug_val } else { #release_val } }
|
||||
let release_val = make_default_val(ty, &f.default)?;
|
||||
let debug_val = make_default_val(ty, dbg)?;
|
||||
Ok(quote! { #ident: if cfg!(debug_assertions) { #debug_val } else { #release_val } })
|
||||
}
|
||||
None => {
|
||||
let val = make_default_val(ty, &f.default);
|
||||
quote! { #ident: #val }
|
||||
let val = make_default_val(ty, &f.default)?;
|
||||
Ok(quote! { #ident: #val })
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -241,7 +253,7 @@ fn gen_flatten_default(f: &FlattenField) -> TokenStream2 {
|
||||
quote! { #ident: #ty::defaults() }
|
||||
}
|
||||
|
||||
fn gen_mapped_default(mp: &MappedParent) -> TokenStream2 {
|
||||
fn gen_mapped_default(mp: &MappedParent) -> Result<TokenStream2> {
|
||||
let ident = &mp.ident;
|
||||
let ty = &mp.ty;
|
||||
let sub_fields: Vec<_> = mp
|
||||
@ -251,63 +263,64 @@ fn gen_mapped_default(mp: &MappedParent) -> TokenStream2 {
|
||||
let sub = format_ident!("{}", s.sub_field);
|
||||
let val: TokenStream2 = match &s.default_debug {
|
||||
Some(dbg) => {
|
||||
let release = &s.default;
|
||||
// SAFETY: literal default, validated by tests
|
||||
quote! { if cfg!(debug_assertions) { #dbg.parse().unwrap() } else { #release.parse().unwrap() } }
|
||||
}
|
||||
None => {
|
||||
let default = &s.default;
|
||||
// SAFETY: literal default, validated by tests
|
||||
quote! { #default.parse().unwrap() }
|
||||
let debug = parse_default_tokens(&mp.ty, dbg)?;
|
||||
let release = parse_default_tokens(&mp.ty, &s.default)?;
|
||||
quote! { if cfg!(debug_assertions) { #debug } else { #release } }
|
||||
}
|
||||
None => parse_default_tokens(&mp.ty, &s.default)?,
|
||||
};
|
||||
quote! { #sub: #val }
|
||||
Ok(quote! { #sub: #val })
|
||||
})
|
||||
.collect();
|
||||
quote! { #ident: #ty { #(#sub_fields,)* } }
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
Ok(quote! { #ident: #ty { #(#sub_fields,)* } })
|
||||
}
|
||||
|
||||
// ── Code generation: from_config_repo() ───────────────────────────────
|
||||
|
||||
fn gen_override(f: &SettingField) -> TokenStream2 {
|
||||
fn gen_apply_value(f: &SettingField) -> TokenStream2 {
|
||||
let ident = &f.ident;
|
||||
let key = &f.key;
|
||||
let ty = &f.ty;
|
||||
|
||||
if is_type(ty, "String") {
|
||||
quote! {
|
||||
crate::domain::common::config::helpers::override_string_nonempty(
|
||||
&mut cfg.#ident, repo, #key,
|
||||
).await?;
|
||||
if let Some(v) = values.get(#key)
|
||||
&& !v.is_empty()
|
||||
{
|
||||
self.#ident = v.clone();
|
||||
}
|
||||
}
|
||||
} else if is_type(ty, "bool") {
|
||||
quote! {
|
||||
crate::domain::common::config::helpers::override_bool(
|
||||
&mut cfg.#ident, repo, #key,
|
||||
).await?;
|
||||
if let Some(v) = values.get(#key) {
|
||||
self.#ident = v == "true" || v == "1";
|
||||
}
|
||||
}
|
||||
} else if is_vec_string(ty) {
|
||||
quote! {
|
||||
crate::domain::common::config::helpers::override_csv(
|
||||
&mut cfg.#ident, repo, #key,
|
||||
).await?;
|
||||
if let Some(v) = values.get(#key) {
|
||||
self.#ident = if v.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
v.split(',').map(|s| s.trim().to_string()).collect()
|
||||
};
|
||||
}
|
||||
}
|
||||
} else {
|
||||
quote! {
|
||||
crate::domain::common::config::helpers::override_parsed(
|
||||
&mut cfg.#ident, repo, #key,
|
||||
).await?;
|
||||
if let Some(v) = values.get(#key)
|
||||
&& let Ok(parsed) = v.parse()
|
||||
{
|
||||
self.#ident = parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn gen_flatten_override(f: &FlattenField) -> TokenStream2 {
|
||||
fn gen_flatten_apply(f: &FlattenField) -> TokenStream2 {
|
||||
let ident = &f.ident;
|
||||
let ty = &f.ty;
|
||||
quote! { cfg.#ident = #ty::from_config_repo(repo).await?; }
|
||||
quote! { self.#ident.apply_config_values(values); }
|
||||
}
|
||||
|
||||
fn gen_mapped_overrides(mp: &MappedParent) -> TokenStream2 {
|
||||
fn gen_mapped_apply(mp: &MappedParent) -> TokenStream2 {
|
||||
let parent = &mp.ident;
|
||||
let calls: Vec<_> = mp
|
||||
.settings
|
||||
@ -316,40 +329,37 @@ fn gen_mapped_overrides(mp: &MappedParent) -> TokenStream2 {
|
||||
let sub = format_ident!("{}", s.sub_field);
|
||||
let key = &s.key;
|
||||
quote! {
|
||||
crate::domain::common::config::helpers::override_parsed(
|
||||
&mut cfg.#parent.#sub, repo, #key,
|
||||
).await?;
|
||||
if let Some(v) = values.get(#key)
|
||||
&& let Ok(parsed) = v.parse()
|
||||
{
|
||||
self.#parent.#sub = parsed;
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
quote! { #(#calls)* }
|
||||
}
|
||||
|
||||
// ── Code generation: seed_config_defaults() ───────────────────────────────
|
||||
|
||||
fn gen_seed(f: &SettingField) -> TokenStream2 {
|
||||
fn gen_default_setting(f: &SettingField) -> TokenStream2 {
|
||||
let key = &f.key;
|
||||
let default = &f.default;
|
||||
|
||||
match &f.default_debug {
|
||||
Some(dbg) => quote! {
|
||||
crate::domain::common::config::helpers::seed_key(
|
||||
repo, #key,
|
||||
if cfg!(debug_assertions) { #dbg } else { #default },
|
||||
).await?;
|
||||
settings.push((#key, if cfg!(debug_assertions) { #dbg.to_string() } else { #default.to_string() }));
|
||||
},
|
||||
None => quote! {
|
||||
crate::domain::common::config::helpers::seed_key(repo, #key, #default).await?;
|
||||
settings.push((#key, #default.to_string()));
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn gen_flatten_seed(f: &FlattenField) -> TokenStream2 {
|
||||
fn gen_flatten_default_settings(f: &FlattenField) -> TokenStream2 {
|
||||
let ty = &f.ty;
|
||||
quote! { #ty::seed_config_defaults(repo).await?; }
|
||||
quote! { settings.extend(#ty::default_settings()); }
|
||||
}
|
||||
|
||||
fn gen_mapped_seeds(mp: &MappedParent) -> TokenStream2 {
|
||||
fn gen_mapped_default_settings(mp: &MappedParent) -> TokenStream2 {
|
||||
let calls: Vec<_> = mp
|
||||
.settings
|
||||
.iter()
|
||||
@ -358,13 +368,10 @@ fn gen_mapped_seeds(mp: &MappedParent) -> TokenStream2 {
|
||||
let default = &s.default;
|
||||
match &s.default_debug {
|
||||
Some(dbg) => quote! {
|
||||
crate::domain::common::config::helpers::seed_key(
|
||||
repo, #key,
|
||||
if cfg!(debug_assertions) { #dbg } else { #default },
|
||||
).await?;
|
||||
settings.push((#key, if cfg!(debug_assertions) { #dbg.to_string() } else { #default.to_string() }));
|
||||
},
|
||||
None => quote! {
|
||||
crate::domain::common::config::helpers::seed_key(repo, #key, #default).await?;
|
||||
settings.push((#key, #default.to_string()));
|
||||
},
|
||||
}
|
||||
})
|
||||
@ -372,8 +379,6 @@ fn gen_mapped_seeds(mp: &MappedParent) -> TokenStream2 {
|
||||
quote! { #(#calls)* }
|
||||
}
|
||||
|
||||
// ── Code generation: API_KEYS ──────────────────────────────────────
|
||||
|
||||
fn collect_api_keys(fields: &[ConfigField]) -> Vec<(&str, &str)> {
|
||||
let mut keys = Vec::new();
|
||||
for f in fields {
|
||||
@ -421,8 +426,6 @@ fn gen_keys_consts(fields: &[ConfigField]) -> TokenStream2 {
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ── Code generation: api_values() ─────────────────────────────────
|
||||
|
||||
fn value_to_string_expr(ty: &Type, expr: TokenStream2) -> TokenStream2 {
|
||||
if is_type(ty, "String") {
|
||||
quote! { #expr.clone() }
|
||||
@ -471,13 +474,14 @@ fn gen_api_values(fields: &[ConfigField]) -> TokenStream2 {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Entry point ────────────────────────────────────────────────────
|
||||
|
||||
pub fn config_settings_impl(attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||
let struct_attr = syn::parse_macro_input!(attr as StructAttr);
|
||||
let mut input = syn::parse_macro_input!(item as ItemStruct);
|
||||
|
||||
let mapped_settings = parse_struct_mapped_settings(&mut input, &struct_attr.default_section);
|
||||
let mapped_settings = match parse_struct_mapped_settings(&mut input, &struct_attr.default_section) {
|
||||
Ok(settings) => settings,
|
||||
Err(err) => return err.to_compile_error().into(),
|
||||
};
|
||||
|
||||
let mut mapped_groups: BTreeMap<String, Vec<MappedSetting>> = BTreeMap::new();
|
||||
for ms in mapped_settings {
|
||||
@ -486,21 +490,34 @@ pub fn config_settings_impl(attr: TokenStream, item: TokenStream) -> TokenStream
|
||||
|
||||
let fields = match &mut input.fields {
|
||||
Fields::Named(f) => f,
|
||||
_ => panic!("config_settings only supports named fields"),
|
||||
_ => {
|
||||
return Error::new(input.span(), "config_settings only supports named fields")
|
||||
.to_compile_error()
|
||||
.into();
|
||||
}
|
||||
};
|
||||
|
||||
let mut config_fields = Vec::new();
|
||||
for field in &mut fields.named {
|
||||
let field_name = field.ident.as_ref().expect("named field").to_string();
|
||||
let Some(field_ident) = field.ident.clone() else {
|
||||
return Error::new(field.span(), "config_settings only supports named fields")
|
||||
.to_compile_error()
|
||||
.into();
|
||||
};
|
||||
let field_name = field_ident.to_string();
|
||||
|
||||
if let Some(settings) = mapped_groups.remove(&field_name) {
|
||||
config_fields.push(ConfigField::MappedParent(MappedParent {
|
||||
ident: field.ident.clone().unwrap(),
|
||||
ident: field_ident,
|
||||
ty: field.ty.clone(),
|
||||
settings,
|
||||
}));
|
||||
} else if let Some(cf) = parse_field(field, &struct_attr.default_section) {
|
||||
config_fields.push(cf);
|
||||
} else {
|
||||
match parse_field(field, &struct_attr.default_section) {
|
||||
Ok(Some(cf)) => config_fields.push(cf),
|
||||
Ok(None) => {}
|
||||
Err(err) => return err.to_compile_error().into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -508,30 +525,34 @@ pub fn config_settings_impl(attr: TokenStream, item: TokenStream) -> TokenStream
|
||||
let keys_consts = gen_keys_consts(&config_fields);
|
||||
let api_values = gen_api_values(&config_fields);
|
||||
|
||||
let default_fields: Vec<_> = config_fields
|
||||
let default_fields: Vec<_> = match config_fields
|
||||
.iter()
|
||||
.map(|f| match f {
|
||||
ConfigField::Setting(s) => gen_default(s),
|
||||
ConfigField::Flatten(s) => gen_flatten_default(s),
|
||||
ConfigField::Flatten(s) => Ok(gen_flatten_default(s)),
|
||||
ConfigField::MappedParent(mp) => gen_mapped_default(mp),
|
||||
})
|
||||
.collect();
|
||||
.collect::<Result<Vec<_>>>()
|
||||
{
|
||||
Ok(fields) => fields,
|
||||
Err(err) => return err.to_compile_error().into(),
|
||||
};
|
||||
|
||||
let override_calls: Vec<_> = config_fields
|
||||
let apply_calls: Vec<_> = config_fields
|
||||
.iter()
|
||||
.map(|f| match f {
|
||||
ConfigField::Setting(s) => gen_override(s),
|
||||
ConfigField::Flatten(s) => gen_flatten_override(s),
|
||||
ConfigField::MappedParent(mp) => gen_mapped_overrides(mp),
|
||||
ConfigField::Setting(s) => gen_apply_value(s),
|
||||
ConfigField::Flatten(s) => gen_flatten_apply(s),
|
||||
ConfigField::MappedParent(mp) => gen_mapped_apply(mp),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let seed_calls: Vec<_> = config_fields
|
||||
let default_setting_calls: Vec<_> = config_fields
|
||||
.iter()
|
||||
.map(|f| match f {
|
||||
ConfigField::Setting(s) => gen_seed(s),
|
||||
ConfigField::Flatten(s) => gen_flatten_seed(s),
|
||||
ConfigField::MappedParent(mp) => gen_mapped_seeds(mp),
|
||||
ConfigField::Setting(s) => gen_default_setting(s),
|
||||
ConfigField::Flatten(s) => gen_flatten_default_settings(s),
|
||||
ConfigField::MappedParent(mp) => gen_mapped_default_settings(mp),
|
||||
})
|
||||
.collect();
|
||||
|
||||
@ -548,19 +569,20 @@ pub fn config_settings_impl(attr: TokenStream, item: TokenStream) -> TokenStream
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn from_config_repo(
|
||||
repo: &dyn crate::interface::config_repo::ConfigRepo,
|
||||
) -> Result<Self, crate::domain::common::error::Error> {
|
||||
pub fn from_config_values(values: &super::ConfigValues) -> Self {
|
||||
let mut cfg = Self::defaults();
|
||||
#(#override_calls)*
|
||||
Ok(cfg)
|
||||
cfg.apply_config_values(values);
|
||||
cfg
|
||||
}
|
||||
|
||||
pub async fn seed_config_defaults(
|
||||
repo: &dyn crate::interface::config_repo::ConfigRepo,
|
||||
) -> Result<(), crate::domain::common::error::Error> {
|
||||
#(#seed_calls)*
|
||||
Ok(())
|
||||
pub fn apply_config_values(&mut self, values: &super::ConfigValues) {
|
||||
#(#apply_calls)*
|
||||
}
|
||||
|
||||
pub fn default_settings() -> Vec<(&'static str, String)> {
|
||||
let mut settings = Vec::new();
|
||||
#(#default_setting_calls)*
|
||||
settings
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
187
macros/src/fallible.rs
Normal file
@ -0,0 +1,187 @@
|
||||
use proc_macro::TokenStream;
|
||||
use quote::quote;
|
||||
use syn::parse::{Parse, ParseStream};
|
||||
use syn::spanned::Spanned;
|
||||
use syn::{Attribute, Error, Ident, LitStr, Result, Token, Type};
|
||||
|
||||
struct FallibleVariant {
|
||||
attributes: Vec<Attribute>,
|
||||
error_msg: LitStr,
|
||||
name: Ident,
|
||||
fields: Vec<(Ident, Type)>,
|
||||
}
|
||||
|
||||
impl FallibleVariant {
|
||||
fn has_no_source(&self) -> bool {
|
||||
self.attributes.iter().any(|attr| attr.path().is_ident("no_source"))
|
||||
}
|
||||
|
||||
fn should_generate_constructor(&self) -> bool {
|
||||
if self.has_no_source() {
|
||||
!self.fields.is_empty()
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct FallibleInput {
|
||||
enum_name: Ident,
|
||||
variants: Vec<FallibleVariant>,
|
||||
}
|
||||
|
||||
impl Parse for FallibleInput {
|
||||
fn parse(input: ParseStream) -> Result<Self> {
|
||||
let enum_name = input.parse::<Ident>()?;
|
||||
|
||||
let content;
|
||||
syn::braced!(content in input);
|
||||
|
||||
let mut variants = Vec::new();
|
||||
|
||||
while !content.is_empty() {
|
||||
let mut attributes = Vec::new();
|
||||
|
||||
while content.peek(Token![#]) {
|
||||
attributes.push(content.call(Attribute::parse_outer)?);
|
||||
}
|
||||
|
||||
let attributes: Vec<_> = attributes.into_iter().flatten().collect();
|
||||
|
||||
let error_attr = attributes
|
||||
.iter()
|
||||
.find(|attr| attr.path().is_ident("error"))
|
||||
.ok_or_else(|| Error::new(content.span(), "Missing #[error] attribute"))?;
|
||||
|
||||
let error_msg = match &error_attr.meta {
|
||||
syn::Meta::List(list) => syn::parse2::<LitStr>(list.tokens.clone())?,
|
||||
_ => {
|
||||
return Err(Error::new(error_attr.span(), "Invalid error attribute format"));
|
||||
}
|
||||
};
|
||||
|
||||
let name = content.parse::<Ident>()?;
|
||||
|
||||
let mut fields = Vec::new();
|
||||
if content.peek(syn::token::Brace) {
|
||||
let fields_content;
|
||||
syn::braced!(fields_content in content);
|
||||
|
||||
while !fields_content.is_empty() {
|
||||
let field_name = fields_content.parse::<Ident>()?;
|
||||
fields_content.parse::<Token![:]>()?;
|
||||
let field_type = fields_content.parse::<Type>()?;
|
||||
fields.push((field_name, field_type));
|
||||
|
||||
if !fields_content.is_empty() {
|
||||
fields_content.parse::<Token![,]>()?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !content.is_empty() {
|
||||
content.parse::<Token![,]>()?;
|
||||
}
|
||||
|
||||
variants.push(FallibleVariant {
|
||||
attributes,
|
||||
error_msg,
|
||||
name,
|
||||
fields,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(FallibleInput { enum_name, variants })
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fallible_impl(input: TokenStream) -> TokenStream {
|
||||
let input = syn::parse_macro_input!(input as FallibleInput);
|
||||
|
||||
let enum_name = &input.enum_name;
|
||||
let variants = &input.variants;
|
||||
|
||||
let enum_variants = variants.iter().map(|variant| {
|
||||
let name = &variant.name;
|
||||
let error_msg = &variant.error_msg;
|
||||
let fields = &variant.fields;
|
||||
|
||||
let field_definitions = fields.iter().map(|(name, ty)| {
|
||||
quote! { #name: #ty }
|
||||
});
|
||||
|
||||
if variant.has_no_source() {
|
||||
if variant.fields.is_empty() {
|
||||
quote! {
|
||||
#[error(#error_msg)]
|
||||
#name
|
||||
}
|
||||
} else {
|
||||
quote! {
|
||||
#[error(#error_msg)]
|
||||
#name { #(#field_definitions,)* }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
quote! {
|
||||
#[error(#error_msg)]
|
||||
#name {
|
||||
#(#field_definitions,)*
|
||||
err: String
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let constructors = variants.iter().filter_map(|variant| {
|
||||
if !variant.should_generate_constructor() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let name = &variant.name;
|
||||
let fields = &variant.fields;
|
||||
|
||||
let params = fields.iter().map(|(field_name, field_type)| {
|
||||
quote! { #field_name: impl Into<#field_type> }
|
||||
});
|
||||
|
||||
let field_assignments = fields.iter().map(|(field_name, _)| {
|
||||
quote! { #field_name: #field_name.into() }
|
||||
});
|
||||
|
||||
if variant.has_no_source() {
|
||||
Some(quote! {
|
||||
#[allow(non_snake_case)]
|
||||
pub fn #name(#(#params),*) -> Self {
|
||||
Self::#name {
|
||||
#(#field_assignments,)*
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
Some(quote! {
|
||||
#[allow(non_snake_case)]
|
||||
pub fn #name(#(#params,)* source: impl std::fmt::Display) -> Self {
|
||||
Self::#name {
|
||||
#(#field_assignments,)*
|
||||
err: source.to_string()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
let expanded = quote! {
|
||||
#[allow(dead_code, clippy::enum_variant_names)]
|
||||
#[derive(Debug, Clone, thiserror::Error)]
|
||||
pub enum #enum_name {
|
||||
#(#enum_variants,)*
|
||||
}
|
||||
|
||||
impl #enum_name {
|
||||
#(#constructors)*
|
||||
}
|
||||
};
|
||||
|
||||
TokenStream::from(expanded)
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
mod config;
|
||||
mod error_enum;
|
||||
mod fallible;
|
||||
mod log;
|
||||
mod loggable;
|
||||
mod traceable;
|
||||
@ -11,6 +12,11 @@ pub fn config_settings(attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||
config::config_settings_impl(attr, item)
|
||||
}
|
||||
|
||||
#[proc_macro]
|
||||
pub fn fallible(input: TokenStream) -> TokenStream {
|
||||
fallible::fallible_impl(input)
|
||||
}
|
||||
|
||||
#[proc_macro]
|
||||
pub fn log(input: TokenStream) -> TokenStream {
|
||||
log::log_impl(input)
|
||||
|
||||
@ -1,17 +0,0 @@
|
||||
[package]
|
||||
name = "mcp-server"
|
||||
version = "1.0.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
reqwest = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
clap = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
|
||||
[[bin]]
|
||||
name = "netguardia-mcp"
|
||||
path = "src/main.rs"
|
||||
@ -1,314 +0,0 @@
|
||||
use std::io::{self, BufRead, Write};
|
||||
use std::time::Duration;
|
||||
|
||||
use clap::Parser;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// NetGuardia MCP Server — thin proxy to the NetGuardia HTTP API.
|
||||
/// Communicates via stdin/stdout using the MCP JSON-RPC protocol.
|
||||
#[derive(Parser)]
|
||||
#[command(name = "netguardia-mcp", about = "NetGuardia MCP Server")]
|
||||
struct Args {
|
||||
/// NetGuardia API base URL
|
||||
#[arg(long, default_value = "http://127.0.0.1:8080")]
|
||||
api_url: String,
|
||||
|
||||
/// API key for authentication (prefer NETGUARDIA_API_KEY env var)
|
||||
#[arg(long)]
|
||||
api_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct JsonRpcRequest {
|
||||
jsonrpc: String,
|
||||
id: Option<Value>,
|
||||
method: String,
|
||||
#[serde(default)]
|
||||
params: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct JsonRpcResponse {
|
||||
jsonrpc: String,
|
||||
id: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
result: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<JsonRpcError>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct JsonRpcError {
|
||||
code: i64,
|
||||
message: String,
|
||||
}
|
||||
|
||||
struct McpServer {
|
||||
client: Client,
|
||||
api_url: String,
|
||||
api_key: String,
|
||||
}
|
||||
|
||||
impl McpServer {
|
||||
fn new(api_url: String, api_key: String) -> Self {
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.expect("Failed to create HTTP client");
|
||||
Self {
|
||||
client,
|
||||
api_url,
|
||||
api_key,
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_request(&self, req: JsonRpcRequest) -> JsonRpcResponse {
|
||||
match req.method.as_str() {
|
||||
"initialize" => self.handle_initialize(req.id),
|
||||
"tools/list" => self.handle_tools_list(req.id),
|
||||
"tools/call" => self.handle_tool_call(req.id, req.params).await,
|
||||
_ => JsonRpcResponse {
|
||||
jsonrpc: "2.0".into(),
|
||||
id: req.id,
|
||||
result: None,
|
||||
error: Some(JsonRpcError {
|
||||
code: -32601,
|
||||
message: "Method not found".into(),
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_initialize(&self, id: Option<Value>) -> JsonRpcResponse {
|
||||
JsonRpcResponse {
|
||||
jsonrpc: "2.0".into(),
|
||||
id,
|
||||
result: Some(serde_json::json!({
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": { "tools": {} },
|
||||
"serverInfo": {
|
||||
"name": "netguardia-mcp",
|
||||
"version": "0.1.0"
|
||||
}
|
||||
})),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_tools_list(&self, id: Option<Value>) -> JsonRpcResponse {
|
||||
let tools = serde_json::json!({
|
||||
"tools": [
|
||||
{ "name": "get_health", "description": "System health status (CPU, memory, uptime, eBPF status)", "inputSchema": { "type": "object", "properties": {} } },
|
||||
{ "name": "get_stats", "description": "Traffic statistics summary", "inputSchema": { "type": "object", "properties": {} } },
|
||||
{ "name": "list_alerts", "description": "Recent threat alerts with details", "inputSchema": { "type": "object", "properties": { "limit": { "type": "integer", "default": 20 } } } },
|
||||
{ "name": "list_blocked_ips", "description": "Currently blocked IPs (manual + auto)", "inputSchema": { "type": "object", "properties": {} } },
|
||||
{ "name": "get_geo_stats", "description": "List GeoIP blocked countries", "inputSchema": { "type": "object", "properties": {} } },
|
||||
{ "name": "get_flow_summary", "description": "Top talkers, protocols, ports", "inputSchema": { "type": "object", "properties": {} } },
|
||||
{ "name": "get_enforce_mode", "description": "Current mode (monitor/enforce)", "inputSchema": { "type": "object", "properties": {} } },
|
||||
{ "name": "list_playbooks", "description": "SOAR playbook configurations", "inputSchema": { "type": "object", "properties": {} } },
|
||||
{ "name": "generate_report", "description": "Generate security summary report", "inputSchema": { "type": "object", "properties": {} } },
|
||||
{ "name": "block_ip", "description": "Add IP to blacklist", "inputSchema": { "type": "object", "properties": { "ip": { "type": "string" } }, "required": ["ip"] } },
|
||||
{ "name": "unblock_ip", "description": "Remove IP from blacklist", "inputSchema": { "type": "object", "properties": { "ip": { "type": "string" } }, "required": ["ip"] } },
|
||||
{ "name": "set_enforce_mode", "description": "Toggle monitor/enforce mode", "inputSchema": { "type": "object", "properties": { "mode": { "type": "string", "enum": ["monitor", "enforce"] } }, "required": ["mode"] } },
|
||||
{ "name": "add_dns_filter", "description": "Add domain to DNS blacklist", "inputSchema": { "type": "object", "properties": { "domain": { "type": "string" } }, "required": ["domain"] } },
|
||||
{ "name": "add_geo_block", "description": "Block country by code", "inputSchema": { "type": "object", "properties": { "country_code": { "type": "string" } }, "required": ["country_code"] } },
|
||||
]
|
||||
});
|
||||
|
||||
JsonRpcResponse {
|
||||
jsonrpc: "2.0".into(),
|
||||
id,
|
||||
result: Some(tools),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_tool_call(&self, id: Option<Value>, params: Value) -> JsonRpcResponse {
|
||||
let tool_name = params.get("name").and_then(|n| n.as_str()).unwrap_or("");
|
||||
let arguments = params
|
||||
.get("arguments")
|
||||
.cloned()
|
||||
.unwrap_or(Value::Object(Default::default()));
|
||||
|
||||
let (method, path, body): (&str, String, Option<Value>) = match tool_name {
|
||||
"get_health" => ("GET", "/api/health/status".into(), None),
|
||||
"get_stats" => ("GET", "/api/stats/summary".into(), None),
|
||||
"list_alerts" => ("GET", "/api/soar/executions".into(), None),
|
||||
"list_blocked_ips" => ("GET", "/api/soar/blocks".into(), None),
|
||||
"get_geo_stats" => ("GET", "/api/acl/geo/blocked".into(), None),
|
||||
"get_flow_summary" => ("GET", "/api/stats/flows".into(), None),
|
||||
"get_enforce_mode" => ("GET", "/api/system/enforce-mode".into(), None),
|
||||
"list_playbooks" => ("GET", "/api/soar/playbooks".into(), None),
|
||||
"generate_report" => ("POST", "/api/report/generate".into(), None),
|
||||
"block_ip" => {
|
||||
let ip = arguments.get("ip").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let is_v6 = ip.contains(':');
|
||||
let ip_ver = if is_v6 { "ipv6" } else { "ipv4" };
|
||||
let addr = if is_v6 {
|
||||
format!("[{}]:0", ip)
|
||||
} else {
|
||||
format!("{}:0", ip)
|
||||
};
|
||||
(
|
||||
"PUT",
|
||||
format!("/api/acl/{}/source/blacklist", ip_ver),
|
||||
Some(Value::String(addr)),
|
||||
)
|
||||
}
|
||||
"unblock_ip" => {
|
||||
let ip = arguments.get("ip").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let is_v6 = ip.contains(':');
|
||||
let ip_ver = if is_v6 { "ipv6" } else { "ipv4" };
|
||||
let addr = if is_v6 {
|
||||
format!("[{}]:0", ip)
|
||||
} else {
|
||||
format!("{}:0", ip)
|
||||
};
|
||||
(
|
||||
"DELETE",
|
||||
format!("/api/acl/{}/source/blacklist", ip_ver),
|
||||
Some(Value::String(addr)),
|
||||
)
|
||||
}
|
||||
"set_enforce_mode" => {
|
||||
let mode = arguments.get("mode").and_then(|v| v.as_str()).unwrap_or("monitor");
|
||||
(
|
||||
"PUT",
|
||||
"/api/system/enforce-mode".into(),
|
||||
Some(serde_json::json!({"mode": mode})),
|
||||
)
|
||||
}
|
||||
"add_dns_filter" => {
|
||||
let domain = arguments.get("domain").and_then(|v| v.as_str()).unwrap_or("");
|
||||
(
|
||||
"PUT",
|
||||
"/api/filter/dns/blacklist".into(),
|
||||
Some(serde_json::json!({"domains": [domain]})),
|
||||
)
|
||||
}
|
||||
"add_geo_block" => {
|
||||
let code = arguments.get("country_code").and_then(|v| v.as_str()).unwrap_or("");
|
||||
(
|
||||
"PUT",
|
||||
"/api/acl/geo/block".into(),
|
||||
Some(serde_json::json!({"country_codes": [code]})),
|
||||
)
|
||||
}
|
||||
_ => {
|
||||
return JsonRpcResponse {
|
||||
jsonrpc: "2.0".into(),
|
||||
id,
|
||||
result: None,
|
||||
error: Some(JsonRpcError {
|
||||
code: -32602,
|
||||
message: format!("Unknown tool: {}", tool_name),
|
||||
}),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let url = format!("{}{}", self.api_url, path);
|
||||
let mut req_builder = match method {
|
||||
"PUT" => self.client.put(&url),
|
||||
"DELETE" => self.client.delete(&url),
|
||||
"POST" => self.client.post(&url),
|
||||
_ => self.client.get(&url),
|
||||
};
|
||||
|
||||
req_builder = req_builder.header("X-API-Key", &self.api_key);
|
||||
|
||||
if let Some(body) = body {
|
||||
req_builder = req_builder.json(&body);
|
||||
}
|
||||
|
||||
match req_builder.send().await {
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
let body: Value = resp.json().await.unwrap_or(Value::Null);
|
||||
|
||||
if status.is_success() {
|
||||
JsonRpcResponse {
|
||||
jsonrpc: "2.0".into(),
|
||||
id,
|
||||
result: Some(serde_json::json!({
|
||||
"content": [{ "type": "text", "text": serde_json::to_string_pretty(&body).unwrap_or_default() }]
|
||||
})),
|
||||
error: None,
|
||||
}
|
||||
} else {
|
||||
JsonRpcResponse {
|
||||
jsonrpc: "2.0".into(),
|
||||
id,
|
||||
result: Some(serde_json::json!({
|
||||
"content": [{ "type": "text", "text": format!("API error ({}): {}", status, serde_json::to_string(&body).unwrap_or_default()) }],
|
||||
"isError": true
|
||||
})),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => JsonRpcResponse {
|
||||
jsonrpc: "2.0".into(),
|
||||
id,
|
||||
result: Some(serde_json::json!({
|
||||
"content": [{ "type": "text", "text": format!("Connection error: {}", e) }],
|
||||
"isError": true
|
||||
})),
|
||||
error: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let args = Args::parse();
|
||||
|
||||
let api_key = args
|
||||
.api_key
|
||||
.or_else(|| std::env::var("NETGUARDIA_API_KEY").ok())
|
||||
.unwrap_or_else(|| {
|
||||
eprintln!("Error: No API key provided. Set NETGUARDIA_API_KEY env var or use --api-key flag.");
|
||||
std::process::exit(1);
|
||||
});
|
||||
|
||||
let server = McpServer::new(args.api_url, api_key);
|
||||
|
||||
let stdin = io::stdin();
|
||||
let mut stdout = io::stdout();
|
||||
|
||||
for line in stdin.lock().lines() {
|
||||
let line = match line {
|
||||
Ok(l) => l,
|
||||
Err(_) => break,
|
||||
};
|
||||
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let req: JsonRpcRequest = match serde_json::from_str(&line) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
let err_resp = JsonRpcResponse {
|
||||
jsonrpc: "2.0".into(),
|
||||
id: None,
|
||||
result: None,
|
||||
error: Some(JsonRpcError {
|
||||
code: -32700,
|
||||
message: format!("Parse error: {}", e),
|
||||
}),
|
||||
};
|
||||
let _ = writeln!(stdout, "{}", serde_json::to_string(&err_resp).unwrap());
|
||||
let _ = stdout.flush();
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let resp = server.handle_request(req).await;
|
||||
let _ = writeln!(stdout, "{}", serde_json::to_string(&resp).unwrap());
|
||||
let _ = stdout.flush();
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"created_at": "2026-04-07T02:27:17.281069",
|
||||
"framework": "PyTorch",
|
||||
"model_type": "MultiTaskModel",
|
||||
"model_type": "pipeline",
|
||||
"model": {
|
||||
"deep_autoencoder": {
|
||||
"file": "deep_autoencoder.onnx",
|
||||
@ -44,7 +44,7 @@
|
||||
},
|
||||
"classifier": {
|
||||
"file": "classifier.onnx",
|
||||
"type": "MultiTaskModel",
|
||||
"type": "classifier",
|
||||
"n_features": 32,
|
||||
"n_classes": 10,
|
||||
"outputs": [
|
||||
@ -333,4 +333,4 @@
|
||||
"8": "Reconnaissance",
|
||||
"9": "Web Attack"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -34,128 +34,128 @@
|
||||
],
|
||||
"ae_clip_params": {
|
||||
"flow_duration": {
|
||||
"lower": 0.0,
|
||||
"lower": 0,
|
||||
"upper": 115669365.2
|
||||
},
|
||||
"fwd_packets": {
|
||||
"lower": 0.0,
|
||||
"upper": 120.0
|
||||
"lower": 0,
|
||||
"upper": 120
|
||||
},
|
||||
"bwd_packets": {
|
||||
"lower": 0.0,
|
||||
"upper": 126.0
|
||||
"lower": 0,
|
||||
"upper": 126
|
||||
},
|
||||
"fwd_bytes": {
|
||||
"lower": 0.0,
|
||||
"lower": 0,
|
||||
"upper": 19557.400390625
|
||||
},
|
||||
"bwd_bytes": {
|
||||
"lower": 0.0,
|
||||
"upper": 85164.0
|
||||
"lower": 0,
|
||||
"upper": 85164
|
||||
},
|
||||
"flow_bytes_per_sec": {
|
||||
"lower": 0.0,
|
||||
"lower": 0,
|
||||
"upper": 1627586.8125000005
|
||||
},
|
||||
"flow_pkts_per_sec": {
|
||||
"lower": 0.0,
|
||||
"lower": 0,
|
||||
"upper": 23809.5234375
|
||||
},
|
||||
"fwd_win_bytes": {
|
||||
"lower": 0.0,
|
||||
"upper": 65280.0
|
||||
"lower": 0,
|
||||
"upper": 65280
|
||||
},
|
||||
"bwd_win_bytes": {
|
||||
"lower": 0.0,
|
||||
"upper": 65535.0
|
||||
"lower": 0,
|
||||
"upper": 65535
|
||||
},
|
||||
"fwd_pkt_len_mean": {
|
||||
"lower": 0.0,
|
||||
"upper": 1500.0
|
||||
"lower": 0,
|
||||
"upper": 1500
|
||||
},
|
||||
"bwd_pkt_len_mean": {
|
||||
"lower": 0.0,
|
||||
"lower": 0,
|
||||
"upper": 822.0007794189461
|
||||
},
|
||||
"fwd_iat_mean": {
|
||||
"lower": 0.0,
|
||||
"lower": 0,
|
||||
"upper": 54051113.24
|
||||
},
|
||||
"bwd_iat_mean": {
|
||||
"lower": 0.0,
|
||||
"lower": 0,
|
||||
"upper": 6912790.715000001
|
||||
},
|
||||
"flow_iat_mean": {
|
||||
"lower": 0.0,
|
||||
"upper": 166521472.0
|
||||
"lower": 0,
|
||||
"upper": 166521472
|
||||
},
|
||||
"pkt_len_mean": {
|
||||
"lower": 0.0,
|
||||
"lower": 0,
|
||||
"upper": 957.2035284423835
|
||||
},
|
||||
"dst_port": {
|
||||
"lower": 0.0,
|
||||
"upper": 63005.0
|
||||
"lower": 0,
|
||||
"upper": 63005
|
||||
},
|
||||
"protocol": {
|
||||
"lower": 0.0,
|
||||
"upper": 17.0
|
||||
"lower": 0,
|
||||
"upper": 17
|
||||
},
|
||||
"psh_flag_cnt": {
|
||||
"lower": 0.0,
|
||||
"upper": 52.0
|
||||
"lower": 0,
|
||||
"upper": 52
|
||||
},
|
||||
"ack_flag_cnt": {
|
||||
"lower": 0.0,
|
||||
"upper": 107.0
|
||||
"lower": 0,
|
||||
"upper": 107
|
||||
},
|
||||
"syn_flag_cnt": {
|
||||
"lower": 0.0,
|
||||
"upper": 4.0
|
||||
"lower": 0,
|
||||
"upper": 4
|
||||
},
|
||||
"fin_flag_cnt": {
|
||||
"lower": 0.0,
|
||||
"upper": 1.0
|
||||
"lower": 0,
|
||||
"upper": 1
|
||||
},
|
||||
"rst_flag_cnt": {
|
||||
"lower": 0.0,
|
||||
"upper": 0.0
|
||||
"lower": 0,
|
||||
"upper": 0
|
||||
},
|
||||
"pkt_len_std": {
|
||||
"lower": 0.0,
|
||||
"lower": 0,
|
||||
"upper": 818.4579974365238
|
||||
},
|
||||
"fwd_pkt_len_std": {
|
||||
"lower": 0.0,
|
||||
"lower": 0,
|
||||
"upper": 256.8401712036142
|
||||
},
|
||||
"bwd_pkt_len_std": {
|
||||
"lower": 0.0,
|
||||
"lower": 0,
|
||||
"upper": 676.0667114257812
|
||||
},
|
||||
"fwd_seg_size_min": {
|
||||
"lower": 0.0,
|
||||
"lower": 0,
|
||||
"upper": 1026743.0693750025
|
||||
},
|
||||
"fwd_act_data_pkts": {
|
||||
"lower": 0.0,
|
||||
"upper": 12.0
|
||||
"lower": 0,
|
||||
"upper": 12
|
||||
},
|
||||
"fwd_iat_std": {
|
||||
"lower": 0.0,
|
||||
"lower": 0,
|
||||
"upper": 6691987.085000001
|
||||
},
|
||||
"bwd_iat_std": {
|
||||
"lower": 0.0,
|
||||
"lower": 0,
|
||||
"upper": 5136363.065000001
|
||||
},
|
||||
"fwd_bwd_bytes_ratio": {
|
||||
"lower": 0.0,
|
||||
"upper": 1.0
|
||||
"lower": 0,
|
||||
"upper": 1
|
||||
},
|
||||
"iat_cv": {
|
||||
"lower": 0.0,
|
||||
"upper": 0.0
|
||||
"lower": 0,
|
||||
"upper": 0
|
||||
}
|
||||
},
|
||||
"ae_scaler_mean": [
|
||||
@ -180,7 +180,7 @@
|
||||
1.4141691028300247,
|
||||
0.08438195832759936,
|
||||
0.0413846397252831,
|
||||
0.0,
|
||||
0,
|
||||
20.458150398533718,
|
||||
3.321724142251631,
|
||||
13.033036407393814,
|
||||
@ -189,7 +189,7 @@
|
||||
94146.99057411935,
|
||||
68775.38334652747,
|
||||
0.47595050130443944,
|
||||
0.0
|
||||
0
|
||||
],
|
||||
"ae_scaler_std": [
|
||||
12196313.175317517,
|
||||
@ -213,7 +213,7 @@
|
||||
10.431975160428792,
|
||||
0.558462828085695,
|
||||
0.19916731632770637,
|
||||
1.0,
|
||||
1,
|
||||
106.06236469581468,
|
||||
24.362268530572912,
|
||||
85.37603561474889,
|
||||
@ -222,11 +222,10 @@
|
||||
646928.7442307192,
|
||||
525550.9737726098,
|
||||
0.3431291415218137,
|
||||
1.0
|
||||
1
|
||||
],
|
||||
"ae_post_clip_min": -5.0,
|
||||
"ae_post_clip_max": 5.0,
|
||||
"ae_threshold": 0.23011694848537445,
|
||||
"ae_post_clip_min": -5,
|
||||
"ae_post_clip_max": 5,
|
||||
"classifier_feature_names": [
|
||||
"flow_duration",
|
||||
"fwd_packets",
|
||||
@ -261,58 +260,7 @@
|
||||
"iat_cv",
|
||||
"ae_anomaly_score"
|
||||
],
|
||||
"attack_labels": {
|
||||
"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
|
||||
}
|
||||
"minmax_params": {},
|
||||
"robust_params": {},
|
||||
"quantile_params": {}
|
||||
}
|
||||
|
||||
@ -1,54 +1,168 @@
|
||||
# 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-v1
|
||||
version: 1
|
||||
|
||||
name: netguardia-v10
|
||||
adapter: multi_task
|
||||
runtime:
|
||||
pipeline_mode: dag
|
||||
normal_label: Normal
|
||||
|
||||
models:
|
||||
autoencoder: deep_autoencoder.onnx
|
||||
classifier: classifier.onnx
|
||||
artifacts:
|
||||
- id: anomaly_detector_onnx
|
||||
file: deep_autoencoder.onnx
|
||||
kind: onnx
|
||||
- id: classifier_onnx
|
||||
file: classifier.onnx
|
||||
kind: onnx
|
||||
- id: preprocessing_sidecar
|
||||
file: inference_config.json
|
||||
kind: sidecar
|
||||
|
||||
# 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
|
||||
stages:
|
||||
- id: anomaly_detector
|
||||
kind: autoencoder
|
||||
model_file: deep_autoencoder.onnx
|
||||
inputs:
|
||||
- { name: flow_duration, source: feature }
|
||||
- { name: fwd_packets, source: feature }
|
||||
- { name: bwd_packets, source: feature }
|
||||
- { name: fwd_bytes, source: feature }
|
||||
- { name: bwd_bytes, source: feature }
|
||||
- { name: flow_bytes_per_sec, source: feature }
|
||||
- { name: flow_pkts_per_sec, source: feature }
|
||||
- { name: fwd_win_bytes, source: feature }
|
||||
- { name: bwd_win_bytes, source: feature }
|
||||
- { name: fwd_pkt_len_mean, source: feature }
|
||||
- { name: bwd_pkt_len_mean, source: feature }
|
||||
- { name: fwd_iat_mean, source: feature }
|
||||
- { name: bwd_iat_mean, source: feature }
|
||||
- { name: flow_iat_mean, source: feature }
|
||||
- { name: pkt_len_mean, source: feature }
|
||||
- { name: dst_port, source: feature }
|
||||
- { name: protocol, source: feature }
|
||||
- { name: psh_flag_cnt, source: feature }
|
||||
- { name: ack_flag_cnt, source: feature }
|
||||
- { name: syn_flag_cnt, source: feature }
|
||||
- { name: fin_flag_cnt, source: feature }
|
||||
- { name: rst_flag_cnt, source: feature }
|
||||
- { name: pkt_len_std, source: feature }
|
||||
- { name: fwd_pkt_len_std, source: feature }
|
||||
- { name: bwd_pkt_len_std, source: feature }
|
||||
- { name: fwd_seg_size_min, source: feature }
|
||||
- { name: fwd_act_data_pkts, source: feature }
|
||||
- { name: fwd_iat_std, source: feature }
|
||||
- { name: bwd_iat_std, source: feature }
|
||||
- { name: fwd_bwd_bytes_ratio, source: feature }
|
||||
- { name: iat_cv, source: feature }
|
||||
preprocessing:
|
||||
- type: standard_scaler
|
||||
sidecar: inference_config.json
|
||||
- type: clip
|
||||
min: -5.0
|
||||
max: 5.0
|
||||
output_heads:
|
||||
- name: ae_anomaly_score
|
||||
index: 0
|
||||
shape: [1]
|
||||
semantic: anomaly_score
|
||||
threshold: 0.23011694848537445
|
||||
|
||||
- id: classifier
|
||||
kind: classifier
|
||||
model_file: classifier.onnx
|
||||
depends_on:
|
||||
- anomaly_detector
|
||||
inputs:
|
||||
- { name: flow_duration, source: feature }
|
||||
- { name: fwd_packets, source: feature }
|
||||
- { name: bwd_packets, source: feature }
|
||||
- { name: fwd_bytes, source: feature }
|
||||
- { name: bwd_bytes, source: feature }
|
||||
- { name: flow_bytes_per_sec, source: feature }
|
||||
- { name: flow_pkts_per_sec, source: feature }
|
||||
- { name: fwd_win_bytes, source: feature }
|
||||
- { name: bwd_win_bytes, source: feature }
|
||||
- { name: fwd_pkt_len_mean, source: feature }
|
||||
- { name: bwd_pkt_len_mean, source: feature }
|
||||
- { name: fwd_iat_mean, source: feature }
|
||||
- { name: bwd_iat_mean, source: feature }
|
||||
- { name: flow_iat_mean, source: feature }
|
||||
- { name: pkt_len_mean, source: feature }
|
||||
- { name: dst_port, source: feature }
|
||||
- { name: protocol, source: feature }
|
||||
- { name: psh_flag_cnt, source: feature }
|
||||
- { name: ack_flag_cnt, source: feature }
|
||||
- { name: syn_flag_cnt, source: feature }
|
||||
- { name: fin_flag_cnt, source: feature }
|
||||
- { name: rst_flag_cnt, source: feature }
|
||||
- { name: pkt_len_std, source: feature }
|
||||
- { name: fwd_pkt_len_std, source: feature }
|
||||
- { name: bwd_pkt_len_std, source: feature }
|
||||
- { name: fwd_seg_size_min, source: feature }
|
||||
- { name: fwd_act_data_pkts, source: feature }
|
||||
- { name: fwd_iat_std, source: feature }
|
||||
- { name: bwd_iat_std, source: feature }
|
||||
- { name: fwd_bwd_bytes_ratio, source: feature }
|
||||
- { name: iat_cv, source: feature }
|
||||
- name: ae_anomaly_score
|
||||
source: stage_output
|
||||
stage: anomaly_detector
|
||||
output: ae_anomaly_score
|
||||
output_heads:
|
||||
- name: anomaly
|
||||
index: 0
|
||||
shape: [1]
|
||||
semantic: binary
|
||||
threshold: 0.9179317355155945
|
||||
- name: class_probs
|
||||
index: 1
|
||||
shape: [10]
|
||||
semantic: multiclass
|
||||
min_confidence: 0.4
|
||||
- name: c2_score
|
||||
index: 2
|
||||
shape: [1]
|
||||
semantic: binary
|
||||
threshold: 0.9085615873336792
|
||||
|
||||
outputs:
|
||||
- stage: anomaly_detector
|
||||
output: ae_anomaly_score
|
||||
alias: ae_anomaly_score
|
||||
role: anomaly_score
|
||||
- stage: classifier
|
||||
output: anomaly
|
||||
alias: anomaly
|
||||
role: binary_score
|
||||
- stage: classifier
|
||||
output: class_probs
|
||||
alias: class_probs
|
||||
role: class_probabilities
|
||||
- stage: classifier
|
||||
output: c2_score
|
||||
alias: c2_score
|
||||
role: c2_score
|
||||
|
||||
detection_rules:
|
||||
- id: classifier_anomaly_threshold
|
||||
type: threshold
|
||||
output: anomaly
|
||||
attack:
|
||||
source: predicted_class
|
||||
output: class_probs
|
||||
exclude_normal: true
|
||||
- id: class_confidence
|
||||
type: class_confidence
|
||||
output: class_probs
|
||||
attack:
|
||||
source: predicted_class
|
||||
output: class_probs
|
||||
exclude_normal: true
|
||||
- id: c2_threshold
|
||||
type: threshold
|
||||
output: c2_score
|
||||
attack:
|
||||
source: fixed_label
|
||||
label: C2 Communication
|
||||
|
||||
# `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 }
|
||||
@ -61,14 +175,8 @@ labels:
|
||||
"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
|
||||
alert_rules:
|
||||
- condition: "anomaly > threshold"
|
||||
source_label: anomaly
|
||||
- condition: "class_probs.argmax != Normal AND class_probs.max > min_confidence"
|
||||
source_label: class_probs
|
||||
|
||||
@ -1,16 +1,15 @@
|
||||
[package]
|
||||
name = "common"
|
||||
name = "net-guardia-abi"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
user = ["aya", "serde"]
|
||||
kernel = ["aya-ebpf"]
|
||||
kernel = []
|
||||
|
||||
[dependencies]
|
||||
aya = { workspace = true, optional = true }
|
||||
aya-ebpf = { workspace = true, optional = true }
|
||||
serde = { workspace = true, optional = true }
|
||||
network-types = { workspace = true }
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
use core::mem::size_of;
|
||||
|
||||
use network_types::eth::EthHdr;
|
||||
use network_types::ip::{Ipv4Hdr, Ipv6Hdr};
|
||||
use network_types::tcp::TcpHdr;
|
||||
@ -26,7 +28,7 @@ pub const IPV6_UDP_HEADER_END: usize = IPV6_UDP_HEADER_START + size_of::<UdpHdr>
|
||||
|
||||
#[cfg(not(feature = "user"))]
|
||||
const _: () = {
|
||||
assert!(core::mem::size_of::<EthHdr>() == 14);
|
||||
assert!(core::mem::size_of::<Ipv4Hdr>() == 20);
|
||||
assert!(core::mem::size_of::<Ipv6Hdr>() == 40);
|
||||
assert!(size_of::<EthHdr>() == 14);
|
||||
assert!(size_of::<Ipv4Hdr>() == 20);
|
||||
assert!(size_of::<Ipv6Hdr>() == 40);
|
||||
};
|
||||
186
net-guardia-abi/src/ebpf/parsing.rs
Normal file
@ -0,0 +1,186 @@
|
||||
use core::mem::size_of;
|
||||
use core::ptr;
|
||||
|
||||
use network_types::eth::{EthHdr, EtherType};
|
||||
use network_types::ip::{IpProto, Ipv4Hdr, Ipv6Hdr};
|
||||
use network_types::tcp::TcpHdr;
|
||||
use network_types::udp::UdpHdr;
|
||||
|
||||
use crate::define::offset::*;
|
||||
use crate::model::ip_address::IpVersion;
|
||||
use crate::model::parsed_packet::ParsedPacket;
|
||||
|
||||
pub unsafe fn parse_packet(start: usize, end: usize, target: *mut ParsedPacket) -> Option<()> {
|
||||
unsafe {
|
||||
if start + ETHER_HEADER_END > end {
|
||||
return None;
|
||||
}
|
||||
let eth = &*((start + ETHER_HEADER_START) as *const EthHdr);
|
||||
let ether_type = eth.ether_type().ok()?;
|
||||
match ether_type {
|
||||
EtherType::Ipv4 => parse_ipv4_packet(start, end, target),
|
||||
EtherType::Ipv6 => parse_ipv6_packet(start, end, target),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
unsafe fn parse_ipv4_packet(start: usize, end: usize, target: *mut ParsedPacket) -> Option<()> {
|
||||
if start + IPV4_HEADER_END > end {
|
||||
return None;
|
||||
}
|
||||
|
||||
unsafe {
|
||||
let ipv4 = &*((start + IPV4_HEADER_START) as *const Ipv4Hdr);
|
||||
let ipv4_header_len = parse_ipv4_header_len(start, end)?;
|
||||
let l4_start = IPV4_HEADER_START + ipv4_header_len;
|
||||
let ip_total_len = read_be_u16(start, end, IPV4_HEADER_START + 2)? as usize;
|
||||
if ip_total_len < ipv4_header_len {
|
||||
return None;
|
||||
}
|
||||
let transport_len = ip_total_len - ipv4_header_len;
|
||||
let packet_length = ip_total_len as u32;
|
||||
|
||||
let t = &mut *target;
|
||||
ptr::copy_nonoverlapping(ipv4.src_addr.as_ptr(), t.src_ip.as_mut_ptr(), 4);
|
||||
ptr::copy_nonoverlapping(ipv4.dst_addr.as_ptr(), t.dst_ip.as_mut_ptr(), 4);
|
||||
t.packet_length = packet_length;
|
||||
t.ip_version = IpVersion::V4.as_u8();
|
||||
t.protocol = ipv4.proto;
|
||||
|
||||
let (src_port, dst_port, tcp_flags, l4_header_len, transport_len) = match ipv4.proto {
|
||||
value if value == IpProto::Tcp as u8 => parse_tcp(start, end, l4_start, transport_len)?,
|
||||
value if value == IpProto::Udp as u8 => parse_udp(start, end, l4_start, transport_len)?,
|
||||
_ => (0, 0, 0, 0, 0),
|
||||
};
|
||||
|
||||
t.payload_length = (transport_len as u32).saturating_sub(l4_header_len as u32);
|
||||
t.src_port = src_port;
|
||||
t.dst_port = dst_port;
|
||||
t.tcp_flags = tcp_flags;
|
||||
}
|
||||
|
||||
Some(())
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
unsafe fn parse_ipv6_packet(start: usize, end: usize, target: *mut ParsedPacket) -> Option<()> {
|
||||
if start + IPV6_HEADER_END > end {
|
||||
return None;
|
||||
}
|
||||
|
||||
unsafe {
|
||||
let ipv6 = &*((start + IPV6_HEADER_START) as *const Ipv6Hdr);
|
||||
let payload_len = read_be_u16(start, end, IPV6_HEADER_START + 4)? as usize;
|
||||
let packet_length = (IPV6_HEADER_END - IPV6_HEADER_START + payload_len) as u32;
|
||||
|
||||
let t = &mut *target;
|
||||
ptr::copy_nonoverlapping(ipv6.src_addr.as_ptr(), t.src_ip.as_mut_ptr(), 16);
|
||||
ptr::copy_nonoverlapping(ipv6.dst_addr.as_ptr(), t.dst_ip.as_mut_ptr(), 16);
|
||||
t.packet_length = packet_length;
|
||||
t.ip_version = IpVersion::V6.as_u8();
|
||||
t.protocol = ipv6.next_hdr;
|
||||
|
||||
let (src_port, dst_port, tcp_flags, l4_header_len, transport_len) = match ipv6.next_hdr {
|
||||
value if value == IpProto::Tcp as u8 => parse_tcp(start, end, IPV6_TCP_HEADER_START, payload_len)?,
|
||||
value if value == IpProto::Udp as u8 => parse_udp(start, end, IPV6_UDP_HEADER_START, payload_len)?,
|
||||
_ => (0, 0, 0, 0, 0),
|
||||
};
|
||||
|
||||
t.payload_length = (transport_len as u32).saturating_sub(l4_header_len as u32);
|
||||
t.src_port = src_port;
|
||||
t.dst_port = dst_port;
|
||||
t.tcp_flags = tcp_flags;
|
||||
}
|
||||
|
||||
Some(())
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
unsafe fn parse_ipv4_header_len(start: usize, end: usize) -> Option<usize> {
|
||||
if start + IPV4_HEADER_START + 1 > end {
|
||||
return None;
|
||||
}
|
||||
|
||||
let version_ihl = unsafe { *((start + IPV4_HEADER_START) as *const u8) };
|
||||
let version = version_ihl >> 4;
|
||||
let ihl = (version_ihl & 0x0f) as usize;
|
||||
if version != 4 || !(5..=15).contains(&ihl) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let header_len = ihl * 4;
|
||||
if start + IPV4_HEADER_START + header_len > end {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(header_len)
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
unsafe fn parse_tcp(
|
||||
start: usize,
|
||||
end: usize,
|
||||
tcp_start: usize,
|
||||
transport_len: usize,
|
||||
) -> Option<(u16, u16, u8, usize, usize)> {
|
||||
if start + tcp_start + size_of::<TcpHdr>() > end {
|
||||
return None;
|
||||
}
|
||||
if transport_len < size_of::<TcpHdr>() {
|
||||
return None;
|
||||
}
|
||||
|
||||
unsafe {
|
||||
let tcp = &*((start + tcp_start) as *const TcpHdr);
|
||||
let data_offset = (*((start + tcp_start + 12) as *const u8) >> 4) as usize;
|
||||
if !(5..=15).contains(&data_offset) {
|
||||
return None;
|
||||
}
|
||||
let header_len = data_offset * 4;
|
||||
if header_len > transport_len || start + tcp_start + header_len > end {
|
||||
return None;
|
||||
}
|
||||
let flags = *((start + tcp_start + 13) as *const u8);
|
||||
Some((
|
||||
u16::from_be_bytes(tcp.source),
|
||||
u16::from_be_bytes(tcp.dest),
|
||||
flags,
|
||||
header_len,
|
||||
transport_len,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
unsafe fn parse_udp(
|
||||
start: usize,
|
||||
end: usize,
|
||||
udp_start: usize,
|
||||
transport_len: usize,
|
||||
) -> Option<(u16, u16, u8, usize, usize)> {
|
||||
if start + udp_start + size_of::<UdpHdr>() > end {
|
||||
return None;
|
||||
}
|
||||
if transport_len < size_of::<UdpHdr>() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let udp = unsafe { &*((start + udp_start) as *const UdpHdr) };
|
||||
let udp_len = udp.len() as usize;
|
||||
if udp_len < size_of::<UdpHdr>() || udp_len > transport_len {
|
||||
return None;
|
||||
}
|
||||
Some((udp.src_port(), udp.dst_port(), 0u8, 8usize, udp_len))
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn read_be_u16(start: usize, end: usize, offset: usize) -> Option<u16> {
|
||||
if start + offset + 2 > end {
|
||||
return None;
|
||||
}
|
||||
let hi = unsafe { *((start + offset) as *const u8) };
|
||||
let lo = unsafe { *((start + offset + 1) as *const u8) };
|
||||
Some(u16::from_be_bytes([hi, lo]))
|
||||
}
|
||||
@ -1,3 +1,4 @@
|
||||
use crate::model::ip_address::IpVersion;
|
||||
use crate::model::parsed_packet::ParsedPacket;
|
||||
|
||||
#[inline(always)]
|
||||
@ -7,8 +8,8 @@ pub fn symmetric_queue_id(pkt: &ParsedPacket, num_queues: u32) -> Option<u32> {
|
||||
}
|
||||
|
||||
let ip_hash = match pkt.ip_version {
|
||||
4 => pkt.src_ip_v4() ^ pkt.dst_ip_v4(),
|
||||
6 => {
|
||||
value if value == IpVersion::V4.as_u8() => pkt.src_ip_v4() ^ pkt.dst_ip_v4(),
|
||||
value if value == IpVersion::V6.as_u8() => {
|
||||
let s = pkt.src_ip_v6();
|
||||
let d = pkt.dst_ip_v6();
|
||||
let xor = s ^ d;
|
||||
@ -18,7 +19,7 @@ pub fn symmetric_queue_id(pkt: &ParsedPacket, num_queues: u32) -> Option<u32> {
|
||||
};
|
||||
let port_hash = (pkt.src_port as u32) ^ (pkt.dst_port as u32);
|
||||
|
||||
let h = (ip_hash ^ port_hash.rotate_left(16) ^ (pkt.protocol as u8 as u32)).wrapping_mul(2654435761);
|
||||
let h = (ip_hash ^ port_hash.rotate_left(16) ^ pkt.protocol as u32).wrapping_mul(2654435761);
|
||||
|
||||
Some(h % num_queues)
|
||||
}
|
||||
@ -1,8 +1,6 @@
|
||||
#[cfg(feature = "user")]
|
||||
use aya::Pod;
|
||||
|
||||
/// Fixed-size DNS name in wire format (length-prefixed labels).
|
||||
/// Stored lowercase, zero-padded. Example: \x07example\x03com\x00
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct DnsName {
|
||||
1
net-guardia-abi/src/model/empty.rs
Normal file
@ -0,0 +1 @@
|
||||
pub type EmptyMapValue = u8;
|
||||
@ -1,3 +1,5 @@
|
||||
use core::convert::TryFrom;
|
||||
|
||||
#[cfg(feature = "user")]
|
||||
use aya::Pod;
|
||||
|
||||
@ -5,6 +7,65 @@ pub type IPv4 = u32;
|
||||
pub type IPv6 = u128;
|
||||
pub type Port = u16;
|
||||
|
||||
#[repr(u8)]
|
||||
#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
|
||||
pub enum IpVersion {
|
||||
V4 = 4,
|
||||
V6 = 6,
|
||||
}
|
||||
|
||||
impl IpVersion {
|
||||
#[inline(always)]
|
||||
pub const fn as_u8(self) -> u8 {
|
||||
self as u8
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub const fn from_u8(value: u8) -> Option<Self> {
|
||||
match value {
|
||||
4 => Some(Self::V4),
|
||||
6 => Some(Self::V6),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub const fn is_v4(self) -> bool {
|
||||
matches!(self, Self::V4)
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub const fn is_v6(self) -> bool {
|
||||
matches!(self, Self::V6)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<IpVersion> for u8 {
|
||||
#[inline(always)]
|
||||
fn from(value: IpVersion) -> Self {
|
||||
value.as_u8()
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<u8> for IpVersion {
|
||||
type Error = ();
|
||||
|
||||
#[inline(always)]
|
||||
fn try_from(value: u8) -> Result<Self, Self::Error> {
|
||||
Self::from_u8(value).ok_or(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "user")]
|
||||
impl serde::Serialize for IpVersion {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
serializer.serialize_u8(self.as_u8())
|
||||
}
|
||||
}
|
||||
|
||||
#[repr(transparent)]
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct AddrPortV4([u8; 8]);
|
||||
@ -1,10 +1,10 @@
|
||||
pub mod dns_name;
|
||||
pub mod drop_event;
|
||||
pub mod empty;
|
||||
pub mod flow_stats;
|
||||
pub mod http_method;
|
||||
pub mod ip_address;
|
||||
pub mod parsed_packet;
|
||||
pub mod placeholder;
|
||||
pub mod port_rule;
|
||||
pub mod pseudo_header;
|
||||
pub mod rate_limit;
|
||||
@ -1,5 +1,3 @@
|
||||
use network_types::ip::IpProto;
|
||||
|
||||
use crate::model::ip_address::{AddrPortV4, AddrPortV6};
|
||||
|
||||
#[repr(C, align(8))]
|
||||
@ -12,9 +10,8 @@ pub struct ParsedPacket {
|
||||
pub src_port: u16,
|
||||
pub dst_port: u16,
|
||||
pub ip_version: u8,
|
||||
pub protocol: IpProto,
|
||||
pub protocol: u8,
|
||||
pub tcp_flags: u8,
|
||||
/// Padding for 8-byte alignment (required by eBPF PerCpuArray)
|
||||
pub _pad: u8,
|
||||
}
|
||||
|
||||
@ -1,16 +1,15 @@
|
||||
[package]
|
||||
name = "ng-cli"
|
||||
name = "net-guardia-cli"
|
||||
version = "1.0.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
reqwest = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
clap = { workspace = true }
|
||||
libc = { workspace = true }
|
||||
|
||||
[[bin]]
|
||||
name = "ng"
|
||||
name = "net-guardia-cli"
|
||||
path = "src/main.rs"
|
||||
@ -1,22 +1,21 @@
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
#[cfg(unix)]
|
||||
use std::io::{self, Write};
|
||||
use std::mem;
|
||||
use std::os::unix::io::AsRawFd;
|
||||
use std::path::PathBuf;
|
||||
use std::process;
|
||||
use std::time::Duration;
|
||||
use std::{env, fs};
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use reqwest::Client;
|
||||
use reqwest::{Client, Method};
|
||||
use serde_json::Value;
|
||||
|
||||
const CSRF_HEADER: &str = "X-CSRF-Token";
|
||||
|
||||
/// NetGuardia CLI management tool.
|
||||
#[derive(Parser)]
|
||||
#[command(name = "ng", about = "NetGuardia CLI", version)]
|
||||
#[command(name = "net-guardia-cli", about = "NetGuardia CLI management tool", version)]
|
||||
struct Cli {
|
||||
/// API base URL
|
||||
#[arg(long, default_value = "http://127.0.0.1:8080", global = true)]
|
||||
#[arg(long, default_value = "http://127.0.0.1:8080", global = true, help = "API base URL")]
|
||||
url: String,
|
||||
|
||||
#[command(subcommand)]
|
||||
@ -25,37 +24,37 @@ struct Cli {
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Commands {
|
||||
/// System health + enforce mode
|
||||
#[command(about = "System health + enforce mode")]
|
||||
Status,
|
||||
/// ML engine status
|
||||
#[command(about = "ML engine status")]
|
||||
Ml,
|
||||
/// Add IP to source blacklist
|
||||
#[command(about = "Add IP to source blacklist")]
|
||||
Block { ip: String },
|
||||
/// Remove IP from source blacklist
|
||||
#[command(about = "Remove IP from source blacklist")]
|
||||
Unblock { ip: String },
|
||||
/// List ACL rules (source blacklist by default)
|
||||
#[command(about = "List ACL rules")]
|
||||
Rules {
|
||||
#[arg(long, default_value = "source")]
|
||||
direction: String,
|
||||
#[arg(long, default_value = "blacklist")]
|
||||
list_type: String,
|
||||
},
|
||||
/// Generate security report (JSON data)
|
||||
#[command(about = "Generate security report")]
|
||||
Report,
|
||||
/// Get or set enforce mode
|
||||
#[command(about = "Get or set enforce mode")]
|
||||
Mode {
|
||||
/// Set mode to "monitor" or "enforce"
|
||||
#[arg(help = "Set mode to monitor or enforce")]
|
||||
mode: Option<String>,
|
||||
},
|
||||
/// Authenticate and save JWT
|
||||
#[command(about = "Authenticate and save JWT")]
|
||||
Login,
|
||||
/// List SOAR active blocks
|
||||
#[command(about = "List SOAR active blocks")]
|
||||
Blocks,
|
||||
/// List SOAR playbooks
|
||||
#[command(about = "List SOAR playbooks")]
|
||||
Playbooks,
|
||||
/// List SOAR execution history
|
||||
#[command(about = "List SOAR execution history")]
|
||||
Executions,
|
||||
/// API key management
|
||||
#[command(about = "API key management")]
|
||||
ApiKey {
|
||||
#[command(subcommand)]
|
||||
action: ApiKeyAction,
|
||||
@ -64,16 +63,16 @@ enum Commands {
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum ApiKeyAction {
|
||||
/// Generate a new API key
|
||||
#[command(about = "Generate a new API key")]
|
||||
Generate {
|
||||
#[arg(long, default_value = "default")]
|
||||
name: String,
|
||||
#[arg(long, default_value = "read_only")]
|
||||
level: String,
|
||||
},
|
||||
/// List all API keys
|
||||
#[command(about = "List all API keys")]
|
||||
List,
|
||||
/// Revoke an API key
|
||||
#[command(about = "Revoke an API key")]
|
||||
Revoke { id: i64 },
|
||||
}
|
||||
|
||||
@ -84,19 +83,19 @@ struct ApiClient {
|
||||
}
|
||||
|
||||
impl ApiClient {
|
||||
fn new(base_url: String) -> Self {
|
||||
fn new(base_url: String) -> Result<Self, String> {
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.build()
|
||||
.expect("Failed to create HTTP client");
|
||||
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
||||
|
||||
let token_path = dirs_next().join("token");
|
||||
|
||||
Self {
|
||||
Ok(Self {
|
||||
client,
|
||||
base_url,
|
||||
token_path,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn load_token(&self) -> Option<String> {
|
||||
@ -119,7 +118,7 @@ impl ApiClient {
|
||||
let resp = req.send().await.map_err(|e| format!("Connection error: {}", e))?;
|
||||
let status = resp.status().as_u16();
|
||||
if status == 401 {
|
||||
return Err("Session expired. Run `ng login` to re-authenticate.".into());
|
||||
return Err("Session expired. Run `net-guardia-cli login` to re-authenticate.".into());
|
||||
}
|
||||
let text = resp.text().await.map_err(|e| format!("Read error: {}", e))?;
|
||||
serde_json::from_str(&text).map_err(|_| {
|
||||
@ -131,7 +130,7 @@ impl ApiClient {
|
||||
})
|
||||
}
|
||||
|
||||
async fn request(&self, method: reqwest::Method, path: &str, body: Option<Value>) -> Result<Value, String> {
|
||||
async fn request(&self, method: Method, path: &str, body: Option<Value>) -> Result<Value, String> {
|
||||
let url = format!("{}{}", self.base_url, path);
|
||||
let include_csrf = should_send_csrf(&method);
|
||||
let mut req = self.client.request(method, &url);
|
||||
@ -139,7 +138,7 @@ impl ApiClient {
|
||||
req = req.header("Authorization", format!("Bearer {}", token.trim()));
|
||||
}
|
||||
if include_csrf {
|
||||
req = req.header(CSRF_HEADER, "ng-cli");
|
||||
req = req.header(CSRF_HEADER, "net-guardia-cli");
|
||||
}
|
||||
if let Some(b) = body {
|
||||
req = req.json(&b);
|
||||
@ -147,7 +146,7 @@ impl ApiClient {
|
||||
let resp = req.send().await.map_err(|e| format!("Connection error: {}", e))?;
|
||||
let status = resp.status().as_u16();
|
||||
if status == 401 {
|
||||
return Err("Session expired. Run `ng login` to re-authenticate.".into());
|
||||
return Err("Session expired. Run `net-guardia-cli login` to re-authenticate.".into());
|
||||
}
|
||||
let text = resp.text().await.map_err(|e| format!("Read error: {}", e))?;
|
||||
if text.is_empty() {
|
||||
@ -189,51 +188,43 @@ impl ApiClient {
|
||||
}
|
||||
|
||||
fn dirs_next() -> PathBuf {
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
|
||||
PathBuf::from(home).join(".ng")
|
||||
let home = env::var("HOME").unwrap_or_else(|_| ".".into());
|
||||
PathBuf::from(home).join(".net-guardia-cli")
|
||||
}
|
||||
|
||||
fn print_json(data: &Value) {
|
||||
println!("{}", serde_json::to_string_pretty(data).unwrap_or_default());
|
||||
}
|
||||
|
||||
fn should_send_csrf(method: &reqwest::Method) -> bool {
|
||||
!matches!(
|
||||
*method,
|
||||
reqwest::Method::GET | reqwest::Method::HEAD | reqwest::Method::OPTIONS
|
||||
)
|
||||
fn should_send_csrf(method: &Method) -> bool {
|
||||
!matches!(*method, Method::GET | Method::HEAD | Method::OPTIONS)
|
||||
}
|
||||
|
||||
fn read_password() -> String {
|
||||
// Disable echo for password input
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let fd = std::io::stdin().as_raw_fd();
|
||||
let mut termios = unsafe { std::mem::zeroed::<libc::termios>() };
|
||||
unsafe { libc::tcgetattr(fd, &mut termios) };
|
||||
let old = termios;
|
||||
termios.c_lflag &= !libc::ECHO;
|
||||
unsafe { libc::tcsetattr(fd, libc::TCSANOW, &termios) };
|
||||
fn read_password() -> Result<String, String> {
|
||||
let fd = io::stdin().as_raw_fd();
|
||||
let mut termios = unsafe { mem::zeroed::<libc::termios>() };
|
||||
unsafe { libc::tcgetattr(fd, &mut termios) };
|
||||
let old = termios;
|
||||
termios.c_lflag &= !libc::ECHO;
|
||||
unsafe { libc::tcsetattr(fd, libc::TCSANOW, &termios) };
|
||||
|
||||
let mut password = String::new();
|
||||
std::io::stdin().read_line(&mut password).unwrap();
|
||||
println!(); // newline after hidden input
|
||||
let mut password = String::new();
|
||||
let read_result = io::stdin()
|
||||
.read_line(&mut password)
|
||||
.map_err(|e| format!("Failed to read password: {}", e));
|
||||
println!();
|
||||
|
||||
unsafe { libc::tcsetattr(fd, libc::TCSANOW, &old) };
|
||||
password.trim().to_string()
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let mut password = String::new();
|
||||
std::io::stdin().read_line(&mut password).unwrap();
|
||||
password.trim().to_string()
|
||||
}
|
||||
unsafe { libc::tcsetattr(fd, libc::TCSANOW, &old) };
|
||||
read_result.map(|_| password.trim().to_string())
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let cli = Cli::parse();
|
||||
let api = ApiClient::new(cli.url);
|
||||
let api = ApiClient::new(cli.url).unwrap_or_else(|e| {
|
||||
eprintln!("Error: {}", e);
|
||||
process::exit(1);
|
||||
});
|
||||
|
||||
let result = match cli.command {
|
||||
Commands::Status => api.get("/api/health/status").await.map(|d| print_json(&d)),
|
||||
@ -247,7 +238,7 @@ async fn main() {
|
||||
format!("{}:0", ip)
|
||||
};
|
||||
api.request(
|
||||
reqwest::Method::PUT,
|
||||
Method::PUT,
|
||||
&format!("/api/acl/{}/source/blacklist", ip_ver),
|
||||
Some(Value::String(addr)),
|
||||
)
|
||||
@ -263,7 +254,7 @@ async fn main() {
|
||||
format!("{}:0", ip)
|
||||
};
|
||||
api.request(
|
||||
reqwest::Method::DELETE,
|
||||
Method::DELETE,
|
||||
&format!("/api/acl/{}/source/blacklist", ip_ver),
|
||||
Some(Value::String(addr)),
|
||||
)
|
||||
@ -271,7 +262,6 @@ async fn main() {
|
||||
.map(|_| println!("Unblocked: {}", ip))
|
||||
}
|
||||
Commands::Rules { direction, list_type } => {
|
||||
// Try both IPv4 and IPv6
|
||||
let v4 = api.get(&format!("/api/acl/ipv4/{}/{}", direction, list_type)).await;
|
||||
let v6 = api.get(&format!("/api/acl/ipv6/{}/{}", direction, list_type)).await;
|
||||
println!("=== IPv4 {} {} ===", direction, list_type);
|
||||
@ -286,14 +276,11 @@ async fn main() {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Commands::Report => {
|
||||
// Use /api/report/data for JSON output
|
||||
api.get("/api/report/data").await.map(|d| print_json(&d))
|
||||
}
|
||||
Commands::Report => api.get("/api/report/data").await.map(|d| print_json(&d)),
|
||||
Commands::Mode { mode } => match mode {
|
||||
Some(m) => {
|
||||
let body = serde_json::json!({"mode": m});
|
||||
api.request(reqwest::Method::PUT, "/api/system/enforce-mode", Some(body))
|
||||
api.request(Method::PUT, "/api/system/enforce-mode", Some(body))
|
||||
.await
|
||||
.map(|d| print_json(&d))
|
||||
}
|
||||
@ -301,20 +288,29 @@ async fn main() {
|
||||
},
|
||||
Commands::Login => {
|
||||
print!("Username: ");
|
||||
let mut stdout = std::io::stdout();
|
||||
stdout.flush().unwrap();
|
||||
let mut stdout = io::stdout();
|
||||
if let Err(e) = stdout.flush() {
|
||||
eprintln_and_exit(format!("Failed to flush stdout: {}", e));
|
||||
}
|
||||
let mut username = String::new();
|
||||
std::io::stdin().read_line(&mut username).unwrap();
|
||||
if let Err(e) = io::stdin().read_line(&mut username) {
|
||||
eprintln_and_exit(format!("Failed to read username: {}", e));
|
||||
}
|
||||
let username = username.trim();
|
||||
|
||||
print!("Password: ");
|
||||
stdout.flush().unwrap();
|
||||
let password = read_password();
|
||||
if let Err(e) = stdout.flush() {
|
||||
eprintln_and_exit(format!("Failed to flush stdout: {}", e));
|
||||
}
|
||||
let password = match read_password() {
|
||||
Ok(password) => password,
|
||||
Err(e) => eprintln_and_exit(e),
|
||||
};
|
||||
|
||||
match api.login(username, &password).await {
|
||||
Ok(token) => match api.save_token(&token) {
|
||||
Ok(()) => {
|
||||
println!("Login successful. Token saved to ~/.ng/token");
|
||||
println!("Login successful. Token saved to ~/.net-guardia-cli/token");
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
@ -328,7 +324,7 @@ async fn main() {
|
||||
Commands::ApiKey { action } => match action {
|
||||
ApiKeyAction::Generate { name, level } => {
|
||||
let body = serde_json::json!({"name": name, "level": level});
|
||||
api.request(reqwest::Method::POST, "/api/api-keys/generate", Some(body))
|
||||
api.request(Method::POST, "/api/api-keys/generate", Some(body))
|
||||
.await
|
||||
.map(|data| {
|
||||
if let Some(key) = data.get("key").and_then(|k| k.as_str()) {
|
||||
@ -363,7 +359,7 @@ async fn main() {
|
||||
}
|
||||
}),
|
||||
ApiKeyAction::Revoke { id } => api
|
||||
.request(reqwest::Method::DELETE, &format!("/api/api-keys/{}", id), None)
|
||||
.request(Method::DELETE, &format!("/api/api-keys/{}", id), None)
|
||||
.await
|
||||
.map(|data| {
|
||||
if data.get("deleted").and_then(|v| v.as_bool()).unwrap_or(false) {
|
||||
@ -377,21 +373,26 @@ async fn main() {
|
||||
|
||||
if let Err(e) = result {
|
||||
eprintln!("Error: {}", e);
|
||||
std::process::exit(1);
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn eprintln_and_exit(message: String) -> ! {
|
||||
eprintln!("Error: {}", message);
|
||||
process::exit(1);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn csrf_header_is_only_needed_for_state_changing_methods() {
|
||||
assert!(!should_send_csrf(&reqwest::Method::GET));
|
||||
assert!(!should_send_csrf(&reqwest::Method::HEAD));
|
||||
assert!(!should_send_csrf(&reqwest::Method::OPTIONS));
|
||||
assert!(should_send_csrf(&reqwest::Method::POST));
|
||||
assert!(should_send_csrf(&reqwest::Method::PUT));
|
||||
assert!(should_send_csrf(&reqwest::Method::DELETE));
|
||||
assert!(!should_send_csrf(&Method::GET));
|
||||
assert!(!should_send_csrf(&Method::HEAD));
|
||||
assert!(!should_send_csrf(&Method::OPTIONS));
|
||||
assert!(should_send_csrf(&Method::POST));
|
||||
assert!(should_send_csrf(&Method::PUT));
|
||||
assert!(should_send_csrf(&Method::DELETE));
|
||||
}
|
||||
}
|
||||
@ -1 +1 @@
|
||||
Subproject commit 4fd1b9027860ce86b29cd54eb3f5eea523a7e692
|
||||
Subproject commit a32e66250e9a08b482da76ff4b383a78df6eefb1
|
||||
@ -1 +1 @@
|
||||
Subproject commit dea59f289635445fe63e69db8345df2f444fb9f1
|
||||
Subproject commit 3f61156bd3ae5fe797a226adeedba21f5fee946f
|
||||
@ -4,13 +4,12 @@ version = "1.0.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
common = { workspace = true, features = ["user"] }
|
||||
net-guardia-abi = { workspace = true, features = ["user"] }
|
||||
macros = { workspace = true }
|
||||
|
||||
# eBPF userspace
|
||||
aya = { workspace = true }
|
||||
aya-log = { workspace = true }
|
||||
network-types = { workspace = true }
|
||||
xsk-rs = { workspace = true }
|
||||
libxdp-sys = { workspace = true }
|
||||
libc = { workspace = true }
|
||||
@ -26,16 +25,16 @@ uuid = { workspace = true }
|
||||
rust-embed = { workspace = true }
|
||||
mime_guess = { workspace = true }
|
||||
url = { workspace = true }
|
||||
tokio-tungstenite = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
|
||||
# Serialization
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
serde_yaml_ng = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
|
||||
# Async
|
||||
tokio = { workspace = true }
|
||||
tokio-util = { workspace = true }
|
||||
futures-util = { workspace = true }
|
||||
crossbeam = { workspace = true }
|
||||
|
||||
@ -73,7 +72,6 @@ ipnetwork = { workspace = true }
|
||||
lru = { workspace = true }
|
||||
rusqlite = { workspace = true }
|
||||
async-sqlite = { workspace = true }
|
||||
jsonwebtoken = { workspace = true }
|
||||
argon2 = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
hmac = { workspace = true }
|
||||
|
||||
@ -13,15 +13,10 @@ fn main() {
|
||||
build_frontend();
|
||||
}
|
||||
|
||||
/// Resolve the absolute path of bpf-linker.
|
||||
/// Searches PATH first, then falls back to CARGO_HOME/bin.
|
||||
fn find_bpf_linker() -> PathBuf {
|
||||
// Try PATH via which
|
||||
if let Ok(path) = which::which("bpf-linker") {
|
||||
return path;
|
||||
}
|
||||
|
||||
// Fallback: CARGO_HOME/bin (handles CI cache + which v8 issues)
|
||||
let cargo_home = env::var("CARGO_HOME").unwrap_or_else(|_| {
|
||||
let home = env::var("HOME").unwrap_or_default();
|
||||
format!("{home}/.cargo")
|
||||
@ -60,8 +55,6 @@ fn build_ebpf_package(package_name: &str, target_subdir: &str) {
|
||||
if build_ebpf {
|
||||
let arch = env::var_os("CARGO_CFG_TARGET_ARCH").unwrap();
|
||||
let target = format!("{target}-unknown-none");
|
||||
|
||||
// Find bpf-linker once, pass its path to the subprocess explicitly.
|
||||
let bpf_linker = find_bpf_linker();
|
||||
let bpf_linker_str = bpf_linker.to_str().expect("bpf-linker path is not valid UTF-8");
|
||||
|
||||
@ -69,7 +62,7 @@ fn build_ebpf_package(package_name: &str, target_subdir: &str) {
|
||||
let ebpf_dir = manifest_path.parent().unwrap();
|
||||
|
||||
println!("cargo:rerun-if-changed={}", ebpf_dir.as_str());
|
||||
println!("cargo:rerun-if-changed=../common/src");
|
||||
println!("cargo:rerun-if-changed=../net-guardia-abi/src");
|
||||
|
||||
let mut cmd = Command::new("cargo");
|
||||
cmd.args([
|
||||
@ -84,9 +77,6 @@ fn build_ebpf_package(package_name: &str, target_subdir: &str) {
|
||||
]);
|
||||
|
||||
cmd.env("CARGO_CFG_BPF_TARGET_ARCH", arch);
|
||||
|
||||
// Tell cargo which linker to use for the BPF targets.
|
||||
// This avoids relying on PATH in the subprocess.
|
||||
let linker_env_bpfel = "CARGO_TARGET_BPFEL_UNKNOWN_NONE_LINKER";
|
||||
let linker_env_bpfeb = "CARGO_TARGET_BPFEB_UNKNOWN_NONE_LINKER";
|
||||
cmd.env(linker_env_bpfel, bpf_linker_str);
|
||||
@ -120,16 +110,13 @@ fn build_ebpf_package(package_name: &str, target_subdir: &str) {
|
||||
let stdout = BufReader::new(stdout);
|
||||
let mut executables = Vec::new();
|
||||
for message in Message::parse_stream(stdout) {
|
||||
#[allow(clippy::collapsible_match)]
|
||||
match message.expect("valid JSON") {
|
||||
Message::CompilerArtifact(Artifact {
|
||||
executable,
|
||||
executable: Some(executable),
|
||||
target: Target { name, .. },
|
||||
..
|
||||
}) => {
|
||||
if let Some(executable) = executable {
|
||||
executables.push((name, executable.into_std_path_buf()));
|
||||
}
|
||||
executables.push((name, executable.into_std_path_buf()));
|
||||
}
|
||||
Message::CompilerMessage(CompilerMessage { message, .. }) => {
|
||||
for line in message.rendered.unwrap_or_default().split('\n') {
|
||||
@ -152,8 +139,6 @@ fn build_ebpf_package(package_name: &str, target_subdir: &str) {
|
||||
|
||||
for (name, binary) in executables {
|
||||
let dst = out_dir.join(name);
|
||||
// Only copy if content actually changed to avoid updating mtime,
|
||||
// which would cause cargo to unnecessarily relink the binary.
|
||||
if !files_equal(&binary, &dst) {
|
||||
let _: u64 =
|
||||
fs::copy(&binary, &dst).unwrap_or_else(|err| panic!("failed to copy {binary:?} to {dst:?}: {err}"));
|
||||
@ -186,11 +171,6 @@ fn build_frontend() {
|
||||
if !frontend_dir.exists() {
|
||||
panic!("Frontend directory {:?} does not exist", frontend_dir);
|
||||
}
|
||||
|
||||
// Emit rerun-if-changed for individual files so that edits inside
|
||||
// subdirectories (e.g. src/components/Foo.vue) actually trigger a rebuild.
|
||||
// Directory-level rerun-if-changed only watches the directory mtime, which
|
||||
// doesn't change when files in subdirectories are modified on Linux.
|
||||
for dir_name in ["src", "public"] {
|
||||
let dir_path = frontend_dir.join(dir_name);
|
||||
if dir_path.exists() {
|
||||
@ -238,10 +218,6 @@ fn build_frontend() {
|
||||
fs::create_dir_all(&static_dir).unwrap_or_else(|err| panic!("failed to create {:?}: {err}", static_dir));
|
||||
|
||||
copy_dir_all(&out_dir, &static_dir).unwrap_or_else(|err| panic!("failed to copy frontend build: {err}"));
|
||||
|
||||
// rust_embed embeds static/ at compile time. After copying new frontend
|
||||
// output into static/web/, we must tell cargo to recompile the crate so
|
||||
// the embedded files are refreshed in the binary.
|
||||
emit_rerun_if_changed_recursive(&static_dir);
|
||||
}
|
||||
|
||||
@ -310,7 +286,6 @@ fn get_dir_last_modified(path: &std::path::Path) -> Option<SystemTime> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Returns true if both files exist and have identical contents.
|
||||
fn files_equal(a: &std::path::Path, b: &std::path::Path) -> bool {
|
||||
let Ok(a_meta) = fs::metadata(a) else { return false };
|
||||
let Ok(b_meta) = fs::metadata(b) else { return false };
|
||||
|
||||
@ -2,13 +2,12 @@ use std::net::{IpAddr, SocketAddrV4, SocketAddrV6};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::adapter::ebpf::access_control::AccessControl;
|
||||
use crate::domain::common::error::Error;
|
||||
use crate::common::error::Error;
|
||||
use crate::domain::data_plane::direction::FlowDirection;
|
||||
use crate::domain::data_plane::error::EbpfError;
|
||||
use crate::domain::data_plane::list_type::ListType;
|
||||
use crate::interface::access_control::AccessControlPort;
|
||||
use crate::interface::data_plane::access_control::AccessControlPort;
|
||||
|
||||
/// Adapter that implements AccessControlPort by delegating to the eBPF AccessControl.
|
||||
pub struct AccessControlAdapter {
|
||||
access_control: Arc<AccessControl>,
|
||||
}
|
||||
@ -19,11 +18,14 @@ impl AccessControlAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_ip(ip: &str) -> Result<IpAddr, Error> {
|
||||
let addr = ip.parse().map_err(|_| EbpfError::InvalidIpAddress(ip.to_string()))?;
|
||||
Ok(addr)
|
||||
}
|
||||
|
||||
impl AccessControlPort for AccessControlAdapter {
|
||||
fn block_ip(&self, ip: &str) -> Result<(), Error> {
|
||||
let addr: IpAddr = ip
|
||||
.parse()
|
||||
.map_err(|_| Error::from(EbpfError::InvalidIpAddress(ip.to_string())))?;
|
||||
let addr = parse_ip(ip)?;
|
||||
match addr {
|
||||
IpAddr::V4(v4) => {
|
||||
let socket = SocketAddrV4::new(v4, 0);
|
||||
@ -39,9 +41,7 @@ impl AccessControlPort for AccessControlAdapter {
|
||||
}
|
||||
|
||||
fn unblock_ip(&self, ip: &str) -> Result<(), Error> {
|
||||
let addr: IpAddr = ip
|
||||
.parse()
|
||||
.map_err(|_| Error::from(EbpfError::InvalidIpAddress(ip.to_string())))?;
|
||||
let addr = parse_ip(ip)?;
|
||||
match addr {
|
||||
IpAddr::V4(v4) => {
|
||||
let socket = SocketAddrV4::new(v4, 0);
|
||||
|
||||
@ -3,16 +3,16 @@ use std::net::{Ipv4Addr, Ipv6Addr, SocketAddrV4, SocketAddrV6};
|
||||
|
||||
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 net_guardia_abi::model::ip_address::{IPv4, IPv6, Port};
|
||||
use net_guardia_abi::model::port_rule::PortRule;
|
||||
use parking_lot::RwLock;
|
||||
|
||||
use crate::domain::common::error::Error;
|
||||
use crate::common::error::Error;
|
||||
use crate::domain::data_plane::direction::FlowDirection;
|
||||
use crate::domain::data_plane::error::EbpfError;
|
||||
use crate::domain::data_plane::ip_address::NativeConvert;
|
||||
use crate::domain::data_plane::list_type::ListType;
|
||||
use crate::interface::access_control_admin::AccessControlAdminPort;
|
||||
use crate::interface::data_plane::access_control_admin::AccessControlAdminPort;
|
||||
|
||||
pub struct AccessControl {
|
||||
ipv4_src_whitelist: RwLock<MapWrapper<IPv4>>,
|
||||
@ -182,6 +182,12 @@ struct MapWrapper<T> {
|
||||
map: Option<AyaHashMap<MapData, T, PortRule>>,
|
||||
}
|
||||
|
||||
enum PortRuleRemoval {
|
||||
Unchanged,
|
||||
Update,
|
||||
Delete,
|
||||
}
|
||||
|
||||
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)?;
|
||||
@ -237,19 +243,56 @@ impl<T: NativeConvert + Pod> MapWrapper<T> {
|
||||
}
|
||||
|
||||
let mut rule = map.get(&ip, 0).map_err(|_| EbpfError::IpDoesNotExist)?;
|
||||
|
||||
if rule.is_match_all() {
|
||||
map.remove(&ip).map_err(EbpfError::MapOperationError)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
rule.remove_port(port);
|
||||
|
||||
if rule.is_empty() {
|
||||
map.remove(&ip).map_err(EbpfError::MapOperationError)?;
|
||||
} else {
|
||||
map.insert(ip, rule, 0).map_err(EbpfError::MapOperationError)?;
|
||||
match remove_port_from_rule(&mut rule, port) {
|
||||
PortRuleRemoval::Unchanged => {}
|
||||
PortRuleRemoval::Delete => {
|
||||
map.remove(&ip).map_err(EbpfError::MapOperationError)?;
|
||||
}
|
||||
PortRuleRemoval::Update => {
|
||||
map.insert(ip, rule, 0).map_err(EbpfError::MapOperationError)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_port_from_rule(rule: &mut PortRule, port: Port) -> PortRuleRemoval {
|
||||
if rule.is_match_all() {
|
||||
return if port == 0 {
|
||||
PortRuleRemoval::Delete
|
||||
} else {
|
||||
PortRuleRemoval::Unchanged
|
||||
};
|
||||
}
|
||||
|
||||
rule.remove_port(port);
|
||||
if rule.is_empty() {
|
||||
PortRuleRemoval::Delete
|
||||
} else {
|
||||
PortRuleRemoval::Update
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn removing_specific_port_from_match_all_rule_is_noop() {
|
||||
let mut rule = PortRule::new_match_all();
|
||||
|
||||
let removal = remove_port_from_rule(&mut rule, 443);
|
||||
|
||||
assert!(matches!(removal, PortRuleRemoval::Unchanged));
|
||||
assert!(rule.is_match_all());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_port_zero_from_match_all_rule_deletes_rule() {
|
||||
let mut rule = PortRule::new_match_all();
|
||||
|
||||
let removal = remove_port_from_rule(&mut rule, 0);
|
||||
|
||||
assert!(matches!(removal, PortRuleRemoval::Delete));
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,16 +1,21 @@
|
||||
use std::mem::size_of;
|
||||
use std::net::Ipv6Addr;
|
||||
use std::ptr;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use aya::maps::{MapData, RingBuf};
|
||||
use common::define::drop_reason::*;
|
||||
use common::model::drop_event::DropEvent as RawDropEvent;
|
||||
use macros::log;
|
||||
use net_guardia_abi::define::drop_reason::*;
|
||||
use net_guardia_abi::model::drop_event::DropEvent as RawDropEvent;
|
||||
use tokio::sync::{broadcast, oneshot};
|
||||
use tokio::time::interval;
|
||||
|
||||
use crate::domain::data_plane::drop_event::{DropCounters, DropEventMessage};
|
||||
use crate::interface::drop_stats::DropStatsPort;
|
||||
use crate::domain::data_plane::ip_version::IpVersion;
|
||||
use crate::domain::data_plane::log::EbpfLog;
|
||||
use crate::interface::data_plane::drop_stats::DropStatsPort;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct DropCountersAtomic {
|
||||
@ -22,21 +27,36 @@ pub struct DropCountersAtomic {
|
||||
protocol_filter: AtomicU64,
|
||||
dns_blacklist: AtomicU64,
|
||||
geo_block: AtomicU64,
|
||||
total: AtomicU64,
|
||||
}
|
||||
|
||||
impl DropCountersAtomic {
|
||||
pub fn snapshot(&self) -> DropCounters {
|
||||
let acl_blacklist = self.acl_blacklist.load(Ordering::Relaxed);
|
||||
let rate_limit_pkt = self.rate_limit_pkt.load(Ordering::Relaxed);
|
||||
let rate_limit_syn = self.rate_limit_syn.load(Ordering::Relaxed);
|
||||
let rate_limit_udp = self.rate_limit_udp.load(Ordering::Relaxed);
|
||||
let rate_limit_dns = self.rate_limit_dns.load(Ordering::Relaxed);
|
||||
let protocol_filter = self.protocol_filter.load(Ordering::Relaxed);
|
||||
let dns_blacklist = self.dns_blacklist.load(Ordering::Relaxed);
|
||||
let geo_block = self.geo_block.load(Ordering::Relaxed);
|
||||
let total = acl_blacklist
|
||||
+ rate_limit_pkt
|
||||
+ rate_limit_syn
|
||||
+ rate_limit_udp
|
||||
+ rate_limit_dns
|
||||
+ protocol_filter
|
||||
+ dns_blacklist
|
||||
+ geo_block;
|
||||
DropCounters {
|
||||
acl_blacklist: self.acl_blacklist.load(Ordering::Relaxed),
|
||||
rate_limit_pkt: self.rate_limit_pkt.load(Ordering::Relaxed),
|
||||
rate_limit_syn: self.rate_limit_syn.load(Ordering::Relaxed),
|
||||
rate_limit_udp: self.rate_limit_udp.load(Ordering::Relaxed),
|
||||
rate_limit_dns: self.rate_limit_dns.load(Ordering::Relaxed),
|
||||
protocol_filter: self.protocol_filter.load(Ordering::Relaxed),
|
||||
dns_blacklist: self.dns_blacklist.load(Ordering::Relaxed),
|
||||
geo_block: self.geo_block.load(Ordering::Relaxed),
|
||||
total: self.total.load(Ordering::Relaxed),
|
||||
acl_blacklist,
|
||||
rate_limit_pkt,
|
||||
rate_limit_syn,
|
||||
rate_limit_udp,
|
||||
rate_limit_dns,
|
||||
protocol_filter,
|
||||
dns_blacklist,
|
||||
geo_block,
|
||||
total,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -73,23 +93,24 @@ impl DropMonitor {
|
||||
}
|
||||
}
|
||||
|
||||
fn record_drop(&self, reason: u8) {
|
||||
self.counters.total.fetch_add(1, Ordering::Relaxed);
|
||||
pub fn record_drop_count(&self, reason: u8) {
|
||||
if let Some(counter) = self.bucket_for(reason) {
|
||||
counter.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_userspace_drop_count_only(&self, reason: u8) {
|
||||
self.record_drop(reason);
|
||||
}
|
||||
|
||||
fn process_event(&self, raw: &RawDropEvent) {
|
||||
self.record_drop(raw.reason);
|
||||
pub fn record_drop_event(&self, raw: &RawDropEvent) {
|
||||
self.record_drop_count(raw.reason);
|
||||
let Some(ip_version) = IpVersion::from_u8(raw.ip_version) else {
|
||||
return;
|
||||
};
|
||||
if self.broadcast_tx.receiver_count() == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let reason_str = reason_to_str(raw.reason);
|
||||
|
||||
let (src_ip, dst_ip) = format_ips(raw);
|
||||
let (src_ip, dst_ip) = format_ips(raw, ip_version);
|
||||
|
||||
let msg = DropEventMessage {
|
||||
timestamp_ns: raw.timestamp_ns,
|
||||
@ -99,10 +120,12 @@ impl DropMonitor {
|
||||
dst_port: raw.dst_port,
|
||||
protocol: raw.protocol,
|
||||
reason: reason_str.to_string(),
|
||||
ip_version: raw.ip_version,
|
||||
ip_version,
|
||||
};
|
||||
|
||||
let _ = self.broadcast_tx.send(msg);
|
||||
if let Err(err) = self.broadcast_tx.send(msg) {
|
||||
log!(EbpfLog::DropBroadcastFailed(err.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -112,9 +135,9 @@ impl DropStatsPort for DropMonitor {
|
||||
}
|
||||
}
|
||||
|
||||
fn format_ips(raw: &RawDropEvent) -> (String, String) {
|
||||
match raw.ip_version {
|
||||
4 => {
|
||||
fn format_ips(raw: &RawDropEvent, ip_version: IpVersion) -> (String, String) {
|
||||
match ip_version {
|
||||
IpVersion::V4 => {
|
||||
let src = format!(
|
||||
"{}.{}.{}.{}",
|
||||
raw.src_ip[0], raw.src_ip[1], raw.src_ip[2], raw.src_ip[3]
|
||||
@ -125,7 +148,7 @@ fn format_ips(raw: &RawDropEvent) -> (String, String) {
|
||||
);
|
||||
(src, dst)
|
||||
}
|
||||
_ => {
|
||||
IpVersion::V6 => {
|
||||
let src = format_ipv6(&raw.src_ip);
|
||||
let dst = format_ipv6(&raw.dst_ip);
|
||||
(src, dst)
|
||||
@ -156,7 +179,6 @@ pub async fn start_consumer(ring_buf: RingBuf<MapData>, monitor: Arc<DropMonitor
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut ring_buf = ring_buf;
|
||||
// todo add interval value to config
|
||||
let mut interval = interval(Duration::from_millis(100));
|
||||
|
||||
loop {
|
||||
@ -166,9 +188,8 @@ pub async fn start_consumer(ring_buf: RingBuf<MapData>, monitor: Arc<DropMonitor
|
||||
}
|
||||
|
||||
while let Some(item) = ring_buf.next() {
|
||||
if item.len() >= size_of::<RawDropEvent>() {
|
||||
let event = unsafe { &*(item.as_ptr() as *const RawDropEvent) };
|
||||
monitor.process_event(event);
|
||||
if let Some(event) = raw_drop_event_from_bytes(&item) {
|
||||
monitor.record_drop_event(&event);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -176,3 +197,60 @@ pub async fn start_consumer(ring_buf: RingBuf<MapData>, monitor: Arc<DropMonitor
|
||||
|
||||
shutdown_tx
|
||||
}
|
||||
|
||||
fn raw_drop_event_from_bytes(bytes: &[u8]) -> Option<RawDropEvent> {
|
||||
if bytes.len() < size_of::<RawDropEvent>() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// SAFETY: The length check guarantees enough initialized bytes for RawDropEvent.
|
||||
let event = unsafe { ptr::read_unaligned(bytes.as_ptr().cast::<RawDropEvent>()) };
|
||||
Some(event)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_drop_event() -> RawDropEvent {
|
||||
RawDropEvent {
|
||||
timestamp_ns: 42,
|
||||
src_ip: [1; 16],
|
||||
dst_ip: [2; 16],
|
||||
src_port: 1234,
|
||||
dst_port: 443,
|
||||
protocol: 6,
|
||||
reason: DROP_REASON_ACL_BLACKLIST,
|
||||
ip_version: IpVersion::V4 as u8,
|
||||
_pad: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn event_bytes(event: &RawDropEvent) -> Vec<u8> {
|
||||
// SAFETY: RawDropEvent is a repr(C), Copy ABI record borrowed as bytes.
|
||||
let bytes = unsafe {
|
||||
std::slice::from_raw_parts((event as *const RawDropEvent).cast::<u8>(), size_of::<RawDropEvent>())
|
||||
};
|
||||
bytes.to_vec()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_drop_event_from_bytes_rejects_short_buffers() {
|
||||
let bytes = vec![0; size_of::<RawDropEvent>() - 1];
|
||||
|
||||
assert!(raw_drop_event_from_bytes(&bytes).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_drop_event_from_bytes_accepts_unaligned_buffers() {
|
||||
let event = sample_drop_event();
|
||||
let mut bytes = vec![0];
|
||||
bytes.extend(event_bytes(&event));
|
||||
|
||||
let parsed = raw_drop_event_from_bytes(&bytes[1..]).expect("drop event");
|
||||
|
||||
assert_eq!(parsed.timestamp_ns, event.timestamp_ns);
|
||||
assert_eq!(parsed.src_port, event.src_port);
|
||||
assert_eq!(parsed.reason, event.reason);
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,25 +6,38 @@ use aya::Ebpf;
|
||||
use aya::maps::MapData;
|
||||
use aya::maps::lpm_trie::{Key, LpmTrie};
|
||||
use ipnetwork::IpNetwork;
|
||||
use maxminddb::{Reader, geoip2};
|
||||
use maxminddb::Reader;
|
||||
use parking_lot::RwLock;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::common::error::Error;
|
||||
use crate::common::error::io::IOError;
|
||||
use crate::domain::common::config::AppConfig;
|
||||
use crate::domain::common::error::Error;
|
||||
use crate::domain::common::error::misc::MiscError;
|
||||
use crate::domain::data_plane::error::EbpfError;
|
||||
use crate::interface::geo_block_api::GeoBlockPort;
|
||||
use crate::interface::data_plane::geo_block_api::GeoBlockPort;
|
||||
|
||||
struct GeoIndex {
|
||||
v4: StdHashMap<String, Vec<(u32, u32)>>,
|
||||
v6: StdHashMap<String, Vec<(u128, u32)>>,
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
struct GeoCountryRecord<'a> {
|
||||
#[serde(borrow, default)]
|
||||
country: GeoCountry<'a>,
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
struct GeoCountry<'a> {
|
||||
iso_code: Option<&'a str>,
|
||||
}
|
||||
|
||||
pub struct GeoBlock {
|
||||
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>,
|
||||
db_path: String,
|
||||
index: RwLock<Option<Arc<GeoIndex>>>,
|
||||
}
|
||||
|
||||
impl GeoBlock {
|
||||
@ -35,50 +48,61 @@ impl GeoBlock {
|
||||
let v6_map = ebpf.take_map("GEO_BLOCK_V6").ok_or(EbpfError::MapNotFound)?;
|
||||
let v6_trie = LpmTrie::try_from(v6_map).map_err(EbpfError::MapOperationError)?;
|
||||
|
||||
// todo read config from AppConfig, not db
|
||||
let db_path = app_config.load().acl.geoip_db_path.clone();
|
||||
let reader = Reader::open_readfile(&db_path).map_err(|e| MiscError::GeoIPDatabaseError(db_path.clone(), e))?;
|
||||
|
||||
let index = Self::build_index(&reader)?;
|
||||
let _ = Reader::open_readfile(&db_path).map_err(|e| IOError::OpenFileFailed(db_path.clone(), e))?;
|
||||
|
||||
Ok(Self {
|
||||
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),
|
||||
db_path,
|
||||
index: RwLock::new(None),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn unavailable(app_config: Arc<ArcSwap<AppConfig>>) -> Self {
|
||||
let index = Reader::open_readfile(&app_config.load().acl.geoip_db_path)
|
||||
.ok()
|
||||
.and_then(|reader| Self::build_index(&reader).ok())
|
||||
.unwrap_or(GeoIndex {
|
||||
v4: StdHashMap::new(),
|
||||
v6: StdHashMap::new(),
|
||||
});
|
||||
let db_path = app_config.load().acl.geoip_db_path.clone();
|
||||
Self {
|
||||
geo_block_v4: RwLock::new(None),
|
||||
geo_block_v6: RwLock::new(None),
|
||||
blocked_countries: ArcSwap::from_pointee(HashSet::new()),
|
||||
index: Arc::new(index),
|
||||
db_path,
|
||||
index: RwLock::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn index(&self) -> Result<Arc<GeoIndex>, Error> {
|
||||
if let Some(index) = self.index.read().as_ref() {
|
||||
return Ok(index.clone());
|
||||
}
|
||||
|
||||
let mut guard = self.index.write();
|
||||
if let Some(index) = guard.as_ref() {
|
||||
return Ok(index.clone());
|
||||
}
|
||||
|
||||
let reader =
|
||||
Reader::open_readfile(&self.db_path).map_err(|e| IOError::OpenFileFailed(self.db_path.clone(), e))?;
|
||||
let index = Arc::new(Self::build_index(&reader)?);
|
||||
*guard = Some(index.clone());
|
||||
Ok(index)
|
||||
}
|
||||
|
||||
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();
|
||||
let ipv4_all = parse_geoip_network("0.0.0.0/0")?;
|
||||
if let Ok(iter) = reader.within(ipv4_all, Default::default()) {
|
||||
for result in iter {
|
||||
let Ok(lookup) = result else { continue };
|
||||
let Ok(network) = lookup.network() else { continue };
|
||||
let Ok(Some(city)) = lookup.decode::<geoip2::City>() else {
|
||||
let Ok(Some(record)) = lookup.decode::<GeoCountryRecord>() else {
|
||||
continue;
|
||||
};
|
||||
let Some(code) = record.country.iso_code else {
|
||||
continue;
|
||||
};
|
||||
let Some(code) = city.country.iso_code else { continue };
|
||||
let code = code.to_uppercase();
|
||||
|
||||
if let IpNetwork::V4(v4_net) = network {
|
||||
@ -88,16 +112,17 @@ impl GeoBlock {
|
||||
}
|
||||
}
|
||||
|
||||
// SAFETY: "::/0" is a valid IPv6 CIDR literal, parse is infallible.
|
||||
let ipv6_all: IpNetwork = "::/0".parse().unwrap();
|
||||
let ipv6_all = parse_geoip_network("::/0")?;
|
||||
if let Ok(iter) = reader.within(ipv6_all, Default::default()) {
|
||||
for result in iter {
|
||||
let Ok(lookup) = result else { continue };
|
||||
let Ok(network) = lookup.network() else { continue };
|
||||
let Ok(Some(city)) = lookup.decode::<geoip2::City>() else {
|
||||
let Ok(Some(record)) = lookup.decode::<GeoCountryRecord>() else {
|
||||
continue;
|
||||
};
|
||||
let Some(code) = record.country.iso_code else {
|
||||
continue;
|
||||
};
|
||||
let Some(code) = city.country.iso_code else { continue };
|
||||
let code = code.to_uppercase();
|
||||
|
||||
if let IpNetwork::V6(v6_net) = network {
|
||||
@ -115,45 +140,52 @@ impl GeoBlock {
|
||||
}
|
||||
|
||||
pub fn block_countries(&self, country_codes: &[String]) -> Result<u64, Error> {
|
||||
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()) {
|
||||
next.insert(upper);
|
||||
}
|
||||
let mut next: HashSet<String> = self.blocked_countries.load().as_ref().clone();
|
||||
for code in country_codes {
|
||||
let upper = code.trim().to_uppercase();
|
||||
if upper.len() == 2 && upper.chars().all(|c| c.is_ascii_alphabetic()) {
|
||||
next.insert(upper);
|
||||
}
|
||||
next
|
||||
});
|
||||
self.rebuild_tries()
|
||||
}
|
||||
let count = self.rebuild_tries_for(&next)?;
|
||||
self.blocked_countries.store(Arc::new(next));
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
pub fn unblock_countries(&self, country_codes: &[String]) -> Result<u64, Error> {
|
||||
self.blocked_countries.rcu(|cur| {
|
||||
let mut next: HashSet<String> = (**cur).clone();
|
||||
for code in country_codes {
|
||||
next.remove(&code.trim().to_uppercase());
|
||||
}
|
||||
next
|
||||
});
|
||||
self.rebuild_tries()
|
||||
let mut next: HashSet<String> = self.blocked_countries.load().as_ref().clone();
|
||||
for code in country_codes {
|
||||
next.remove(&code.trim().to_uppercase());
|
||||
}
|
||||
let count = self.rebuild_tries_for(&next)?;
|
||||
self.blocked_countries.store(Arc::new(next));
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
fn rebuild_tries(&self) -> Result<u64, Error> {
|
||||
let countries = self.blocked_countries.load_full();
|
||||
|
||||
let mut v4_entries: Vec<(Key<u32>, u8)> = Vec::new();
|
||||
let mut v6_entries: Vec<(Key<u128>, u8)> = Vec::new();
|
||||
|
||||
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));
|
||||
}
|
||||
fn rebuild_tries_for(&self, countries: &HashSet<String>) -> Result<u64, Error> {
|
||||
{
|
||||
let v4_guard = self.geo_block_v4.read();
|
||||
let v6_guard = self.geo_block_v6.read();
|
||||
if v4_guard.is_none() || v6_guard.is_none() {
|
||||
Err(EbpfError::NotLoaded)?;
|
||||
}
|
||||
if let Some(prefixes) = self.index.v6.get(code) {
|
||||
for &(ip_be, prefix_len) in prefixes {
|
||||
v6_entries.push((Key::new(prefix_len, ip_be), 1u8));
|
||||
}
|
||||
|
||||
let mut v4_entries: HashSet<(u32, u32)> = HashSet::new();
|
||||
let mut v6_entries: HashSet<(u128, u32)> = HashSet::new();
|
||||
|
||||
if !countries.is_empty() {
|
||||
let index = self.index()?;
|
||||
for code in countries {
|
||||
if let Some(prefixes) = index.v4.get(code) {
|
||||
for &entry in prefixes {
|
||||
v4_entries.insert(entry);
|
||||
}
|
||||
}
|
||||
if let Some(prefixes) = index.v6.get(code) {
|
||||
for &entry in prefixes {
|
||||
v6_entries.insert(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -164,39 +196,59 @@ impl GeoBlock {
|
||||
(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 {
|
||||
if v4_trie.insert(key, *val, 0).is_ok() {
|
||||
for &(ip_be, prefix_len) in &v4_entries {
|
||||
let key = Key::new(prefix_len, ip_be);
|
||||
if v4_trie.insert(&key, 1u8, 0).is_ok() {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
for (key, val) in &v6_entries {
|
||||
if v6_trie.insert(key, *val, 0).is_ok() {
|
||||
for &(ip_be, prefix_len) in &v6_entries {
|
||||
let key = Key::new(prefix_len, ip_be);
|
||||
if v6_trie.insert(&key, 1u8, 0).is_ok() {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
Self::remove_stale_v4(v4_trie, &v4_entries);
|
||||
Self::remove_stale_v6(v6_trie, &v6_entries);
|
||||
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
fn clear_trie_v4(trie: &mut LpmTrie<MapData, u32, u8>) {
|
||||
let keys: Vec<Key<u32>> = trie.iter().filter_map(|r| r.ok()).map(|(k, _)| k).collect();
|
||||
for key in keys {
|
||||
fn remove_stale_v4(trie: &mut LpmTrie<MapData, u32, u8>, desired: &HashSet<(u32, u32)>) {
|
||||
let stale: Vec<Key<u32>> = trie
|
||||
.iter()
|
||||
.filter_map(|entry| entry.ok())
|
||||
.map(|(key, _)| key)
|
||||
.filter(|key| !desired.contains(&(key.data(), key.prefix_len())))
|
||||
.collect();
|
||||
for key in stale {
|
||||
let _ = trie.remove(&key);
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_trie_v6(trie: &mut LpmTrie<MapData, u128, u8>) {
|
||||
let keys: Vec<Key<u128>> = trie.iter().filter_map(|r| r.ok()).map(|(k, _)| k).collect();
|
||||
for key in keys {
|
||||
fn remove_stale_v6(trie: &mut LpmTrie<MapData, u128, u8>, desired: &HashSet<(u128, u32)>) {
|
||||
let stale: Vec<Key<u128>> = trie
|
||||
.iter()
|
||||
.filter_map(|entry| entry.ok())
|
||||
.map(|(key, _)| key)
|
||||
.filter(|key| !desired.contains(&(key.data(), key.prefix_len())))
|
||||
.collect();
|
||||
for key in stale {
|
||||
let _ = trie.remove(&key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_geoip_network(cidr: &str) -> Result<IpNetwork, Error> {
|
||||
let network = cidr
|
||||
.parse::<IpNetwork>()
|
||||
.map_err(|err| EbpfError::InvalidGeoIpCidr(cidr, err))?;
|
||||
Ok(network)
|
||||
}
|
||||
|
||||
impl GeoBlockPort for GeoBlock {
|
||||
fn list_blocked(&self) -> Vec<String> {
|
||||
self.get_blocked_countries()
|
||||
@ -210,3 +262,39 @@ impl GeoBlockPort for GeoBlock {
|
||||
self.unblock_countries(codes)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use arc_swap::ArcSwap;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn unavailable_geo_block() -> GeoBlock {
|
||||
GeoBlock::unavailable(Arc::new(ArcSwap::from_pointee(AppConfig::defaults())))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_does_not_publish_state_when_rebuild_fails() {
|
||||
let geo_block = unavailable_geo_block();
|
||||
|
||||
let result = geo_block.block_countries(&["US".to_string()]);
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(geo_block.get_blocked_countries().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unblock_does_not_publish_state_when_rebuild_fails() {
|
||||
let geo_block = unavailable_geo_block();
|
||||
geo_block
|
||||
.blocked_countries
|
||||
.store(Arc::new(HashSet::from(["US".to_string()])));
|
||||
|
||||
let result = geo_block.unblock_countries(&["US".to_string()]);
|
||||
|
||||
assert!(result.is_err());
|
||||
assert_eq!(geo_block.get_blocked_countries(), vec!["US".to_string()]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,7 +11,6 @@ use arc_swap::ArcSwap;
|
||||
use aya::Ebpf;
|
||||
use aya::maps::{MapData, RingBuf};
|
||||
use crossbeam::queue::SegQueue;
|
||||
use macros::log;
|
||||
use parking_lot::Mutex;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
@ -21,12 +20,11 @@ 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::common::error::Error;
|
||||
use crate::domain::common::config::AppConfig;
|
||||
use crate::domain::common::error::Error;
|
||||
use crate::domain::common::error::system::SystemError;
|
||||
use crate::domain::data_plane::error::EbpfError;
|
||||
use crate::interface::dns_query_filter::DnsQueryFilter;
|
||||
use crate::interface::packet_sink::PacketSinkFactory;
|
||||
use crate::interface::data_plane::dns_query_filter::DnsQueryFilter;
|
||||
use crate::interface::data_plane::packet_sink::PacketSinkFactory;
|
||||
|
||||
pub struct EbpfServices {
|
||||
pub xsk_manager: Arc<XskManager>,
|
||||
@ -105,9 +103,7 @@ impl EbpfServices {
|
||||
|
||||
pub fn terminate(self: Arc<Self>) {
|
||||
while let Some(shutdown) = self.shutdowns.pop() {
|
||||
if shutdown.send(()).is_err() {
|
||||
log!(SystemError::ShutdownSignalFailed);
|
||||
}
|
||||
let _ = shutdown.send(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,15 +3,17 @@ use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, SocketAddrV4, SocketAddrV
|
||||
|
||||
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 net_guardia_abi::model::empty::EmptyMapValue;
|
||||
use net_guardia_abi::model::http_method::{HttpMethod, HttpMethodBitmap};
|
||||
use net_guardia_abi::model::ip_address::{AddrPortV4, AddrPortV6, IPv4, IPv6};
|
||||
use parking_lot::RwLock;
|
||||
|
||||
use crate::domain::common::error::Error;
|
||||
use crate::common::error::Error;
|
||||
use crate::domain::data_plane::error::EbpfError;
|
||||
use crate::domain::data_plane::ip_address::NativeConvert;
|
||||
use crate::interface::protocol_filter::{IpVersion, ProtocolFilterPort};
|
||||
use crate::domain::data_plane::ip_version::IpVersion;
|
||||
use crate::interface::data_plane::protocol_filter::HttpFilterPort;
|
||||
use crate::interface::data_plane::protocol_filter::SshFilterPort;
|
||||
|
||||
pub struct ProtocolFilter {
|
||||
ipv4_http_service: RwLock<HttpServiceWrapper<AddrPortV4>>,
|
||||
@ -84,7 +86,7 @@ fn require_v6_ip(ip: IpAddr) -> Result<Ipv6Addr, Error> {
|
||||
}
|
||||
}
|
||||
|
||||
impl ProtocolFilterPort for ProtocolFilter {
|
||||
impl HttpFilterPort for ProtocolFilter {
|
||||
fn get_http_service(&self, version: IpVersion) -> HashMap<SocketAddr, Vec<HttpMethod>> {
|
||||
match version {
|
||||
IpVersion::V4 => self
|
||||
@ -134,7 +136,9 @@ impl ProtocolFilterPort for ProtocolFilter {
|
||||
.remove_http_service(require_v6_socket(address)?, methods),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SshFilterPort for ProtocolFilter {
|
||||
fn is_ssh_white_list_enable(&self) -> bool {
|
||||
self.ssh_white_list_enable.read().is_white_list_enable()
|
||||
}
|
||||
@ -248,7 +252,7 @@ impl ProtocolFilterPort for ProtocolFilter {
|
||||
}
|
||||
|
||||
struct WhiteListControl {
|
||||
map: Option<AyaArray<MapData, PlaceHolder>>,
|
||||
map: Option<AyaArray<MapData, EmptyMapValue>>,
|
||||
}
|
||||
|
||||
impl WhiteListControl {
|
||||
@ -273,14 +277,16 @@ impl WhiteListControl {
|
||||
}
|
||||
|
||||
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(())
|
||||
self.set_white_list(true)
|
||||
}
|
||||
|
||||
fn disable_white_list(&mut self) -> Result<(), Error> {
|
||||
self.set_white_list(false)
|
||||
}
|
||||
|
||||
fn set_white_list(&mut self, enabled: bool) -> Result<(), Error> {
|
||||
let map = self.map.as_mut().ok_or(EbpfError::NotLoaded)?;
|
||||
map.set(0, 0_u8, 0).map_err(EbpfError::MapOperationError)?;
|
||||
map.set(0, u8::from(enabled), 0).map_err(EbpfError::MapOperationError)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@ -343,7 +349,7 @@ impl<T: NativeConvert + Pod> HttpServiceWrapper<T> {
|
||||
}
|
||||
|
||||
struct EntryMap<T> {
|
||||
map: Option<AyaHashMap<MapData, T, PlaceHolder>>,
|
||||
map: Option<AyaHashMap<MapData, T, EmptyMapValue>>,
|
||||
}
|
||||
|
||||
impl<T: NativeConvert + Pod> EntryMap<T> {
|
||||
|
||||
@ -2,9 +2,9 @@ use aya::Ebpf;
|
||||
use aya::maps::{Array, MapData};
|
||||
use parking_lot::Mutex;
|
||||
|
||||
use crate::domain::common::error::Error;
|
||||
use crate::common::error::Error;
|
||||
use crate::domain::data_plane::error::EbpfError;
|
||||
use crate::interface::rate_limit_api::RateLimitPort;
|
||||
use crate::interface::data_plane::rate_limit_api::RateLimitPort;
|
||||
|
||||
pub struct RateLimitConfig {
|
||||
config_map: Mutex<Option<Array<MapData, u64>>>,
|
||||
@ -25,6 +25,13 @@ impl RateLimitConfig {
|
||||
}
|
||||
}
|
||||
|
||||
fn get_at(&self, index: u32) -> Result<u64, Error> {
|
||||
let guard = self.config_map.lock();
|
||||
let map = guard.as_ref().ok_or(EbpfError::NotLoaded)?;
|
||||
let value = map.get(&index, 0).map_err(EbpfError::MapOperationError)?;
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
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)?;
|
||||
@ -32,32 +39,6 @@ impl RateLimitConfig {
|
||||
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)
|
||||
}
|
||||
@ -77,29 +58,29 @@ impl RateLimitConfig {
|
||||
pub fn get_window_ns(&self) -> Result<u64, Error> {
|
||||
self.get_at(4)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
@ -119,4 +100,24 @@ impl RateLimitPort for RateLimitConfig {
|
||||
fn get_window_ns(&self) -> Result<u64, Error> {
|
||||
self.get_window_ns()
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,31 +4,31 @@ use std::num::NonZero;
|
||||
use std::os::fd::AsRawFd;
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use arc_swap::ArcSwap;
|
||||
use aya::Ebpf;
|
||||
use aya::maps::{MapData, XskMap};
|
||||
use common::define::drop_reason::DROP_REASON_DNS_BLACKLIST;
|
||||
use crossbeam::channel::{Receiver, Sender, TrySendError, bounded};
|
||||
use crossbeam::queue::SegQueue;
|
||||
use macros::log;
|
||||
use net_guardia_abi::define::drop_reason::DROP_REASON_DNS_BLACKLIST;
|
||||
use parking_lot::Mutex;
|
||||
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::adapter::ebpf::drop_monitor::DropMonitor;
|
||||
use crate::common::error::Error;
|
||||
use crate::common::error::system::SystemError;
|
||||
use crate::common::utils::packet_parser::parse_packet_at;
|
||||
use crate::domain::common::config::AppConfig;
|
||||
use crate::domain::common::config::ebpf::EbpfConfig;
|
||||
use crate::domain::common::error::Error;
|
||||
use crate::domain::common::error::system::SystemError;
|
||||
use crate::domain::data_plane::direction::Direction;
|
||||
use crate::domain::data_plane::error::EbpfError;
|
||||
use crate::domain::data_plane::log::EbpfLog;
|
||||
use crate::interface::dns_query_filter::DnsQueryFilter;
|
||||
use crate::interface::packet_sink::{PacketSink, PacketSinkFactory};
|
||||
use crate::utils::packet_parser::parse_packet;
|
||||
use crate::interface::data_plane::dns_query_filter::DnsQueryFilter;
|
||||
use crate::interface::data_plane::packet_sink::{PacketSink, PacketSinkFactory};
|
||||
|
||||
struct BufferPool {
|
||||
buffers: Vec<Vec<u8>>,
|
||||
@ -102,10 +102,6 @@ impl XskManager {
|
||||
drop_monitor: Option<Arc<DropMonitor>>,
|
||||
shutdowns: &SegQueue<oneshot::Sender<()>>,
|
||||
) -> Result<(), Error> {
|
||||
// todo need to check logic
|
||||
// If eBPF failed to load, there are no XSK maps to bind and no queues
|
||||
// to start — skip silently. AF_XDP would have no maps to attach sockets
|
||||
// to, and ML sees no packets, which is the designed behaviour.
|
||||
if self.ingress_xsk_map.lock().is_none() || self.egress_xsk_map.lock().is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
@ -200,14 +196,24 @@ impl XskPair {
|
||||
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)?;
|
||||
let ifname_field = match direction {
|
||||
Direction::Ingress => "ebpf.ingress_ifname",
|
||||
Direction::Egress => "ebpf.egress_ifname",
|
||||
};
|
||||
let rx_ifname_c = CString::new(rx_ifname).map_err(|_| SystemError::InvalidConfigField(ifname_field))?;
|
||||
|
||||
let fill_queue_size = QueueSize::new(config.fill_queue_size).map_err(|_| SystemError::InvalidConfig)?;
|
||||
let comp_queue_size = QueueSize::new(config.comp_queue_size).map_err(|_| SystemError::InvalidConfig)?;
|
||||
let tx_queue_size = QueueSize::new(config.tx_queue_size).map_err(|_| SystemError::InvalidConfig)?;
|
||||
let rx_queue_size = QueueSize::new(config.rx_queue_size).map_err(|_| SystemError::InvalidConfig)?;
|
||||
let frame_size = FrameSize::new(config.frame_size).map_err(|_| SystemError::InvalidConfig)?;
|
||||
let frame_count = NonZero::new(config.frame_count).ok_or(SystemError::InvalidConfig)?;
|
||||
let fill_queue_size = QueueSize::new(config.fill_queue_size)
|
||||
.map_err(|_| SystemError::InvalidConfigField("ebpf.fill_queue_size"))?;
|
||||
let comp_queue_size = QueueSize::new(config.comp_queue_size)
|
||||
.map_err(|_| SystemError::InvalidConfigField("ebpf.comp_queue_size"))?;
|
||||
let tx_queue_size =
|
||||
QueueSize::new(config.tx_queue_size).map_err(|_| SystemError::InvalidConfigField("ebpf.tx_queue_size"))?;
|
||||
let rx_queue_size =
|
||||
QueueSize::new(config.rx_queue_size).map_err(|_| SystemError::InvalidConfigField("ebpf.rx_queue_size"))?;
|
||||
let frame_size =
|
||||
FrameSize::new(config.frame_size).map_err(|_| SystemError::InvalidConfigField("ebpf.frame_size"))?;
|
||||
let frame_count =
|
||||
NonZero::new(config.frame_count).ok_or(SystemError::InvalidConfigField("ebpf.frame_count"))?;
|
||||
|
||||
let umem_config = UmemConfig::builder()
|
||||
.fill_queue_size(fill_queue_size)
|
||||
@ -231,7 +237,7 @@ impl XskPair {
|
||||
let (tx, rx, queue) =
|
||||
unsafe { Socket::new(socket_config, &umem, &interface, queue_id).map_err(EbpfError::SocketSetFailed)? };
|
||||
|
||||
let (mut fill_queue, comp_queue) = queue.ok_or(EbpfError::UnknownError)?;
|
||||
let (mut fill_queue, comp_queue) = queue.ok_or(EbpfError::AfXdpQueueUnavailable(direction, queue_id))?;
|
||||
|
||||
let total_frames = frame_descs.len();
|
||||
let fill_frames_count = (total_frames / 2).min(config.fill_queue_size as usize);
|
||||
@ -240,7 +246,7 @@ impl XskPair {
|
||||
|
||||
let submitted = unsafe { fill_queue.produce(&fill_frames) };
|
||||
if submitted != fill_frames.len() {
|
||||
Err(EbpfError::FillQueueInitFailed)?;
|
||||
Err(EbpfError::FillQueueInitIncomplete(submitted, fill_frames.len()))?;
|
||||
}
|
||||
|
||||
let pool_frames: Vec<FrameDesc> = frame_descs.iter().skip(fill_frames_count).copied().collect();
|
||||
@ -360,6 +366,7 @@ impl XskPair {
|
||||
|
||||
if rx_count > 0 {
|
||||
let is_ingress = self.direction == Direction::Ingress;
|
||||
let timestamp_us = current_timestamp_us();
|
||||
|
||||
for rx_desc in rx_descs.iter().take(rx_count) {
|
||||
let lengths = rx_desc.lengths();
|
||||
@ -373,29 +380,19 @@ impl XskPair {
|
||||
}
|
||||
|
||||
let raw = &contents[..packet_len];
|
||||
|
||||
// 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
|
||||
&& dns.is_query_blacklisted(raw)
|
||||
{
|
||||
if let Some(ref monitor) = self.drop_monitor {
|
||||
monitor.record_userspace_drop_count_only(DROP_REASON_DNS_BLACKLIST);
|
||||
monitor.record_drop_count(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 sink) = self.sink
|
||||
&& let Some((packet_info, _)) = parse_packet(raw)
|
||||
&& let Some((packet_info, _)) = parse_packet_at(raw, timestamp_us)
|
||||
{
|
||||
sink.process_packet(packet_info, is_ingress);
|
||||
}
|
||||
|
||||
// Clone into pooled buffer for forwarding
|
||||
let mut buf = buffer_pool.get();
|
||||
buf.extend_from_slice(raw);
|
||||
if let Err(e) = forward_tx.try_send(buf) {
|
||||
@ -479,8 +476,6 @@ impl XskPair {
|
||||
}
|
||||
|
||||
let nb_submitted = unsafe { self.tx.produce(&self.tx_frame_buf) };
|
||||
|
||||
// Return unsubmitted frames to pool to prevent frame leak
|
||||
if nb_submitted < self.tx_frame_buf.len() {
|
||||
for frame in self.tx_frame_buf[nb_submitted..].iter() {
|
||||
self.frame_pool.push(*frame);
|
||||
@ -492,18 +487,10 @@ impl XskPair {
|
||||
{
|
||||
log!(EbpfLog::TXWakeupFailed(e.to_string()));
|
||||
}
|
||||
|
||||
// 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));
|
||||
}
|
||||
|
||||
// Return all buffers to pool
|
||||
for pkt in self.tx_packet_buf.drain(..) {
|
||||
buffer_pool.put(pkt);
|
||||
}
|
||||
@ -511,3 +498,10 @@ impl XskPair {
|
||||
Ok(nb_submitted)
|
||||
}
|
||||
}
|
||||
|
||||
fn current_timestamp_us() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_micros() as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
267
net-guardia/src/adapter/flow_trace_store.rs
Normal file
@ -0,0 +1,267 @@
|
||||
use std::fs;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::{self, Write as _};
|
||||
use std::path::Path;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::interface::detection::flow_trace_store::{
|
||||
FLOW_TRACE_FILE_EXT, FLOW_TRACE_FILE_MARKER, FlowTraceFile, FlowTraceOpenFile, FlowTraceStore, FlowTraceWriter,
|
||||
};
|
||||
|
||||
const FLOW_TRACE_CREATE_ATTEMPTS: u64 = 16;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct FsFlowTraceStore;
|
||||
|
||||
impl FlowTraceStore for FsFlowTraceStore {
|
||||
fn ensure_directory(&self, directory: &Path) -> io::Result<()> {
|
||||
fs::create_dir_all(directory)
|
||||
}
|
||||
|
||||
fn create_rotated_writer(&self, directory: &Path, header: &[String]) -> io::Result<FlowTraceOpenFile> {
|
||||
let ts_ns = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos() as u64)
|
||||
.unwrap_or(0);
|
||||
create_rotated_writer_at(directory, header, ts_ns)
|
||||
}
|
||||
|
||||
fn list_files(&self, directory: &Path) -> io::Result<Vec<FlowTraceFile>> {
|
||||
if !directory.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut entries = Vec::new();
|
||||
for dirent in fs::read_dir(directory)? {
|
||||
let dirent = dirent?;
|
||||
let path = dirent.path();
|
||||
if !dirent.file_type()?.is_file() {
|
||||
continue;
|
||||
}
|
||||
let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
if !name.starts_with(FLOW_TRACE_FILE_MARKER) || !name.ends_with(FLOW_TRACE_FILE_EXT) {
|
||||
continue;
|
||||
}
|
||||
let metadata = dirent.metadata()?;
|
||||
let size_bytes = metadata.len();
|
||||
let modified_unix_secs = metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
entries.push(FlowTraceFile {
|
||||
name: name.to_string(),
|
||||
path: path.clone(),
|
||||
size_bytes,
|
||||
modified_unix_secs,
|
||||
});
|
||||
}
|
||||
entries.sort_by_key(|e| parse_timestamp_suffix(&e.name).unwrap_or(u64::MAX));
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
fn enforce_retention_budget(&self, directory: &Path, budget: u64) -> io::Result<()> {
|
||||
let files = self.list_files(directory)?;
|
||||
let total: u64 = files.iter().map(|f| f.size_bytes).sum();
|
||||
if total <= budget {
|
||||
return Ok(());
|
||||
}
|
||||
let mut remaining = total;
|
||||
for file in files {
|
||||
if remaining <= budget {
|
||||
break;
|
||||
}
|
||||
fs::remove_file(&file.path)?;
|
||||
remaining = remaining.saturating_sub(file.size_bytes);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn create_rotated_writer_at(directory: &Path, header: &[String], ts_ns: u64) -> io::Result<FlowTraceOpenFile> {
|
||||
for offset in 0..FLOW_TRACE_CREATE_ATTEMPTS {
|
||||
let candidate_ts = ts_ns.saturating_add(offset);
|
||||
let path = directory.join(format!(
|
||||
"{FLOW_TRACE_FILE_MARKER}{candidate_ts:020}{FLOW_TRACE_FILE_EXT}"
|
||||
));
|
||||
match create_writer(&path, header) {
|
||||
Ok(opened) => return Ok(opened),
|
||||
Err(err) if err.kind() == io::ErrorKind::AlreadyExists => continue,
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::AlreadyExists,
|
||||
"flow trace filename collision budget exhausted",
|
||||
))
|
||||
}
|
||||
|
||||
fn create_writer(path: &Path, header: &[String]) -> io::Result<FlowTraceOpenFile> {
|
||||
let file = OpenOptions::new().create_new(true).write(true).open(path)?;
|
||||
let mut writer: FlowTraceWriter = Box::new(file);
|
||||
let header_line = format!("{}\n", header.join(","));
|
||||
writer.write_all(header_line.as_bytes())?;
|
||||
writer.flush()?;
|
||||
Ok(FlowTraceOpenFile {
|
||||
writer,
|
||||
bytes_written: header_line.len() as u64,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_timestamp_suffix(name: &str) -> Option<u64> {
|
||||
let without_prefix = name.strip_prefix(FLOW_TRACE_FILE_MARKER)?;
|
||||
let without_ext = without_prefix.strip_suffix(FLOW_TRACE_FILE_EXT)?;
|
||||
without_ext.parse::<u64>().ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::env;
|
||||
use std::io::Write as _;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn scratch_dir(tag: &str) -> PathBuf {
|
||||
let dir = env::temp_dir().join(format!("nguardia-flow-trace-store-{tag}-{}", Uuid::new_v4()));
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn write_fake_trace(dir: &Path, ts_ns: u64, bytes: usize) -> PathBuf {
|
||||
let path = dir.join(format!("{FLOW_TRACE_FILE_MARKER}{ts_ns:020}{FLOW_TRACE_FILE_EXT}"));
|
||||
let mut f = fs::File::create(&path).unwrap();
|
||||
f.write_all(&vec![b'a'; bytes]).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_timestamp_suffix_accepts_padded_ns() {
|
||||
assert_eq!(parse_timestamp_suffix("flow-trace-00000000000000000042.csv"), Some(42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_timestamp_suffix_rejects_unrelated_names() {
|
||||
assert!(parse_timestamp_suffix("random.csv").is_none());
|
||||
assert!(parse_timestamp_suffix("flow-trace-hello.csv").is_none());
|
||||
assert!(parse_timestamp_suffix("flow-trace-42.txt").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_returns_files_sorted_oldest_first() {
|
||||
let dir = scratch_dir("list-order");
|
||||
write_fake_trace(&dir, 200, 10);
|
||||
write_fake_trace(&dir, 100, 10);
|
||||
write_fake_trace(&dir, 300, 10);
|
||||
let store = FsFlowTraceStore;
|
||||
let files = store.list_files(&dir).unwrap();
|
||||
let suffixes: Vec<_> = files.iter().map(|f| parse_timestamp_suffix(&f.name).unwrap()).collect();
|
||||
assert_eq!(suffixes, vec![100, 200, 300]);
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_skips_non_flow_trace_files() {
|
||||
let dir = scratch_dir("skip");
|
||||
write_fake_trace(&dir, 42, 10);
|
||||
fs::write(dir.join("not-ours.csv"), b"foo").unwrap();
|
||||
fs::write(dir.join("flow-trace-bad-suffix.txt"), b"foo").unwrap();
|
||||
let store = FsFlowTraceStore;
|
||||
let files = store.list_files(&dir).unwrap();
|
||||
assert_eq!(files.len(), 1);
|
||||
assert_eq!(parse_timestamp_suffix(&files[0].name), Some(42));
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn list_skips_flow_trace_symlinks() {
|
||||
let dir = scratch_dir("skip-symlink");
|
||||
let target = write_fake_trace(&dir, 42, 10);
|
||||
let link = dir.join(format!("{FLOW_TRACE_FILE_MARKER}{:020}{FLOW_TRACE_FILE_EXT}", 43));
|
||||
std::os::unix::fs::symlink(&target, link).unwrap();
|
||||
|
||||
let files = FsFlowTraceStore.list_files(&dir).unwrap();
|
||||
|
||||
assert_eq!(files.len(), 1);
|
||||
assert_eq!(parse_timestamp_suffix(&files[0].name), Some(42));
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_on_missing_dir_returns_empty() {
|
||||
let missing = Path::new("/nonexistent/flow-trace/dir");
|
||||
let store = FsFlowTraceStore;
|
||||
assert!(store.list_files(missing).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retention_budget_removes_oldest_until_under_cap() {
|
||||
let dir = scratch_dir("budget");
|
||||
write_fake_trace(&dir, 100, 1024);
|
||||
write_fake_trace(&dir, 200, 1024);
|
||||
write_fake_trace(&dir, 300, 1024);
|
||||
let store = FsFlowTraceStore;
|
||||
store.enforce_retention_budget(&dir, 1500).unwrap();
|
||||
let remaining = store.list_files(&dir).unwrap();
|
||||
let suffixes: Vec<_> = remaining
|
||||
.iter()
|
||||
.map(|f| parse_timestamp_suffix(&f.name).unwrap())
|
||||
.collect();
|
||||
assert_eq!(suffixes, vec![300]);
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retention_budget_is_noop_when_under_cap() {
|
||||
let dir = scratch_dir("budget-noop");
|
||||
write_fake_trace(&dir, 100, 512);
|
||||
write_fake_trace(&dir, 200, 512);
|
||||
let store = FsFlowTraceStore;
|
||||
store.enforce_retention_budget(&dir, 8192).unwrap();
|
||||
assert_eq!(store.list_files(&dir).unwrap().len(), 2);
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotated_writer_creates_canonical_trace_file_with_header() {
|
||||
let dir = scratch_dir("rotated-writer");
|
||||
let store = FsFlowTraceStore;
|
||||
let opened = store
|
||||
.create_rotated_writer(&dir, &["duration".to_string(), "bytes".to_string()])
|
||||
.unwrap();
|
||||
drop(opened.writer);
|
||||
assert_eq!(opened.bytes_written, "duration,bytes\n".len() as u64);
|
||||
let files = store.list_files(&dir).unwrap();
|
||||
assert_eq!(files.len(), 1);
|
||||
assert!(files[0].name.starts_with(FLOW_TRACE_FILE_MARKER));
|
||||
assert!(files[0].name.ends_with(FLOW_TRACE_FILE_EXT));
|
||||
assert_eq!(fs::read_to_string(&files[0].path).unwrap(), "duration,bytes\n");
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotated_writer_does_not_truncate_existing_trace_on_name_collision() {
|
||||
let dir = scratch_dir("rotated-writer-collision");
|
||||
let existing = write_fake_trace(&dir, 42, 5);
|
||||
|
||||
let opened = create_rotated_writer_at(&dir, &["duration".to_string()], 42).unwrap();
|
||||
drop(opened.writer);
|
||||
|
||||
assert_eq!(fs::read(&existing).unwrap(), vec![b'a'; 5]);
|
||||
let files = FsFlowTraceStore.list_files(&dir).unwrap();
|
||||
let suffixes: Vec<_> = files.iter().map(|f| parse_timestamp_suffix(&f.name).unwrap()).collect();
|
||||
assert_eq!(suffixes, vec![42, 43]);
|
||||
let collision_path = dir.join(format!("{FLOW_TRACE_FILE_MARKER}{:020}{FLOW_TRACE_FILE_EXT}", 43));
|
||||
assert!(matches!(
|
||||
fs::read_to_string(collision_path).as_deref(),
|
||||
Ok("duration\n")
|
||||
));
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
@ -7,9 +7,9 @@ use maxminddb::{MaxMindDbError, Reader, geoip2};
|
||||
use moka::sync::Cache;
|
||||
use tokio::task;
|
||||
|
||||
use crate::common::utils::ip_address;
|
||||
use crate::domain::data_plane::geolocation::GeoLocation;
|
||||
use crate::interface::geo_lookup::GeoLookup;
|
||||
use crate::utils::ip_address;
|
||||
use crate::interface::detection::geo_lookup::GeoLookup;
|
||||
|
||||
pub struct GeoIpService {
|
||||
reader: Arc<Reader<Vec<u8>>>,
|
||||
82
net-guardia/src/adapter/html_report_writer.rs
Normal file
@ -0,0 +1,82 @@
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::io::Write as _;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use chrono::Local;
|
||||
|
||||
use crate::common::error::Error;
|
||||
use crate::common::error::io::IOError;
|
||||
use crate::interface::reporting::html_report_writer::HtmlReportWriter;
|
||||
|
||||
const REPORT_CREATE_ATTEMPTS: u32 = 100;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct FsHtmlReportWriter;
|
||||
|
||||
impl HtmlReportWriter for FsHtmlReportWriter {
|
||||
fn write_html_report(&self, output_dir: &str, html: &str) -> Result<PathBuf, Error> {
|
||||
let timestamp = Local::now().format("%Y%m%d-%H%M%S").to_string();
|
||||
write_html_report_at(&PathBuf::from(output_dir), ×tamp, html)
|
||||
}
|
||||
}
|
||||
|
||||
fn write_html_report_at(output_dir: &PathBuf, timestamp: &str, html: &str) -> Result<PathBuf, Error> {
|
||||
fs::create_dir_all(output_dir).map_err(|e| IOError::CreateDirectoryFailed(output_dir.clone(), e))?;
|
||||
|
||||
for attempt in 0..REPORT_CREATE_ATTEMPTS {
|
||||
let html_path = output_dir.join(report_file_name(timestamp, attempt));
|
||||
let mut file = match fs::OpenOptions::new().create_new(true).write(true).open(&html_path) {
|
||||
Ok(file) => file,
|
||||
Err(err) if err.kind() == io::ErrorKind::AlreadyExists => continue,
|
||||
Err(err) => Err(IOError::WriteFileFailed(html_path.clone(), err))?,
|
||||
};
|
||||
file.write_all(html.as_bytes())
|
||||
.and_then(|()| file.flush())
|
||||
.map_err(|err| IOError::WriteFileFailed(html_path.clone(), err))?;
|
||||
return Ok(html_path);
|
||||
}
|
||||
|
||||
Err(IOError::WriteFileFailed(
|
||||
output_dir.join(report_file_name(timestamp, 0)),
|
||||
io::Error::new(
|
||||
io::ErrorKind::AlreadyExists,
|
||||
"HTML report filename collision budget exhausted",
|
||||
),
|
||||
))?
|
||||
}
|
||||
|
||||
fn report_file_name(timestamp: &str, attempt: u32) -> String {
|
||||
if attempt == 0 {
|
||||
format!("netguardia-report-{timestamp}.html")
|
||||
} else {
|
||||
format!("netguardia-report-{timestamp}-{attempt:02}.html")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::env;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn scratch_dir(tag: &str) -> PathBuf {
|
||||
env::temp_dir().join(format!(
|
||||
"nguardia-html-report-{tag}-{}",
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos()
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_report_writer_does_not_overwrite_same_second_report() {
|
||||
let dir = scratch_dir("collision");
|
||||
let first = write_html_report_at(&dir, "20260507-120000", "<h1>first</h1>").unwrap();
|
||||
let second = write_html_report_at(&dir, "20260507-120000", "<h1>second</h1>").unwrap();
|
||||
|
||||
assert_ne!(first, second);
|
||||
assert_eq!(fs::read_to_string(first).unwrap(), "<h1>first</h1>");
|
||||
assert_eq!(fs::read_to_string(second).unwrap(), "<h1>second</h1>");
|
||||
fs::remove_dir_all(dir).ok();
|
||||
}
|
||||
}
|
||||
@ -1,10 +1,10 @@
|
||||
use actix_web::{HttpResponse, Scope, web};
|
||||
|
||||
use crate::adapter::http::helpers::internal_error;
|
||||
use crate::adapter::http::middleware::extractor::AuthClaims;
|
||||
use crate::adapter::persistence::Database;
|
||||
use crate::domain::common::error::Error;
|
||||
use crate::domain::common::error::database::DatabaseError;
|
||||
use crate::interface::audit::AuditRepo;
|
||||
use crate::common::error::Error;
|
||||
use crate::common::error::database::DatabaseError;
|
||||
use crate::interface::system::audit::AuditRepo;
|
||||
|
||||
pub fn initialize() -> Scope {
|
||||
web::scope("/audit")
|
||||
@ -12,33 +12,13 @@ pub fn initialize() -> Scope {
|
||||
.route("/verify", web::get().to(verify_chain))
|
||||
}
|
||||
|
||||
async fn list_audit_logs(_auth: AuthClaims, db: web::Data<Database>) -> HttpResponse {
|
||||
match db.list_audit_logs().await {
|
||||
Ok(entries) => {
|
||||
let json: Vec<serde_json::Value> = entries
|
||||
.into_iter()
|
||||
.map(|e| {
|
||||
serde_json::json!({
|
||||
"id": e.id,
|
||||
"actor": e.actor,
|
||||
"action": e.action,
|
||||
"detail": e.detail,
|
||||
"created_at": e.created_at,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
HttpResponse::Ok().json(json)
|
||||
}
|
||||
Err(_) => HttpResponse::Ok().json(serde_json::json!([])),
|
||||
async fn list_audit_logs(_auth: AuthClaims, audit: web::Data<dyn AuditRepo>) -> HttpResponse {
|
||||
match audit.list_audit_logs().await {
|
||||
Ok(entries) => HttpResponse::Ok().json(entries),
|
||||
Err(e) => internal_error(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// `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(0).await {
|
||||
Ok((count, _last_id)) => HttpResponse::Ok().json(serde_json::json!({
|
||||
@ -46,11 +26,6 @@ async fn verify_chain(_auth: AuthClaims, audit: web::Data<dyn AuditRepo>) -> Htt
|
||||
"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 {
|
||||
@ -75,3 +50,58 @@ async fn verify_chain(_auth: AuthClaims, audit: web::Data<dyn AuditRepo>) -> Htt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::http::StatusCode;
|
||||
use async_trait::async_trait;
|
||||
|
||||
use super::*;
|
||||
use crate::domain::common::audit::AuditLogEntry;
|
||||
use crate::domain::identity::auth::Claims;
|
||||
|
||||
struct FailingAuditRepo;
|
||||
|
||||
fn test_error() -> Error {
|
||||
DatabaseError::PersistedValueInvalid("audit_log", "detail", "bad").into()
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AuditRepo for FailingAuditRepo {
|
||||
async fn insert_audit_log(&self, _actor: &str, _action: &str, _detail: &str) -> Result<(), Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_audit_logs(&self) -> Result<Vec<AuditLogEntry>, Error> {
|
||||
Err(test_error())
|
||||
}
|
||||
|
||||
async fn list_audit_logs_by_src_ip(&self, _src_ip: &str, _limit: i64) -> Result<Vec<AuditLogEntry>, Error> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn verify_audit_log_chain(&self, _after_id: i64) -> Result<(usize, i64), Error> {
|
||||
Ok((0, 0))
|
||||
}
|
||||
}
|
||||
|
||||
fn claims() -> AuthClaims {
|
||||
AuthClaims(Claims {
|
||||
sub: 1,
|
||||
username: "admin".to_string(),
|
||||
role: "admin".to_string(),
|
||||
permissions: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_audit_logs_returns_500_on_repo_error() {
|
||||
let repo = web::Data::from(Arc::new(FailingAuditRepo) as Arc<dyn AuditRepo>);
|
||||
|
||||
let response = list_audit_logs(claims(), repo).await;
|
||||
|
||||
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,9 +3,11 @@ use std::net::{SocketAddrV4, SocketAddrV6};
|
||||
use actix_web::{HttpResponse, Responder, Scope, web};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::adapter::http::helpers::ok_or_error;
|
||||
use crate::adapter::http::helpers::{bad_request, internal_error, ok_or_error};
|
||||
use crate::common::error::Error;
|
||||
use crate::core::data_plane::acl_service::AclService;
|
||||
use crate::domain::data_plane::direction::FlowDirection;
|
||||
use crate::domain::data_plane::error::EbpfError;
|
||||
use crate::domain::data_plane::list_type::ListType;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@ -85,7 +87,7 @@ async fn block_geo_countries(body: web::Json<CountryCodesRequest>, acl: web::Dat
|
||||
"blocked_countries": acl.get_blocked_countries(),
|
||||
"total_prefixes": total_prefixes,
|
||||
})),
|
||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
|
||||
Err(e) => geo_block_error(e),
|
||||
}
|
||||
}
|
||||
|
||||
@ -96,6 +98,32 @@ async fn unblock_geo_countries(body: web::Json<CountryCodesRequest>, acl: web::D
|
||||
"blocked_countries": acl.get_blocked_countries(),
|
||||
"total_prefixes": total_prefixes,
|
||||
})),
|
||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
|
||||
Err(e) => geo_block_error(e),
|
||||
}
|
||||
}
|
||||
|
||||
fn geo_block_error(error: Error) -> HttpResponse {
|
||||
match &error {
|
||||
Error::Ebpf(EbpfError::InvalidCountryCode { .. }) => bad_request(error),
|
||||
_ => internal_error(error),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use actix_web::http::StatusCode;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn geo_block_validation_errors_are_bad_requests() {
|
||||
let response = geo_block_error(
|
||||
EbpfError::InvalidCountryCode {
|
||||
code: "USA".to_string(),
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,12 +1,16 @@
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
|
||||
use actix_web::{HttpResponse, Responder, Scope, web};
|
||||
use common::model::http_method::HttpMethod;
|
||||
use net_guardia_abi::model::http_method::HttpMethod;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::adapter::http::helpers::ok_or_error;
|
||||
use crate::adapter::http::helpers::{bad_request, internal_error};
|
||||
use crate::common::error::Error;
|
||||
use crate::core::data_plane::dns_filter_service::DnsFilterService;
|
||||
use crate::interface::protocol_filter::{IpVersion, ProtocolFilterPort};
|
||||
use crate::domain::data_plane::error::EbpfError;
|
||||
use crate::domain::data_plane::ip_version::IpVersion;
|
||||
use crate::interface::data_plane::protocol_filter::HttpFilterPort;
|
||||
use crate::interface::data_plane::protocol_filter::SshFilterPort;
|
||||
|
||||
pub fn initialize() -> Scope {
|
||||
web::scope("/filter")
|
||||
@ -48,7 +52,7 @@ async fn add_dns_blacklist(
|
||||
let domains = payload.into_inner().domains;
|
||||
match service.add_domains(&domains).await {
|
||||
Ok(count) => HttpResponse::Ok().json(serde_json::json!({"added": count})),
|
||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
|
||||
Err(e) => dns_filter_error(e),
|
||||
}
|
||||
}
|
||||
|
||||
@ -59,7 +63,33 @@ async fn remove_dns_blacklist(
|
||||
let domains = payload.into_inner().domains;
|
||||
match service.remove_domains(&domains).await {
|
||||
Ok(count) => HttpResponse::Ok().json(serde_json::json!({"removed": count})),
|
||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
|
||||
Err(e) => dns_filter_error(e),
|
||||
}
|
||||
}
|
||||
|
||||
fn dns_filter_error(error: Error) -> HttpResponse {
|
||||
match &error {
|
||||
Error::Ebpf(
|
||||
EbpfError::InvalidDnsDomain { .. }
|
||||
| EbpfError::DnsLabelOutOfRange { .. }
|
||||
| EbpfError::DnsDomainTooLong { .. }
|
||||
| EbpfError::TooManyDnsDomains { .. },
|
||||
) => bad_request(error),
|
||||
_ => internal_error(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn protocol_filter_result(result: Result<(), Error>) -> HttpResponse {
|
||||
match result {
|
||||
Ok(()) => HttpResponse::Ok().finish(),
|
||||
Err(error) => protocol_filter_error(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn protocol_filter_error(error: Error) -> HttpResponse {
|
||||
match &error {
|
||||
Error::Ebpf(EbpfError::IpVersionMismatch { .. }) => bad_request(error),
|
||||
_ => internal_error(error),
|
||||
}
|
||||
}
|
||||
|
||||
@ -96,9 +126,9 @@ fn ssh_blacklist_scope() -> Scope {
|
||||
.route("/{version}", web::delete().to(remove_ssh_black_list))
|
||||
}
|
||||
|
||||
async fn get_http_service(path: web::Path<String>, service: web::Data<dyn ProtocolFilterPort>) -> impl Responder {
|
||||
async fn get_http_service(path: web::Path<String>, service: web::Data<dyn HttpFilterPort>) -> impl Responder {
|
||||
let Some(version) = parse_ip_version(&path) else {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid IP version"}));
|
||||
return bad_request("invalid IP version");
|
||||
};
|
||||
HttpResponse::Ok().json(service.get_http_service(version))
|
||||
}
|
||||
@ -106,30 +136,30 @@ async fn get_http_service(path: web::Path<String>, service: web::Data<dyn Protoc
|
||||
async fn add_http_service(
|
||||
path: web::Path<String>,
|
||||
payload: web::Json<(SocketAddr, Vec<HttpMethod>)>,
|
||||
service: web::Data<dyn ProtocolFilterPort>,
|
||||
service: web::Data<dyn HttpFilterPort>,
|
||||
) -> impl Responder {
|
||||
let Some(version) = parse_ip_version(&path) else {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid IP version"}));
|
||||
return bad_request("invalid IP version");
|
||||
};
|
||||
let (addr, methods) = payload.into_inner();
|
||||
ok_or_error(service.add_http_service(version, addr, methods))
|
||||
protocol_filter_result(service.add_http_service(version, addr, methods))
|
||||
}
|
||||
|
||||
async fn remove_http_service(
|
||||
path: web::Path<String>,
|
||||
payload: web::Json<(SocketAddr, Vec<HttpMethod>)>,
|
||||
service: web::Data<dyn ProtocolFilterPort>,
|
||||
service: web::Data<dyn HttpFilterPort>,
|
||||
) -> impl Responder {
|
||||
let Some(version) = parse_ip_version(&path) else {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid IP version"}));
|
||||
return bad_request("invalid IP version");
|
||||
};
|
||||
let (addr, methods) = payload.into_inner();
|
||||
ok_or_error(service.remove_http_service(version, addr, methods))
|
||||
protocol_filter_result(service.remove_http_service(version, addr, methods))
|
||||
}
|
||||
|
||||
async fn get_ssh_service(path: web::Path<String>, service: web::Data<dyn ProtocolFilterPort>) -> impl Responder {
|
||||
async fn get_ssh_service(path: web::Path<String>, service: web::Data<dyn SshFilterPort>) -> impl Responder {
|
||||
let Some(version) = parse_ip_version(&path) else {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid IP version"}));
|
||||
return bad_request("invalid IP version");
|
||||
};
|
||||
HttpResponse::Ok().json(service.get_ssh_service(version))
|
||||
}
|
||||
@ -137,42 +167,42 @@ async fn get_ssh_service(path: web::Path<String>, service: web::Data<dyn Protoco
|
||||
async fn add_ssh_service(
|
||||
path: web::Path<String>,
|
||||
payload: web::Json<SocketAddr>,
|
||||
service: web::Data<dyn ProtocolFilterPort>,
|
||||
service: web::Data<dyn SshFilterPort>,
|
||||
) -> impl Responder {
|
||||
let Some(version) = parse_ip_version(&path) else {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid IP version"}));
|
||||
return bad_request("invalid IP version");
|
||||
};
|
||||
ok_or_error(service.add_ssh_service(version, payload.into_inner()))
|
||||
protocol_filter_result(service.add_ssh_service(version, payload.into_inner()))
|
||||
}
|
||||
|
||||
async fn remove_ssh_service(
|
||||
path: web::Path<String>,
|
||||
payload: web::Json<SocketAddr>,
|
||||
service: web::Data<dyn ProtocolFilterPort>,
|
||||
service: web::Data<dyn SshFilterPort>,
|
||||
) -> impl Responder {
|
||||
let Some(version) = parse_ip_version(&path) else {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid IP version"}));
|
||||
return bad_request("invalid IP version");
|
||||
};
|
||||
ok_or_error(service.remove_ssh_service(version, payload.into_inner()))
|
||||
protocol_filter_result(service.remove_ssh_service(version, payload.into_inner()))
|
||||
}
|
||||
|
||||
async fn is_ssh_white_list_enable(service: web::Data<dyn ProtocolFilterPort>) -> impl Responder {
|
||||
async fn is_ssh_white_list_enable(service: web::Data<dyn SshFilterPort>) -> impl Responder {
|
||||
HttpResponse::Ok().json(serde_json::json!({
|
||||
"enabled": service.is_ssh_white_list_enable(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn enable_ssh_white_list(service: web::Data<dyn ProtocolFilterPort>) -> impl Responder {
|
||||
ok_or_error(service.enable_ssh_white_list())
|
||||
async fn enable_ssh_white_list(service: web::Data<dyn SshFilterPort>) -> impl Responder {
|
||||
protocol_filter_result(service.enable_ssh_white_list())
|
||||
}
|
||||
|
||||
async fn disable_ssh_white_list(service: web::Data<dyn ProtocolFilterPort>) -> impl Responder {
|
||||
ok_or_error(service.disable_ssh_white_list())
|
||||
async fn disable_ssh_white_list(service: web::Data<dyn SshFilterPort>) -> impl Responder {
|
||||
protocol_filter_result(service.disable_ssh_white_list())
|
||||
}
|
||||
|
||||
async fn get_ssh_white_list(path: web::Path<String>, service: web::Data<dyn ProtocolFilterPort>) -> impl Responder {
|
||||
async fn get_ssh_white_list(path: web::Path<String>, service: web::Data<dyn SshFilterPort>) -> impl Responder {
|
||||
let Some(version) = parse_ip_version(&path) else {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid IP version"}));
|
||||
return bad_request("invalid IP version");
|
||||
};
|
||||
HttpResponse::Ok().json(service.get_ssh_white_list(version))
|
||||
}
|
||||
@ -180,28 +210,28 @@ async fn get_ssh_white_list(path: web::Path<String>, service: web::Data<dyn Prot
|
||||
async fn add_ssh_white_list(
|
||||
path: web::Path<String>,
|
||||
payload: web::Json<IpAddr>,
|
||||
service: web::Data<dyn ProtocolFilterPort>,
|
||||
service: web::Data<dyn SshFilterPort>,
|
||||
) -> impl Responder {
|
||||
let Some(version) = parse_ip_version(&path) else {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid IP version"}));
|
||||
return bad_request("invalid IP version");
|
||||
};
|
||||
ok_or_error(service.add_ssh_white_list(version, payload.into_inner()))
|
||||
protocol_filter_result(service.add_ssh_white_list(version, payload.into_inner()))
|
||||
}
|
||||
|
||||
async fn remove_ssh_white_list(
|
||||
path: web::Path<String>,
|
||||
payload: web::Json<IpAddr>,
|
||||
service: web::Data<dyn ProtocolFilterPort>,
|
||||
service: web::Data<dyn SshFilterPort>,
|
||||
) -> impl Responder {
|
||||
let Some(version) = parse_ip_version(&path) else {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid IP version"}));
|
||||
return bad_request("invalid IP version");
|
||||
};
|
||||
ok_or_error(service.remove_ssh_white_list(version, payload.into_inner()))
|
||||
protocol_filter_result(service.remove_ssh_white_list(version, payload.into_inner()))
|
||||
}
|
||||
|
||||
async fn get_ssh_black_list(path: web::Path<String>, service: web::Data<dyn ProtocolFilterPort>) -> impl Responder {
|
||||
async fn get_ssh_black_list(path: web::Path<String>, service: web::Data<dyn SshFilterPort>) -> impl Responder {
|
||||
let Some(version) = parse_ip_version(&path) else {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid IP version"}));
|
||||
return bad_request("invalid IP version");
|
||||
};
|
||||
HttpResponse::Ok().json(service.get_ssh_black_list(version))
|
||||
}
|
||||
@ -209,21 +239,47 @@ async fn get_ssh_black_list(path: web::Path<String>, service: web::Data<dyn Prot
|
||||
async fn add_ssh_black_list(
|
||||
path: web::Path<String>,
|
||||
payload: web::Json<IpAddr>,
|
||||
service: web::Data<dyn ProtocolFilterPort>,
|
||||
service: web::Data<dyn SshFilterPort>,
|
||||
) -> impl Responder {
|
||||
let Some(version) = parse_ip_version(&path) else {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid IP version"}));
|
||||
return bad_request("invalid IP version");
|
||||
};
|
||||
ok_or_error(service.add_ssh_black_list(version, payload.into_inner()))
|
||||
protocol_filter_result(service.add_ssh_black_list(version, payload.into_inner()))
|
||||
}
|
||||
|
||||
async fn remove_ssh_black_list(
|
||||
path: web::Path<String>,
|
||||
payload: web::Json<IpAddr>,
|
||||
service: web::Data<dyn ProtocolFilterPort>,
|
||||
service: web::Data<dyn SshFilterPort>,
|
||||
) -> impl Responder {
|
||||
let Some(version) = parse_ip_version(&path) else {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid IP version"}));
|
||||
return bad_request("invalid IP version");
|
||||
};
|
||||
ok_or_error(service.remove_ssh_black_list(version, payload.into_inner()))
|
||||
protocol_filter_result(service.remove_ssh_black_list(version, payload.into_inner()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use actix_web::http::StatusCode;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn dns_filter_validation_errors_are_bad_requests() {
|
||||
let response = dns_filter_error(EbpfError::TooManyDnsDomains { max: 1 }.into());
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protocol_filter_ip_version_mismatch_is_bad_request() {
|
||||
let response = protocol_filter_error(
|
||||
EbpfError::IpVersionMismatch {
|
||||
expected: "IPv4".to_string(),
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
use actix_web::{HttpResponse, Responder, Scope, web};
|
||||
use common::define::setting::*;
|
||||
|
||||
use crate::adapter::http::helpers::ok_or_error;
|
||||
use crate::adapter::http::helpers::{bad_request, internal_error, ok_json_or_error};
|
||||
use crate::common::error::Error;
|
||||
use crate::core::data_plane::rate_limit_service::RateLimitService;
|
||||
use crate::domain::common::system::rate_limit_settings::RateLimitSettings;
|
||||
use crate::domain::data_plane::error::EbpfError;
|
||||
|
||||
pub fn initialize() -> Scope {
|
||||
web::scope("/rate-limit")
|
||||
@ -12,15 +13,33 @@ pub fn initialize() -> Scope {
|
||||
}
|
||||
|
||||
async fn get_config(service: web::Data<RateLimitService>) -> impl Responder {
|
||||
HttpResponse::Ok().json(RateLimitSettings {
|
||||
packet_rate: Some(service.config().get_packet_rate().unwrap_or(DEFAULT_PACKET_RATE)),
|
||||
syn_rate: Some(service.config().get_syn_rate().unwrap_or(DEFAULT_SYN_RATE)),
|
||||
udp_rate: Some(service.config().get_udp_rate().unwrap_or(DEFAULT_UDP_RATE)),
|
||||
dns_rate: Some(service.config().get_dns_rate().unwrap_or(DEFAULT_DNS_RATE)),
|
||||
window_ns: Some(service.config().get_window_ns().unwrap_or(DEFAULT_WINDOW_NS)),
|
||||
})
|
||||
ok_json_or_error(service.current_settings())
|
||||
}
|
||||
|
||||
async fn set_config(settings: web::Json<RateLimitSettings>, service: web::Data<RateLimitService>) -> impl Responder {
|
||||
ok_or_error(service.update(&settings.into_inner()).await)
|
||||
match service.update(&settings.into_inner()).await {
|
||||
Ok(()) => HttpResponse::Ok().finish(),
|
||||
Err(error) => rate_limit_error(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn rate_limit_error(error: Error) -> HttpResponse {
|
||||
match &error {
|
||||
Error::Ebpf(EbpfError::InvalidRateLimitValue { .. }) => bad_request(error),
|
||||
_ => internal_error(error),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use actix_web::http::StatusCode;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn invalid_rate_limit_values_are_bad_requests() {
|
||||
let response = rate_limit_error(EbpfError::InvalidRateLimitValue("packet_rate".to_string(), 0_u64).into());
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||