Compare commits

...

2 Commits

Author SHA1 Message Date
2a7f62a461 docs: update README screenshots 2026-05-25 23:55:41 +08:00
6c5641c3d8 feat: update BYO pipeline model support (#21) 2026-05-25 13:21:47 +08:00
422 changed files with 25575 additions and 14436 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 223 KiB

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 126 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 74 KiB

BIN
.github/images/ui/global-attack-map.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 267 KiB

After

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 213 KiB

After

Width:  |  Height:  |  Size: 229 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 182 KiB

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 140 KiB

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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
View File

@ -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
![NetGuardia architecture](.github/images/architecture.png)
## 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).

View File

@ -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))
}

View File

@ -1 +0,0 @@
pub type PlaceHolder = u8;

View File

@ -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 && \

View File

@ -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
View 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"

View File

@ -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 }

View File

@ -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());
}

View File

@ -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() }

View File

@ -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 }

View File

@ -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());
}

View File

@ -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

View File

@ -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),

View File

@ -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

View File

@ -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() }

View File

@ -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
View 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)
}

View File

@ -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)

View File

@ -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"

View File

@ -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();
}
}

View File

@ -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"
}
}
}

View File

@ -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": {}
}

View File

@ -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

View File

@ -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 }

View File

@ -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);
};

View 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]))
}

View File

@ -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)
}

View File

@ -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 {

View File

@ -0,0 +1 @@
pub type EmptyMapValue = u8;

View File

@ -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]);

View File

@ -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;

View File

@ -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,
}

View File

@ -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"

View File

@ -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

View File

@ -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 }

View File

@ -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 };

View File

@ -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);

View File

@ -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));
}
}

View File

@ -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);
}
}

View File

@ -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()]);
}
}

View File

@ -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(());
}
}
}

View File

@ -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> {

View File

@ -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)
}
}

View File

@ -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)
}

View 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();
}
}

View File

@ -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>>>,

View 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), &timestamp, 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();
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

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