feat: update BYO pipeline model support

This commit is contained in:
DaLaw2 2026-05-24 22:24:51 +08:00
parent 84a1c29e1e
commit 449b1a5ac6
391 changed files with 25502 additions and 14397 deletions

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 }

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

View File

@ -1,31 +1,25 @@
use std::path::{Component, Path};
use actix_web::{HttpRequest, HttpResponse, Responder};
use mime_guess::from_path;
use crate::utils::static_files::StaticFiles;
use crate::adapter::http::static_files::StaticFiles;
pub async fn default_route(req: HttpRequest) -> impl Responder {
let path = req.path();
let file_path = if path == "/" {
"web/index.html".to_string()
} else {
format!("web{}", path)
let Some(file_path) = embedded_web_asset_path(path) else {
return HttpResponse::NotFound().body("404 Not Found");
};
// 1. Try exact static file match
if let Some(content) = StaticFiles::get(&file_path) {
let mime_type = from_path(&file_path).first_or_octet_stream();
return HttpResponse::Ok()
.content_type(mime_type.as_ref())
.body(content.data.into_owned());
}
// 2. Has file extension (contains '.') but not found → 404
if path.contains('.') {
return HttpResponse::NotFound().body("404 Not Found");
}
// 3. Clean path → SPA fallback to index.html
match StaticFiles::get("web/index.html") {
Some(page) => HttpResponse::Ok()
.content_type("text/html")
@ -33,3 +27,49 @@ pub async fn default_route(req: HttpRequest) -> impl Responder {
None => HttpResponse::NotFound().body("404 Not Found"),
}
}
fn embedded_web_asset_path(request_path: &str) -> Option<String> {
if request_path == "/" {
return Some("web/index.html".to_string());
}
let relative = request_path.strip_prefix('/').unwrap_or(request_path);
if relative
.split(['/', '\\'])
.any(|segment| segment == "." || segment == "..")
{
return None;
}
if Path::new(relative).components().any(|component| {
matches!(
component,
Component::CurDir | Component::ParentDir | Component::RootDir | Component::Prefix(_)
)
}) {
return None;
}
Some(format!("web/{relative}"))
}
#[cfg(test)]
mod tests {
use super::embedded_web_asset_path;
#[test]
fn embedded_web_asset_path_maps_root_to_index() {
assert_eq!(embedded_web_asset_path("/"), Some("web/index.html".to_string()));
}
#[test]
fn embedded_web_asset_path_maps_normal_static_paths() {
assert_eq!(
embedded_web_asset_path("/assets/app.js"),
Some("web/assets/app.js".to_string())
);
}
#[test]
fn embedded_web_asset_path_rejects_dot_segments() {
assert_eq!(embedded_web_asset_path("/../Cargo.toml"), None);
assert_eq!(embedded_web_asset_path("/assets/./app.js"), None);
}
}

View File

@ -1,26 +1,274 @@
//! HTTP surface for the BYO (bring-your-own-model) Quickstart flow.
//! Exposes read-only metadata that helps an administrator author a
//! valid `manifest.yaml` — principally the `FEATURE_REGISTRY` list,
//! which is the authoritative set of feature names the system will
//! extract and feed to a user-supplied ONNX model.
use std::path::PathBuf;
use std::time::Duration;
use actix_multipart::Multipart;
use actix_web::{HttpResponse, Scope, web};
use arc_swap::ArcSwap;
use serde::Serialize;
use uuid::Uuid;
use crate::adapter::http::detection::model_upload::{
UploadCaps, UploadSummary, cleanup_staging_dir, ingest_multipart, structured_error_response,
};
use crate::adapter::http::middleware::extractor::AuthClaims;
use crate::core::inference::model_promotion::{
ModelValidationReport, PromoteError, StagedModelValidation, validate_staged_model,
};
use crate::domain::common::config::AppConfig;
use crate::domain::common::config::constants::PERMISSION_USERS_ADMIN;
use crate::domain::detection::feature_extractor::feature_registry_names;
use crate::domain::detection::manifest::{
ARTIFACT_KINDS, ATTACK_MAPPING_SOURCES, DETECTION_RULE_TYPES, MANIFEST_VERSION, OUTPUT_ROLES, OUTPUT_SEMANTICS,
PREPROCESSING_TYPES, REQUIRED_MANIFEST_SECTIONS, RUNTIME_ADAPTERS, STAGE_INPUT_SOURCES, STAGE_KINDS,
};
use crate::domain::detection::model_files::{MODELS_DIR, STAGING_SUBDIR};
use crate::infrastructure::model_promotion_deps::ModelPromotionDeps;
const VALIDATE_REQUIRED_PERMISSION: &str = PERMISSION_USERS_ADMIN;
pub fn initialize() -> Scope {
web::scope("/byo").route("/feature-registry", web::get().to(get_feature_registry))
web::scope("/byo")
.route("/feature-registry", web::get().to(get_feature_registry))
.route("/schema", web::get().to(get_schema))
.route("/validate", web::post().to(validate_bundle))
}
/// `GET /api/byo/feature-registry` — list every feature name the
/// manifest validator accepts. Returning this over HTTP lets the
/// BYO Quickstart panel show the authoritative set without shipping
/// duplicated documentation that would drift from the Rust constants.
async fn get_feature_registry(_auth: AuthClaims) -> HttpResponse {
let names = feature_registry_names();
HttpResponse::Ok().json(serde_json::json!({
"count": names.len(),
"features": names,
"entries": names.iter().map(|name| FeatureEntry {
name,
value_type: "f32",
unit: None,
description: None,
stability: "stable",
}).collect::<Vec<_>>(),
}))
}
async fn get_schema(_auth: AuthClaims, app_config: web::Data<ArcSwap<AppConfig>>) -> HttpResponse {
let config = app_config.load();
let caps = UploadCaps::from_config(&config);
HttpResponse::Ok().json(serde_json::json!({
"manifest_version": MANIFEST_VERSION,
"bundle": {
"field": "bundle",
"format": "zip",
"required_files": ["manifest.yaml", "inference_config.json"],
"model_file_extensions": [".onnx"],
},
"limits": {
"bundle_bytes": caps.bundle,
"manifest_bytes": caps.manifest,
"onnx_bytes": caps.onnx,
"sidecar_bytes": caps.scaler,
},
"preprocessing": PREPROCESSING_TYPES,
"output_semantics": OUTPUT_SEMANTICS,
"output_roles": OUTPUT_ROLES,
"detection_rule_types": DETECTION_RULE_TYPES,
"attack_mapping_sources": ATTACK_MAPPING_SOURCES,
"runtime_adapters": RUNTIME_ADAPTERS,
"runtime": {
"pipeline_mode": "dag",
"normal_label": "Normal"
},
"required_manifest_sections": REQUIRED_MANIFEST_SECTIONS,
"artifact_kinds": ARTIFACT_KINDS,
"stage_kinds": STAGE_KINDS,
"stage_input_sources": STAGE_INPUT_SOURCES,
"notes": [
"Only manifest version 1 is accepted.",
"Pipeline topology is declared by stages.depends_on and stage_output inputs.",
"inference_config.json is a preprocessing sidecar; runtime topology, labels, thresholds, and output mapping come from manifest.yaml."
],
}))
}
async fn validate_bundle(
app_config: web::Data<ArcSwap<AppConfig>>,
promotion_deps: web::Data<ModelPromotionDeps>,
claims: AuthClaims,
payload: Multipart,
) -> HttpResponse {
if !claims.permissions.iter().any(|p| p == VALIDATE_REQUIRED_PERMISSION) {
return structured_error_response(
403,
"permission_denied",
"authorization",
format!("model validation requires the {VALIDATE_REQUIRED_PERMISSION} permission"),
"Sign in as an administrator and retry validation.",
);
}
let staging_root = PathBuf::from(MODELS_DIR).join(STAGING_SUBDIR);
let staging_id = Uuid::new_v4().to_string();
let staging_dir = staging_root.join(&staging_id);
let config = app_config.load();
let caps = UploadCaps::from_config(&config);
let batch_size = config.ml.inference.inference_batch_size;
let onnx_load_timeout = Duration::from_secs(config.ml.inference.onnx_load_timeout_secs);
drop(config);
let summary = match ingest_multipart(payload, &staging_dir, caps).await {
Ok(value) => value,
Err(err) => {
cleanup_staging_dir(&staging_dir).await;
return err.into_response();
}
};
let outcome = validate_staged_model(&StagedModelValidation {
staging_dir: &staging_dir,
batch_size,
onnx_load_timeout,
validation_gate: Some(promotion_deps.validation_gate.as_ref()),
model_runtime_loader: promotion_deps.model_runtime_loader.as_ref(),
model_artifact_resolver: promotion_deps.model_artifact_resolver.as_ref(),
model_config_loader: promotion_deps.model_config_loader.as_ref(),
promotion_store: promotion_deps.promotion_store.as_ref(),
})
.await;
cleanup_staging_dir(&staging_dir).await;
match outcome {
Ok(report) => validation_ok_response(staging_id, &summary, report),
Err(err) => validation_failed_response(staging_id, &summary, err),
}
}
fn validation_ok_response(staging_id: String, summary: &UploadSummary, report: ModelValidationReport) -> HttpResponse {
HttpResponse::Ok().json(serde_json::json!({
"valid": true,
"staging_id": staging_id,
"artifact_summary": {
"bundle_bytes": summary.bundle_bytes,
"manifest_bytes": summary.manifest_bytes,
"onnx_bytes": summary.onnx_bytes,
"scaler_bytes": summary.scaler_bytes,
"manifest_name": report.manifest_name,
"adapter_kind": report.adapter_kind,
"manifest_sha256": report.manifest_sha256,
"artifacts": report.artifacts,
},
"diagnostics": [validation_diagnostic(
"info",
"validation_passed",
"bundle",
"Bundle validation passed.",
"This bundle can be promoted.",
)],
}))
}
fn validation_failed_response(staging_id: String, summary: &UploadSummary, err: PromoteError) -> HttpResponse {
let diagnostic = diagnostic_from_promote_error(err);
HttpResponse::Ok().json(serde_json::json!({
"valid": false,
"staging_id": staging_id,
"artifact_summary": {
"bundle_bytes": summary.bundle_bytes,
"manifest_bytes": summary.manifest_bytes,
"onnx_bytes": summary.onnx_bytes,
"scaler_bytes": summary.scaler_bytes,
},
"diagnostics": [diagnostic],
}))
}
fn diagnostic_from_promote_error(err: PromoteError) -> ValidationDiagnostic {
match err {
PromoteError::ManifestInvalid { err } => validation_diagnostic(
"error",
"manifest_invalid",
"manifest.yaml",
format!("Manifest is invalid: {err}"),
"Fix manifest.yaml and validate again.",
),
PromoteError::ValidationFailed { err } => validation_diagnostic(
"error",
"model_validation_failed",
"bundle",
format!("Model failed validation: {err}"),
"Check manifest, sidecar, feature order, and ONNX runtime contract.",
),
PromoteError::StagingIo { operation, err } => validation_diagnostic(
"error",
"staging_io_failed",
"bundle",
format!("Staging IO failed during {operation}: {err}"),
"Check server storage permissions and available space.",
),
PromoteError::PromoteIo { operation, err } => validation_diagnostic(
"error",
"promotion_io_failed",
"bundle",
format!("Promotion IO failed during {operation}: {err}"),
"Retry validation after checking server logs.",
),
PromoteError::AuditDetailSerialize { err } => validation_diagnostic(
"error",
"audit_detail_serialize_failed",
"audit",
format!("Audit detail serialization failed: {err}"),
"Retry after checking server logs.",
),
PromoteError::AuditWrite { err } => validation_diagnostic(
"error",
"audit_write_failed",
"audit",
format!("Audit write failed: {err}"),
"Check database health before promotion.",
),
PromoteError::ConcurrentPromote => validation_diagnostic(
"warning",
"concurrent_promote",
"bundle",
"Another model promote is already in progress.",
"Wait for the current promotion to finish and retry.",
),
PromoteError::ConcurrentValidation => validation_diagnostic(
"warning",
"concurrent_validation",
"bundle",
"Another model validation is already in progress.",
"Wait for the current validation to finish and retry.",
),
}
}
fn validation_diagnostic(
severity: &str,
code: &str,
path: &str,
message: impl Into<String>,
hint: &str,
) -> ValidationDiagnostic {
ValidationDiagnostic {
severity: severity.to_string(),
code: code.to_string(),
path: path.to_string(),
message: message.into(),
hint: hint.to_string(),
}
}
#[derive(Serialize)]
struct FeatureEntry<'a> {
name: &'a str,
value_type: &'a str,
unit: Option<&'a str>,
description: Option<&'a str>,
stability: &'a str,
}
#[derive(Serialize)]
struct ValidationDiagnostic {
severity: String,
code: String,
path: String,
message: String,
hint: String,
}

View File

@ -1,18 +1,14 @@
//! HTTP surface for Flow Trace recording. Exposes the rotated CSV
//! shards the writer thread produces so analysts can pull them for
//! offline training / audit.
//!
//! Range support via `actix_files::NamedFile` — the frontend's download
//! progress bar needs `Content-Range` to show % complete on large files.
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use actix_files::NamedFile;
use actix_web::{HttpRequest, HttpResponse, Responder, Scope, web};
use crate::adapter::flow_trace_store::FsFlowTraceStore;
use crate::adapter::http::helpers::{bad_request, forbidden, internal_error, not_found};
use crate::core::inference::engine::Engine;
use crate::core::inference::traffic_logger::list_flow_trace_files;
use crate::domain::common::config::constants::{FLOW_TRACE_FILE_EXT, FLOW_TRACE_FILE_MARKER};
use crate::interface::detection::flow_trace_store::{FLOW_TRACE_FILE_EXT, FLOW_TRACE_FILE_MARKER, FlowTraceStore};
pub fn initialize() -> Scope {
web::scope("/flow-trace")
@ -20,15 +16,13 @@ pub fn initialize() -> Scope {
.route("/download/{name}", web::get().to(download))
}
/// `GET /api/flow-trace/files` — JSON summary of every rotated CSV in
/// the recording directory. Sorted oldest-first so clients showing a
/// retention list get a stable order.
async fn list_files(engine: web::Data<Engine>) -> impl Responder {
let Some(directory) = flow_trace_directory(&engine) else {
return HttpResponse::Ok().json(serde_json::json!({ "files": [], "enabled": false }));
};
match list_flow_trace_files(&directory) {
let store = FsFlowTraceStore;
match store.list_files(&directory) {
Ok(files) => {
let json_files: Vec<serde_json::Value> = files
.into_iter()
@ -45,50 +39,36 @@ async fn list_files(engine: web::Data<Engine>) -> impl Responder {
"enabled": true,
}))
}
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({
"error": format!("failed to list flow-trace directory: {e}"),
})),
Err(e) => internal_error(format!("failed to list flow-trace directory: {e}")),
}
}
/// `GET /api/flow-trace/download/{name}` — streams a single rotated
/// shard with range support.
async fn download(req: HttpRequest, engine: web::Data<Engine>) -> actix_web::Result<HttpResponse> {
let name = match req.match_info().get("name") {
Some(n) => n.to_string(),
None => {
return Ok(HttpResponse::BadRequest().json(serde_json::json!({
"error": "missing filename path segment",
})));
}
};
async fn download(
req: HttpRequest,
path: web::Path<String>,
engine: web::Data<Engine>,
) -> actix_web::Result<HttpResponse> {
let name = path.into_inner();
if !is_safe_flow_trace_name(&name) {
return Ok(HttpResponse::BadRequest().json(serde_json::json!({
"error": "invalid flow-trace filename",
})));
return Ok(bad_request("invalid flow-trace filename"));
}
let Some(directory) = flow_trace_directory(&engine) else {
return Ok(HttpResponse::NotFound().json(serde_json::json!({
"error": "Flow Trace recording is not enabled",
})));
return Ok(not_found("Flow Trace recording is not enabled"));
};
let file_path = directory.join(&name);
if !file_path.is_file() {
return Ok(HttpResponse::NotFound().json(serde_json::json!({
"error": "flow-trace file not found",
})));
match is_regular_flow_trace_file(&file_path) {
Ok(true) => {}
Ok(false) => return Ok(forbidden("flow-trace file must be a regular file")),
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(not_found("flow-trace file not found")),
Err(e) => return Ok(internal_error(format!("failed to inspect flow-trace file: {e}"))),
}
let named = NamedFile::open_async(&file_path).await?;
Ok(named.into_response(&req))
}
/// Reject anything that isn't a plain `flow-trace-<digits>.csv` entry.
/// Traversal sequences and empty / renamed files get zero chance to
/// escape the recording directory.
pub fn is_safe_flow_trace_name(name: &str) -> bool {
if name.is_empty() || name.contains('/') || name.contains('\\') || name.contains("..") {
return false;
@ -102,13 +82,15 @@ pub fn is_safe_flow_trace_name(name: &str) -> bool {
!suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_digit())
}
/// Resolve the Flow Trace recording directory from the shared
/// `Engine` if the logger is active. Returns `None` when Flow Trace
/// isn't enabled (Dormant state).
fn flow_trace_directory(engine: &web::Data<Engine>) -> Option<PathBuf> {
engine.traffic_logger_directory().map(Path::to_path_buf)
}
fn is_regular_flow_trace_file(path: &Path) -> io::Result<bool> {
let meta = fs::symlink_metadata(path)?;
Ok(meta.file_type().is_file())
}
#[cfg(test)]
mod tests {
use super::*;
@ -141,4 +123,21 @@ mod tests {
assert!(!is_safe_flow_trace_name("flow-trace-abc.csv"));
assert!(!is_safe_flow_trace_name("flow-trace-12abc.csv"));
}
#[cfg(unix)]
#[test]
fn regular_file_check_rejects_symlink() {
let dir = std::env::temp_dir().join(format!("netguardia-flow-trace-symlink-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir(&dir).expect("temp dir");
let target = dir.join("outside.txt");
let link = dir.join("flow-trace-00000000000000000001.csv");
fs::write(&target, b"secret").expect("target");
std::os::unix::fs::symlink(&target, &link).expect("symlink");
let result = is_regular_flow_trace_file(&link).expect("metadata");
fs::remove_dir_all(&dir).expect("cleanup");
assert!(!result);
}
}

View File

@ -1,20 +1,14 @@
//! HTTP surface for fusion-layer observability + incident explain.
//! Metrics handlers read shared atomic counters maintained by the
//! detection orchestrator — they never touch orchestrator state, so a
//! hung dashboard cannot stall the detection pipeline. The explain
//! handler reads the WORM audit chain populated by
//! `publish_fusion_audit` and surfaces a per-IP evidence timeline so
//! analysts can answer "why was this IP blocked?" without parsing
//! logs by hand.
use std::cmp;
use std::net::IpAddr;
use actix_web::{HttpRequest, HttpResponse, Responder, Scope, web};
use actix_web::{HttpResponse, Responder, Scope, web};
use arc_swap::ArcSwap;
use crate::adapter::http::helpers::{bad_request, internal_error};
use crate::core::detection::metrics::FusionMetrics;
use crate::domain::common::audit::AuditLogEntry;
use crate::domain::common::config::AppConfig;
use crate::domain::common::config::constants::FUSION_AUDIT_ACTION;
use crate::interface::audit::AuditRepo;
use crate::interface::system::audit::AuditRepo;
pub fn initialize() -> Scope {
web::scope("/fusion")
@ -22,45 +16,34 @@ pub fn initialize() -> Scope {
.route("/explain/{src_ip}", web::get().to(explain_ip))
}
/// `GET /api/fusion/metrics` — lock-free snapshot of fusion counters and
/// derived rates. Drives the operator dashboard's "how well is fusion
/// working on my network?" view.
async fn get_metrics(metrics: web::Data<FusionMetrics>) -> impl Responder {
HttpResponse::Ok().json(metrics.snapshot())
}
/// `GET /api/fusion/explain/{src_ip}` — per-IP fusion evidence timeline.
/// Scans the WORM audit chain for `fused_threat_emitted` entries that
/// match `src_ip`, returning them oldest-first so the UI can render a
/// chronological "why was this IP blocked" view.
async fn explain_ip(
req: HttpRequest,
path: web::Path<String>,
audit: web::Data<dyn AuditRepo>,
app_config: web::Data<ArcSwap<AppConfig>>,
) -> impl Responder {
let src_ip = match req.match_info().get("src_ip") {
Some(ip) => ip.to_string(),
None => {
return HttpResponse::BadRequest().json(serde_json::json!({
"error": "missing src_ip path segment",
}));
}
let src_ip = path.into_inner();
let src_ip = match src_ip.parse::<IpAddr>() {
Ok(ip) => ip.to_string(),
Err(_) => return bad_request("Invalid source IP address"),
};
let obs = app_config.load().observability.clone();
let entries = match audit
.list_audit_logs_by_action(FUSION_AUDIT_ACTION, obs.fusion_explain_scan_limit)
.await
{
let query_limit = fusion_explain_query_limit(obs.fusion_explain_scan_limit, obs.fusion_explain_response_cap);
let mut entries = match audit.list_audit_logs_by_src_ip(&src_ip, query_limit).await {
Ok(e) => e,
Err(e) => {
return HttpResponse::InternalServerError().json(serde_json::json!({
"error": format!("audit store unavailable: {e}"),
}));
return internal_error(format!("audit store unavailable: {e}"));
}
};
let (matches, truncated) = filter_fusion_evidence_for_ip(&entries, &src_ip, obs.fusion_explain_response_cap);
let truncated = entries.len() > obs.fusion_explain_response_cap;
if truncated {
entries.truncate(obs.fusion_explain_response_cap);
}
let matches: Vec<serde_json::Value> = entries.iter().map(render_fusion_evidence_entry).collect();
HttpResponse::Ok().json(serde_json::json!({
"src_ip": src_ip,
"match_count": matches.len(),
@ -69,19 +52,12 @@ async fn explain_ip(
}))
}
/// Filter audit entries down to the ones whose JSON detail's `src_ip`
/// matches `target_ip`, ordered oldest-first (ascending id). Entries
/// with unparseable detail are dropped silently — the chain is
/// append-only, so a malformed row is an integrity concern for the
/// audit-verify endpoint to surface, not this handler.
///
/// Returns `(entries_up_to_cap, truncated)`. `truncated` is `true` when
/// at least one matching entry was dropped — `matches.len() == cap` does
/// NOT imply truncation, so we look at `cap + 1` candidates and set the
/// flag only when the overflow entry exists.
///
/// Extracted as a free function so tests can cover the filter /
/// ordering / cap behaviour without an in-memory DB.
fn fusion_explain_query_limit(scan_limit: i64, response_cap: usize) -> i64 {
let cap_plus_one = i64::try_from(response_cap.saturating_add(1)).unwrap_or(i64::MAX);
cmp::max(1, cmp::min(scan_limit, cap_plus_one))
}
#[cfg(test)]
pub fn filter_fusion_evidence_for_ip(
entries: &[AuditLogEntry],
target_ip: &str,
@ -96,22 +72,22 @@ pub fn filter_fusion_evidence_for_ip(
if truncated {
filtered.truncate(cap);
}
let rendered = filtered
.into_iter()
.map(|entry| {
let detail: serde_json::Value = serde_json::from_str(&entry.detail).unwrap_or(serde_json::Value::Null);
serde_json::json!({
"id": entry.id,
"actor": entry.actor,
"action": entry.action,
"created_at": entry.created_at,
"detail": detail,
})
})
.collect();
let rendered = filtered.into_iter().map(render_fusion_evidence_entry).collect();
(rendered, truncated)
}
fn render_fusion_evidence_entry(entry: &AuditLogEntry) -> serde_json::Value {
let detail: serde_json::Value = serde_json::from_str(&entry.detail).unwrap_or_default();
serde_json::json!({
"id": entry.id,
"actor": entry.actor,
"action": entry.action,
"created_at": entry.created_at,
"detail": detail,
})
}
#[cfg(test)]
fn detail_matches_src_ip(detail_json: &str, target_ip: &str) -> bool {
let parsed: serde_json::Value = match serde_json::from_str(detail_json) {
Ok(v) => v,
@ -157,7 +133,6 @@ mod tests {
#[test]
fn filter_sorts_oldest_first_even_when_input_is_reversed() {
// Real repo query returns DESC; filter must still hand back ASC.
let entries = [
entry(30, "1.1.1.1", "a"),
entry(10, "1.1.1.1", "b"),
@ -180,8 +155,6 @@ mod tests {
#[test]
fn filter_exactly_cap_is_not_truncated() {
// Regression guard: `matches.len() == cap` with no overflow row must
// return `truncated = false`. Earlier `>=` check mis-flagged this.
let entries: Vec<AuditLogEntry> = (1..=3).map(|i| entry(i, "9.9.9.9", "x")).collect();
let (got, truncated) = filter_fusion_evidence_for_ip(&entries, "9.9.9.9", 3);
assert_eq!(got.len(), 3);

View File

@ -1,7 +1,6 @@
use actix_web::{HttpResponse, Responder, Scope, web};
use crate::infrastructure::health::SystemHealth;
use crate::infrastructure::suricata_manager::SuricataManager;
use crate::interface::system::health_query::{HealthQuery, SuricataHealthQuery};
pub fn initialize() -> Scope {
web::scope("/health")
@ -11,22 +10,18 @@ pub fn initialize() -> Scope {
.route("/suricata", web::get().to(get_suricata_health))
}
async fn get_current_metrics(health: web::Data<SystemHealth>) -> impl Responder {
let metrics = health.get_current_metrics();
HttpResponse::Ok().json(metrics)
async fn get_current_metrics(health: web::Data<dyn HealthQuery>) -> impl Responder {
HttpResponse::Ok().json(health.get_current_metrics())
}
async fn get_health_status(health: web::Data<SystemHealth>) -> impl Responder {
let status = health.is_system_healthy();
HttpResponse::Ok().json(status)
async fn get_health_status(health: web::Data<dyn HealthQuery>) -> impl Responder {
HttpResponse::Ok().json(health.get_health_status())
}
async fn get_ebpf_health(health: web::Data<SystemHealth>) -> impl Responder {
let ebpf = (**health.ebpf_health().load()).clone();
HttpResponse::Ok().json(ebpf)
async fn get_ebpf_health(health: web::Data<dyn HealthQuery>) -> impl Responder {
HttpResponse::Ok().json(health.get_ebpf_health())
}
async fn get_suricata_health(manager: web::Data<SuricataManager>) -> impl Responder {
let state = (**manager.health().load()).clone();
HttpResponse::Ok().json(state)
async fn get_suricata_health(manager: web::Data<dyn SuricataHealthQuery>) -> impl Responder {
HttpResponse::Ok().json(manager.get_suricata_health())
}

View File

@ -1,29 +1,32 @@
use std::sync::Arc;
use actix_web::{HttpResponse, Responder, Scope, web};
use tokio::sync::broadcast;
use arc_swap::ArcSwap;
use macros::log;
use tokio::task;
use crate::adapter::http::middleware::extractor::AuthClaims;
use crate::common::error::codec::CodecError;
use crate::common::log::audit::AuditLog;
use crate::core::inference::engine::Engine;
use crate::core::inference::model_adapter::ModelSourceState;
use crate::core::inference::model_watcher::{ModelReloadOutcome, reload_model_from_disk};
use crate::core::inference::runner::Inference;
use crate::domain::common::config::constants::AUDIT_ACTOR_SECURITY_ADMIN_PREFIX;
use crate::domain::common::event::AuditEvent;
use crate::domain::common::config::AppConfig;
use crate::domain::common::config::constants::{AUDIT_ACTOR_SECURITY_ADMIN_PREFIX, PERMISSION_USERS_ADMIN};
use crate::infrastructure::model_promotion_deps::ModelPromotionDeps;
use crate::interface::system::audit::AuditRepo;
/// Permission required to forcibly revert the active ML source to dormant.
/// Mirrors the upload handler's gate so swap-out and revert are symmetric:
/// without this, anyone holding `ai_detection:write` could disable the
/// detector silently while the upload path required `users:admin`.
const DORMANT_REQUIRED_PERMISSION: &str = "users:admin";
/// Action recorded on the WORM chain when the ML source is forced
/// dormant via this endpoint. Stable wire string — UI/audit tooling
/// filters on it, paired with `model_swap` from the upload path.
const MODEL_LIFECYCLE_REQUIRED_PERMISSION: &str = PERMISSION_USERS_ADMIN;
const AUDIT_ACTION_MODEL_DORMANT: &str = "model_dormant";
const AUDIT_ACTION_MODEL_ENABLE: &str = "model_enable";
pub fn initialize() -> Scope {
web::scope("/ml")
.route("/status", web::get().to(get_status))
.route("/models/current", web::get().to(get_current_model))
.route("/models/current", web::delete().to(delete_current_model))
.route("/models/current/enable", web::post().to(enable_current_model))
}
async fn get_status(engine: web::Data<Engine>) -> impl Responder {
@ -41,8 +44,6 @@ async fn get_status(engine: web::Data<Engine>) -> impl Responder {
}))
}
/// `GET /api/ml/models/current` — wire-format snapshot of the ML source
/// state the dashboard's ML Status panel renders.
async fn get_current_model(inference: web::Data<Inference>) -> impl Responder {
let status = inference.model_source_status();
let label = if status.is_active() {
@ -58,19 +59,18 @@ async fn get_current_model(inference: web::Data<Inference>) -> impl Responder {
}))
}
/// `DELETE /api/ml/models/current` — admin action: force the ML source back
/// to dormant. No-op when already dormant so the client can retry idempotently.
/// Requires `users:admin` (see `DORMANT_REQUIRED_PERMISSION`) and emits a
/// WORM `model_dormant` audit entry capturing the pre-revert state, mirroring
/// the upload path's `model_swap` so both swap-in and revert are auditable.
async fn delete_current_model(
inference: web::Data<Inference>,
audit_tx: web::Data<broadcast::Sender<AuditEvent>>,
audit_repo: web::Data<dyn AuditRepo>,
claims: AuthClaims,
) -> impl Responder {
if !claims.permissions.iter().any(|p| p == DORMANT_REQUIRED_PERMISSION) {
if !claims
.permissions
.iter()
.any(|p| p == MODEL_LIFECYCLE_REQUIRED_PERMISSION)
{
return HttpResponse::Forbidden().json(serde_json::json!({
"error": format!("model dormant requires the {DORMANT_REQUIRED_PERMISSION} permission"),
"error": format!("model dormant requires the {MODEL_LIFECYCLE_REQUIRED_PERMISSION} permission"),
}));
}
@ -81,19 +81,495 @@ async fn delete_current_model(
}));
}
inference.swap_state(ModelSourceState::Dormant);
let before_json = match serde_json::to_value(&before_status) {
Ok(value) => value,
Err(err) => {
log!(CodecError::SerializeFailed(err));
return HttpResponse::InternalServerError().json(serde_json::json!({
"error": "failed to serialize current model status",
}));
}
};
let audit_detail = serde_json::json!({
"before": serde_json::to_value(&before_status).unwrap_or(serde_json::Value::Null),
"before": before_json,
})
.to_string();
let _ = audit_tx.send(AuditEvent {
actor: format!("{AUDIT_ACTOR_SECURITY_ADMIN_PREFIX}@{}", claims.username),
action: AUDIT_ACTION_MODEL_DORMANT.to_string(),
detail: audit_detail,
});
let audit_actor = format!("{AUDIT_ACTOR_SECURITY_ADMIN_PREFIX}@{}", claims.username);
if let Err(err) = audit_repo
.insert_audit_log(&audit_actor, AUDIT_ACTION_MODEL_DORMANT, &audit_detail)
.await
{
log!(AuditLog::AuditDbWriteFailed(
err.to_string(),
audit_actor,
AUDIT_ACTION_MODEL_DORMANT.to_string()
));
return HttpResponse::InternalServerError().json(serde_json::json!({
"error": "required audit write failed",
}));
}
log!(AuditLog::AuditEvent(
audit_actor,
AUDIT_ACTION_MODEL_DORMANT.to_string(),
));
inference.swap_state(ModelSourceState::Dormant);
HttpResponse::Ok().json(serde_json::json!({
"already_dormant": false,
}))
}
async fn enable_current_model(
inference: web::Data<Inference>,
app_config: web::Data<ArcSwap<AppConfig>>,
promotion_deps: web::Data<ModelPromotionDeps>,
audit_repo: web::Data<dyn AuditRepo>,
claims: AuthClaims,
) -> impl Responder {
if !claims
.permissions
.iter()
.any(|p| p == MODEL_LIFECYCLE_REQUIRED_PERMISSION)
{
return HttpResponse::Forbidden().json(serde_json::json!({
"error": format!("model enable requires the {MODEL_LIFECYCLE_REQUIRED_PERMISSION} permission"),
}));
}
let before_state = inference.model_source_state();
let before_status = before_state.to_status();
let before_json = match serde_json::to_value(&before_status) {
Ok(value) => value,
Err(err) => {
log!(CodecError::SerializeFailed(err));
return HttpResponse::InternalServerError().json(serde_json::json!({
"error": "failed to serialize current model status",
}));
}
};
let inference = inference.into_inner();
let app_config = app_config.into_inner();
let promotion_deps = promotion_deps.into_inner();
let reload_inference = Arc::clone(&inference);
let reload_config = Arc::clone(&app_config);
let runtime_loader = Arc::clone(&promotion_deps.model_runtime_loader);
let artifact_resolver = Arc::clone(&promotion_deps.model_artifact_resolver);
let config_loader = Arc::clone(&promotion_deps.model_config_loader);
let outcome = match task::spawn_blocking(move || {
reload_model_from_disk(
&reload_inference,
&reload_config,
runtime_loader.as_ref(),
artifact_resolver.as_ref(),
config_loader.as_ref(),
)
})
.await
{
Ok(outcome) => outcome,
Err(err) => {
return HttpResponse::InternalServerError().json(serde_json::json!({
"error": format!("model enable task failed: {err}"),
}));
}
};
match outcome {
ModelReloadOutcome::Active { info } => {
let audit_detail = serde_json::json!({
"before": before_json,
"after": {
"name": info.name(),
"adapter_kind": info.adapter_kind(),
"loaded_at_secs": info.loaded_at_secs,
"features_count": info.features_count(),
},
})
.to_string();
let audit_actor = format!("{AUDIT_ACTOR_SECURITY_ADMIN_PREFIX}@{}", claims.username);
if let Err(err) = audit_repo
.insert_audit_log(&audit_actor, AUDIT_ACTION_MODEL_ENABLE, &audit_detail)
.await
{
inference.swap_state(before_state);
log!(AuditLog::AuditDbWriteFailed(
err.to_string(),
audit_actor,
AUDIT_ACTION_MODEL_ENABLE.to_string()
));
return HttpResponse::InternalServerError().json(serde_json::json!({
"error": "required audit write failed",
}));
}
log!(AuditLog::AuditEvent(audit_actor, AUDIT_ACTION_MODEL_ENABLE.to_string(),));
HttpResponse::Ok().json(serde_json::json!({
"enabled": true,
"label": "active",
"status": inference.model_source_status(),
}))
}
ModelReloadOutcome::Dormant => HttpResponse::NotFound().json(serde_json::json!({
"enabled": false,
"label": "dormant",
"error": "models/manifest.yaml is missing",
"status": inference.model_source_status(),
})),
ModelReloadOutcome::Error {
msg,
last_attempted_path,
} => HttpResponse::UnprocessableEntity().json(serde_json::json!({
"enabled": false,
"label": "error",
"error": msg,
"last_attempted_path": last_attempted_path,
"status": inference.model_source_status(),
})),
}
}
#[cfg(test)]
mod tests {
use std::collections::{BTreeMap, HashMap};
use std::fs;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use std::time::SystemTime;
use actix_web::http::StatusCode;
use async_trait::async_trait;
use super::*;
use crate::adapter::model_promotion_store::FsModelPromotionStore;
use crate::common::error::Error;
use crate::common::error::database::DatabaseError;
use crate::core::inference::model_promotion::PromoteGate;
use crate::core::inference::runner::Inference;
use crate::domain::common::audit::AuditLogEntry;
use crate::domain::detection::ml_detection::ClipParams;
use crate::domain::detection::ml_inference_config::MLInferenceConfig;
use crate::domain::detection::model_source::ModelSourceStatus;
use crate::domain::detection::{
error::MLError,
manifest::{
AlertRuleSpec, ArtifactKind, ArtifactSpec, AttackMappingSpec, DetectionRuleSpec, LabelSpec, ModelManifest,
OutputHeadSpec, OutputRole, OutputSemantic, PipelineOutputSpec, PreprocessingStep, RuntimeSpec,
StageInputSource, StageInputSpec, StageKind, StageSpec,
},
};
use crate::domain::identity::auth::Claims;
use crate::interface::detection::model_artifact_resolver::ModelArtifactResolver;
use crate::interface::detection::model_config_loader::ModelConfigLoader;
use crate::interface::detection::model_runtime::{ModelRuntime, ModelRuntimeLoader, RuntimeTensor};
struct FailingAuditRepo;
#[async_trait]
impl AuditRepo for FailingAuditRepo {
async fn insert_audit_log(&self, _actor: &str, _action: &str, _detail: &str) -> Result<(), Error> {
Err(DatabaseError::PersistedValueInvalid("audit_log", "detail", "forced").into())
}
async fn list_audit_logs(&self) -> Result<Vec<AuditLogEntry>, Error> {
Ok(Vec::new())
}
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![MODEL_LIFECYCLE_REQUIRED_PERMISSION.to_string()],
})
}
fn test_inference_config() -> MLInferenceConfig {
MLInferenceConfig {
ae_feature_names: vec!["Destination Port".to_string()],
ae_clip_params: HashMap::from([(
"Destination Port".to_string(),
ClipParams {
lower: 0.0,
upper: 65_535.0,
},
)]),
ae_scaler_mean: vec![0.0],
ae_scaler_std: vec![1.0],
ae_post_clip_min: -5.0,
ae_post_clip_max: 5.0,
classifier_feature_names: vec!["Destination Port".to_string()],
minmax_params: HashMap::new(),
robust_params: HashMap::new(),
quantile_params: HashMap::new(),
}
}
fn test_manifest() -> ModelManifest {
ModelManifest {
name: "test-model".to_string(),
version: 1,
runtime: RuntimeSpec {
pipeline_mode: "dag".to_string(),
normal_label: "Normal".to_string(),
},
artifacts: vec![
ArtifactSpec {
id: "ae_onnx".to_string(),
file: "deep_autoencoder.onnx".to_string(),
kind: ArtifactKind::Onnx,
},
ArtifactSpec {
id: "classifier_onnx".to_string(),
file: "classifier.onnx".to_string(),
kind: ArtifactKind::Onnx,
},
ArtifactSpec {
id: "sidecar".to_string(),
file: "sidecar.json".to_string(),
kind: ArtifactKind::Sidecar,
},
],
stages: vec![
StageSpec {
id: "anomaly_detector".to_string(),
kind: StageKind::Autoencoder,
model_file: "deep_autoencoder.onnx".to_string(),
depends_on: vec![],
inputs: vec![StageInputSpec {
name: "Destination Port".to_string(),
source: StageInputSource::Feature,
stage: None,
output: None,
}],
preprocessing: vec![PreprocessingStep::StandardScaler {
sidecar: "sidecar.json".to_string(),
}],
output_heads: vec![OutputHeadSpec {
name: "ae_anomaly_score".to_string(),
index: 0,
shape: vec!["1".to_string()],
semantic: OutputSemantic::AnomalyScore,
threshold: Some(0.5),
min_confidence: None,
}],
},
StageSpec {
id: "classifier".to_string(),
kind: StageKind::Classifier,
model_file: "classifier.onnx".to_string(),
depends_on: vec!["anomaly_detector".to_string()],
inputs: vec![
StageInputSpec {
name: "Destination Port".to_string(),
source: StageInputSource::Feature,
stage: None,
output: None,
},
StageInputSpec {
name: "ae_anomaly_score".to_string(),
source: StageInputSource::StageOutput,
stage: Some("anomaly_detector".to_string()),
output: Some("ae_anomaly_score".to_string()),
},
],
preprocessing: vec![],
output_heads: vec![OutputHeadSpec {
name: "class_probs".to_string(),
index: 0,
shape: vec!["2".to_string()],
semantic: OutputSemantic::Multiclass,
threshold: None,
min_confidence: Some(0.4),
}],
},
],
outputs: vec![PipelineOutputSpec {
stage: "classifier".to_string(),
output: "class_probs".to_string(),
alias: Some("class_probs".to_string()),
role: OutputRole::ClassProbabilities,
}],
detection_rules: vec![DetectionRuleSpec::ClassConfidence {
id: "class_confidence".to_string(),
output: "class_probs".to_string(),
attack: AttackMappingSpec::PredictedClass {
output: "class_probs".to_string(),
exclude_normal: true,
},
}],
labels: BTreeMap::from([(
"0".to_string(),
LabelSpec {
name: "Bot".to_string(),
confirmations: Some(1),
playbook: None,
},
)]),
alert_rules: vec![AlertRuleSpec {
condition: "class_probs.max > min_confidence".to_string(),
source_label: "class_probs".to_string(),
}],
}
}
fn inference_with_error_state() -> web::Data<Inference> {
web::Data::new(Inference::new(
ModelSourceState::Error {
msg: "load failed".to_string(),
since: SystemTime::UNIX_EPOCH,
last_attempted_path: Some(PathBuf::from("models/manifest.yaml")),
},
Arc::new(test_inference_config()),
Arc::new(ArcSwap::from_pointee(AppConfig::defaults())),
))
}
struct FakeRuntime;
impl ModelRuntime for FakeRuntime {
fn run_stage_batch(
&self,
_rows: &[Vec<f32>],
_batch_size: usize,
_n_features: usize,
_stage_kind: StageKind,
_output_heads: &[OutputHeadSpec],
) -> Result<Vec<RuntimeTensor>, MLError> {
Ok(Vec::new())
}
}
struct FakeRuntimeLoader;
impl ModelRuntimeLoader for FakeRuntimeLoader {
fn load(
&self,
_model_path: &Path,
_model_name: &str,
_features: usize,
_batch_size: usize,
_timeout: Duration,
) -> Result<Arc<dyn ModelRuntime>, MLError> {
Ok(Arc::new(FakeRuntime))
}
}
struct FakeArtifactResolver;
impl ModelArtifactResolver for FakeArtifactResolver {
fn resolve_model_path(&self, _manifest_path: Option<&Path>, relative_path: &str) -> PathBuf {
PathBuf::from(relative_path)
}
}
struct FakeConfigLoader;
impl ModelConfigLoader for FakeConfigLoader {
fn load_manifest(&self, _manifest_path: &Path) -> Result<ModelManifest, MLError> {
Ok(test_manifest())
}
fn load_manifest_with_sidecar(
&self,
_manifest_path: &Path,
) -> Result<(MLInferenceConfig, ModelManifest), MLError> {
Ok((test_inference_config(), test_manifest()))
}
}
fn promotion_deps() -> web::Data<ModelPromotionDeps> {
web::Data::new(ModelPromotionDeps {
model_runtime_loader: Arc::new(FakeRuntimeLoader),
model_artifact_resolver: Arc::new(FakeArtifactResolver),
model_config_loader: Arc::new(FakeConfigLoader),
promotion_store: Arc::new(FsModelPromotionStore),
validation_gate: Arc::new(PromoteGate::new()),
})
}
struct ManifestSentinel {
path: PathBuf,
remove_file: bool,
remove_dir: bool,
}
impl Drop for ManifestSentinel {
fn drop(&mut self) {
if self.remove_file {
fs::remove_file(&self.path).ok();
}
if self.remove_dir
&& let Some(parent) = self.path.parent()
{
fs::remove_dir(parent).ok();
}
}
}
fn ensure_manifest_sentinel() -> ManifestSentinel {
let dir = PathBuf::from("models");
let remove_dir = !dir.exists();
fs::create_dir_all(&dir).expect("create test models dir");
let path = dir.join("manifest.yaml");
let remove_file = !path.exists();
if remove_file {
fs::write(&path, b"test manifest sentinel").expect("write test manifest sentinel");
}
ManifestSentinel {
path,
remove_file,
remove_dir,
}
}
#[tokio::test]
async fn delete_current_model_preserves_state_when_required_audit_fails() {
let inference = inference_with_error_state();
let audit_repo = web::Data::from(Arc::new(FailingAuditRepo) as Arc<dyn AuditRepo>);
let req = actix_web::test::TestRequest::default().to_http_request();
let response = delete_current_model(inference.clone(), audit_repo, claims())
.await
.respond_to(&req);
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
match inference.model_source_status() {
ModelSourceStatus::Error { msg, .. } => assert_eq!(msg, "load failed"),
other => panic!("model state should remain error after audit failure, got {other:?}"),
}
}
#[tokio::test]
async fn enable_current_model_rolls_back_state_when_required_audit_fails() {
let _manifest = ensure_manifest_sentinel();
let inference = inference_with_error_state();
let audit_repo = web::Data::from(Arc::new(FailingAuditRepo) as Arc<dyn AuditRepo>);
let app_config = web::Data::from(Arc::new(ArcSwap::from_pointee(AppConfig::defaults())));
let req = actix_web::test::TestRequest::default().to_http_request();
let response = enable_current_model(inference.clone(), app_config, promotion_deps(), audit_repo, claims())
.await
.respond_to(&req);
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
match inference.model_source_status() {
ModelSourceStatus::Error { msg, .. } => assert_eq!(msg, "load failed"),
other => panic!("model state should roll back after audit failure, got {other:?}"),
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
use actix_web::{HttpResponse, Responder, Scope, web};
use crate::core::common::statistics::FlowStatistics;
use crate::interface::drop_stats::DropStatsPort;
use crate::interface::data_plane::drop_stats::DropStatsPort;
pub fn initialize() -> Scope {
web::scope("/stats")
@ -16,8 +16,7 @@ async fn get_all_flows(stats: web::Data<FlowStatistics>) -> impl Responder {
}
async fn get_top_flows(stats: web::Data<FlowStatistics>, path: web::Path<usize>) -> impl Responder {
let n = path.into_inner();
HttpResponse::Ok().json(stats.get_top_flows(n))
HttpResponse::Ok().json(stats.get_top_flows(path.into_inner()))
}
async fn get_summary(stats: web::Data<FlowStatistics>) -> impl Responder {

View File

@ -1,18 +1,43 @@
use std::fmt;
use actix_web::HttpResponse;
use actix_web::http::StatusCode;
use serde::Serialize;
pub fn ok_or_error<T, E: fmt::Display>(result: Result<T, E>) -> HttpResponse {
match result {
Ok(_) => HttpResponse::Ok().finish(),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
Err(e) => internal_error(e),
}
}
pub fn ok_json_or_error<T: Serialize, E: fmt::Display>(result: Result<T, E>) -> HttpResponse {
match result {
Ok(value) => HttpResponse::Ok().json(value),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
Err(e) => internal_error(e),
}
}
pub fn internal_error<E: fmt::Display>(error: E) -> HttpResponse {
json_error(StatusCode::INTERNAL_SERVER_ERROR, error)
}
pub fn bad_request<E: fmt::Display>(error: E) -> HttpResponse {
json_error(StatusCode::BAD_REQUEST, error)
}
pub fn not_found<E: fmt::Display>(error: E) -> HttpResponse {
json_error(StatusCode::NOT_FOUND, error)
}
pub fn forbidden<E: fmt::Display>(error: E) -> HttpResponse {
json_error(StatusCode::FORBIDDEN, error)
}
pub fn conflict<E: fmt::Display>(error: E) -> HttpResponse {
json_error(StatusCode::CONFLICT, error)
}
pub fn json_error<E: fmt::Display>(status: StatusCode, error: E) -> HttpResponse {
HttpResponse::build(status).json(serde_json::json!({"error": error.to_string()}))
}

View File

@ -1,9 +1,14 @@
use actix_web::{HttpResponse, Scope, web};
use rand::RngExt;
use rand::distr::Alphanumeric;
use serde::Deserialize;
use crate::adapter::http::helpers::{bad_request, internal_error, not_found};
use crate::adapter::http::middleware::extractor::AuthClaims;
use crate::domain::identity::auth::PermissionLevel;
use crate::interface::api_key::ApiKeyRepo;
use crate::domain::identity::validation::validate_api_key_name;
use crate::interface::identity::api_key::ApiKeyRepo;
use crate::interface::identity::api_key_hasher::ApiKeyHasher;
pub fn initialize() -> Scope {
web::scope("/api-keys")
@ -29,7 +34,7 @@ async fn list_keys(_auth: AuthClaims, db: web::Data<dyn ApiKeyRepo>) -> HttpResp
.collect();
HttpResponse::Ok().json(responses)
}
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
Err(e) => internal_error(e),
}
}
@ -42,34 +47,35 @@ struct GenerateKeyRequest {
async fn generate_key(
_auth: AuthClaims,
db: web::Data<dyn ApiKeyRepo>,
hasher: web::Data<dyn ApiKeyHasher>,
body: web::Json<GenerateKeyRequest>,
) -> HttpResponse {
use rand::Rng;
use rand::distr::Alphanumeric;
let raw_key: String = rand::rng()
.sample_iter(&Alphanumeric)
.take(32)
.map(char::from)
.collect();
let key_hash = db.hmac_api_key(&raw_key);
let key_hash = hasher.hash_api_key(&raw_key);
let raw_level = body.level.as_deref().unwrap_or("read_only");
let Some(level) = PermissionLevel::from_str(raw_level) else {
return HttpResponse::BadRequest().json(serde_json::json!({
"error": "Invalid permission level. Must be: read_only, read_write, or full_access"
}));
return bad_request("Invalid permission level. Must be: read_only, read_write, or full_access");
};
match db.insert_api_key(&key_hash, &body.name, level.as_str()).await {
let name = body.name.trim();
if let Err(message) = validate_api_key_name(name) {
return bad_request(message);
}
match db.insert_api_key(&key_hash, name, level.as_str()).await {
Ok(id) => HttpResponse::Created().json(serde_json::json!({
"id": id,
"key": raw_key,
"name": body.name,
"name": name,
"permission_level": level.as_str(),
})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
Err(e) => internal_error(e),
}
}
@ -77,7 +83,7 @@ async fn delete_key(_auth: AuthClaims, db: web::Data<dyn ApiKeyRepo>, path: web:
let id = path.into_inner();
match db.delete_api_key(id).await {
Ok(true) => HttpResponse::Ok().json(serde_json::json!({"deleted": true})),
Ok(false) => HttpResponse::NotFound().json(serde_json::json!({"error": "Key not found"})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
Ok(false) => not_found("Key not found"),
Err(e) => internal_error(e),
}
}

View File

@ -1,19 +1,16 @@
use actix_web::{HttpResponse, Responder, Scope, web};
use serde::Deserialize;
use actix_web::http::{StatusCode, header};
use actix_web::{HttpRequest, HttpResponse, HttpResponseBuilder, Responder, Scope, web};
use serde::{Deserialize, Serialize};
use crate::adapter::http::helpers::ok_or_error;
use crate::adapter::http::helpers::{bad_request, conflict, forbidden, internal_error, json_error, not_found};
use crate::adapter::http::middleware::extractor::AuthClaims;
use crate::core::identity::auth_service::{AuthService, LoginError, RegisterError};
use crate::domain::identity::auth::{DEFAULT_ADMIN_USERNAME, GROUP_ADMIN, GROUP_VIEWER, ROLE_ADMIN, ROLE_VIEWER};
use crate::domain::identity::password;
use crate::domain::identity::validation::validate_password;
use crate::interface::app_repo::AppRepo;
type Repo = dyn AppRepo;
fn parse_permissions(raw: &str) -> serde_json::Value {
serde_json::from_str(raw).unwrap_or(serde_json::json!([]))
}
use crate::adapter::http::session::SessionCookieService;
use crate::core::identity::auth_service::AuthService;
use crate::core::identity::group_service::GroupService;
use crate::core::identity::session_service::SessionService;
use crate::core::identity::user_service::{UserProfile, UserService};
use crate::domain::identity::auth::{Claims, ROLE_ADMIN, ROLE_VIEWER};
use crate::domain::identity::error::{GroupError, LoginError, RegisterError, UserError};
#[derive(Deserialize)]
struct LoginRequest {
@ -34,9 +31,84 @@ struct ChangePasswordRequest {
new_password: String,
}
#[derive(Deserialize)]
struct UpdateRoleRequest {
role: String,
}
#[derive(Deserialize)]
struct ResetPasswordRequest {
new_password: Option<String>,
password: Option<String>,
}
#[derive(Deserialize)]
struct CreateGroupRequest {
name: Option<String>,
description: Option<String>,
permissions: Option<serde_json::Value>,
}
#[derive(Deserialize)]
struct UpdateGroupRequest {
name: Option<String>,
description: Option<String>,
permissions: Option<serde_json::Value>,
}
#[derive(Deserialize)]
struct SetUserGroupsRequest {
group_ids: Vec<i64>,
}
#[derive(Serialize)]
struct MeResponse {
id: i64,
username: String,
role: String,
permissions: Vec<String>,
groups: Vec<String>,
csrf_token: Option<String>,
}
impl MeResponse {
fn from_profile(profile: UserProfile, csrf_token: Option<String>) -> Self {
Self {
id: profile.id,
username: profile.username,
role: profile.role,
permissions: profile.permissions,
groups: profile.groups,
csrf_token,
}
}
}
fn append_session_removal_cookies(response: &mut HttpResponseBuilder, cookie_service: &SessionCookieService) {
for cookie in cookie_service.removal_cookies() {
response.append_header((header::SET_COOKIE, cookie.to_string()));
}
}
async fn invalidate_group_member_sessions(group_svc: &GroupService, session_service: &SessionService, group_id: i64) {
if let Ok(Some(group)) = group_svc.get_group(group_id).await {
for user_id in group.members {
session_service.remove_sessions_for_user(user_id);
}
}
}
async fn group_member_ids(group_svc: &GroupService, group_id: i64) -> Vec<i64> {
match group_svc.get_group(group_id).await {
Ok(Some(group)) => group.members,
_ => Vec::new(),
}
}
pub fn initialize() -> Scope {
web::scope("/auth")
.route("/login", web::post().to(login))
.route("/logout", web::post().to(logout))
.route("/register", web::post().to(register))
.route("/me", web::get().to(me))
.route("/change-password", web::post().to(change_password))
@ -52,23 +124,53 @@ pub fn initialize() -> Scope {
.route("/groups/{id}", web::delete().to(delete_group))
}
async fn login(body: web::Json<LoginRequest>, auth_svc: web::Data<AuthService>) -> impl Responder {
async fn login(
body: web::Json<LoginRequest>,
auth_svc: web::Data<AuthService>,
session_service: web::Data<SessionService>,
cookie_service: web::Data<SessionCookieService>,
) -> impl Responder {
let req = body.into_inner();
match auth_svc.login(&req.username, &req.password).await {
Ok(result) => HttpResponse::Ok().json(result),
Ok(result) => {
let claims = Claims {
sub: result.user_id,
username: result.username,
role: result.role.clone(),
permissions: result.permissions,
};
let session = session_service.create_session(claims);
HttpResponse::Ok()
.append_header((header::SET_COOKIE, cookie_service.session_cookie(&session).to_string()))
.json(serde_json::json!({
"role": result.role,
"force_password_change": result.force_password_change,
"csrf_token": session.csrf_token,
}))
}
Err(LoginError::Locked { retry_after_secs }) => HttpResponse::TooManyRequests().json(serde_json::json!({
"error": "Account temporarily locked due to too many failed login attempts",
"retry_after_secs": retry_after_secs,
})),
Err(LoginError::InvalidCredentials) => {
HttpResponse::Unauthorized().json(serde_json::json!({"error": "Invalid credentials"}))
}
Err(LoginError::InternalError) => {
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Failed to create token"}))
}
Err(LoginError::InvalidCredentials) => json_error(StatusCode::UNAUTHORIZED, "Invalid credentials"),
Err(LoginError::InternalError) => internal_error("Failed to login"),
}
}
async fn logout(
req: HttpRequest,
session_service: web::Data<SessionService>,
cookie_service: web::Data<SessionCookieService>,
) -> impl Responder {
if let Some(cookie) = req.cookie(cookie_service.cookie_name()) {
session_service.remove_session(cookie.value());
}
let mut response = HttpResponse::Ok();
append_session_removal_cookies(&mut response, &cookie_service);
response.json(serde_json::json!({"message": "Logged out"}))
}
async fn register(
auth: AuthClaims,
body: web::Json<RegisterRequest>,
@ -80,367 +182,265 @@ async fn register(
.await
{
Ok(_) => HttpResponse::Created().json(serde_json::json!({"username": reg.username, "role": reg.role})),
Err(RegisterError::Validation(msg)) => HttpResponse::BadRequest().json(serde_json::json!({"error": msg})),
Err(RegisterError::InvalidRole) => {
HttpResponse::BadRequest().json(serde_json::json!({"error": "Role must be 'admin' or 'viewer'"}))
}
Err(RegisterError::Forbidden) => HttpResponse::Forbidden()
.json(serde_json::json!({"error": "Only administrators can create admin accounts"})),
Err(RegisterError::HashFailed) => {
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Failed to hash password"}))
}
Err(RegisterError::Conflict(e)) => HttpResponse::Conflict().json(serde_json::json!({"error": e.to_string()})),
Err(e @ RegisterError::Validation { .. }) => bad_request(e.to_string()),
Err(RegisterError::InvalidRole) => bad_request("Role must be 'admin' or 'viewer'"),
Err(RegisterError::Forbidden) => forbidden("Only administrators can create admin accounts"),
Err(RegisterError::HashFailed) => internal_error("Failed to hash password"),
Err(e @ RegisterError::Conflict { .. }) => conflict(e.to_string()),
Err(e @ RegisterError::Internal { .. }) => internal_error(e.to_string()),
}
}
async fn me(auth: AuthClaims, auth_svc: web::Data<AuthService>) -> impl Responder {
let profile = auth_svc.user_profile(auth.sub, &auth.username).await;
HttpResponse::Ok().json(profile)
async fn me(
req: HttpRequest,
auth: AuthClaims,
user_svc: web::Data<UserService>,
session_service: web::Data<SessionService>,
cookie_service: web::Data<SessionCookieService>,
) -> impl Responder {
match user_svc.user_profile(auth.sub, &auth.username).await {
Ok(profile) => {
let csrf_token = req
.cookie(cookie_service.cookie_name())
.and_then(|cookie| session_service.csrf_token_for_session(cookie.value()));
HttpResponse::Ok().json(MeResponse::from_profile(profile, csrf_token))
}
Err(e) => internal_error(e),
}
}
async fn change_password(
auth: AuthClaims,
body: web::Json<ChangePasswordRequest>,
db: web::Data<Repo>,
user_svc: web::Data<UserService>,
session_service: web::Data<SessionService>,
cookie_service: web::Data<SessionCookieService>,
) -> impl Responder {
let change_req = body.into_inner();
if let Err(msg) = validate_password(&change_req.new_password) {
return HttpResponse::BadRequest().json(serde_json::json!({"error": msg}));
}
let user = match db.find_user(&auth.username).await {
Ok(Some(u)) => u,
_ => {
return HttpResponse::InternalServerError().json(serde_json::json!({"error": "User not found"}));
match user_svc
.change_password(auth.sub, &change_req.current_password, &change_req.new_password)
.await
{
Ok(()) => {
session_service.remove_sessions_for_user(auth.sub);
let mut response = HttpResponse::Ok();
append_session_removal_cookies(&mut response, &cookie_service);
response.json(serde_json::json!({"message": "Password changed successfully"}))
}
};
match password::verify_password(&change_req.current_password, &user.password_hash) {
Ok(true) => {}
_ => {
return HttpResponse::Unauthorized().json(serde_json::json!({"error": "Current password is incorrect"}));
}
}
let new_hash = match password::hash_password(&change_req.new_password) {
Ok(h) => h,
Err(_) => {
return HttpResponse::InternalServerError().json(serde_json::json!({"error": "Failed to hash password"}));
}
};
match db.update_user_password(auth.sub, &new_hash).await {
Ok(_) => HttpResponse::Ok().json(serde_json::json!({"message": "Password changed successfully"})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
Err(e) => user_error(e),
}
}
async fn list_users(_auth: AuthClaims, db: web::Data<Repo>) -> impl Responder {
match db.list_users_with_groups().await {
Ok(users) => {
let result: Vec<serde_json::Value> = users
.into_iter()
.map(|u| {
let groups: Vec<serde_json::Value> = u
.groups
.iter()
.map(|g| serde_json::json!({"id": g.group_id, "name": g.group_name}))
.collect();
let role = if u.groups.iter().any(|g| g.group_name == GROUP_ADMIN) {
ROLE_ADMIN
} else {
ROLE_VIEWER
};
serde_json::json!({
"id": u.id,
"username": u.username,
"role": role,
"force_password_change": u.force_password_change,
"created_at": u.created_at,
"groups": groups,
})
})
.collect();
HttpResponse::Ok().json(result)
}
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
async fn list_users(_auth: AuthClaims, user_svc: web::Data<UserService>) -> impl Responder {
match user_svc.list_users().await {
Ok(users) => HttpResponse::Ok().json(users),
Err(e) => internal_error(e),
}
}
async fn delete_user(_auth: AuthClaims, path: web::Path<i64>, db: web::Data<Repo>) -> impl Responder {
async fn delete_user(
auth: AuthClaims,
path: web::Path<i64>,
user_svc: web::Data<UserService>,
session_service: web::Data<SessionService>,
) -> impl Responder {
let user_id = path.into_inner();
if _auth.sub == user_id {
return HttpResponse::BadRequest().json(serde_json::json!({"error": "Cannot delete your own account"}));
}
match db.find_user_by_id(user_id).await {
Ok(Some(ref u)) if u.username == DEFAULT_ADMIN_USERNAME => {
return HttpResponse::Forbidden()
.json(serde_json::json!({"error": "Cannot delete the built-in admin account"}));
match user_svc.delete_user(auth.sub, user_id).await {
Ok(true) => {
session_service.remove_sessions_for_user(user_id);
HttpResponse::Ok().json(serde_json::json!({"message": "User deleted successfully"}))
}
_ => {}
}
match db.delete_user(user_id).await {
Ok(true) => HttpResponse::Ok().json(serde_json::json!({"message": "User deleted successfully"})),
Ok(false) => HttpResponse::NotFound().json(serde_json::json!({"error": "User not found"})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
Ok(false) => not_found("User not found"),
Err(e) => user_error(e),
}
}
async fn update_role(
_auth: AuthClaims,
path: web::Path<i64>,
body: web::Json<serde_json::Value>,
db: web::Data<Repo>,
body: web::Json<UpdateRoleRequest>,
user_svc: web::Data<UserService>,
session_service: web::Data<SessionService>,
) -> impl Responder {
let user_id = path.into_inner();
let req = body.into_inner();
if _auth.sub == user_id {
return HttpResponse::BadRequest().json(serde_json::json!({"error": "Cannot change your own role"}));
if req.role != ROLE_ADMIN && req.role != ROLE_VIEWER {
return bad_request("Role must be 'admin' or 'viewer'");
}
let role = match body.get("role").and_then(|v| v.as_str()) {
Some(r) if r == ROLE_ADMIN || r == ROLE_VIEWER => r,
_ => {
return HttpResponse::BadRequest().json(serde_json::json!({"error": "Role must be 'admin' or 'viewer'"}));
match user_svc.update_role(_auth.sub, user_id, &req.role).await {
Ok(_) => {
session_service.remove_sessions_for_user(user_id);
HttpResponse::Ok().json(serde_json::json!({"message": "Role updated successfully", "role": req.role}))
}
};
match db.find_user_by_id(user_id).await {
Ok(Some(_)) => {}
Ok(None) => {
return HttpResponse::NotFound().json(serde_json::json!({"error": "User not found"}));
}
Err(e) => {
return HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()}));
}
}
match db.update_user_role(user_id, role).await {
Ok(_) => HttpResponse::Ok().json(serde_json::json!({"message": "Role updated successfully", "role": role})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
Err(e) => user_error(e),
}
}
async fn reset_password(
_auth: AuthClaims,
path: web::Path<i64>,
body: web::Json<serde_json::Value>,
db: web::Data<Repo>,
body: web::Json<ResetPasswordRequest>,
user_svc: web::Data<UserService>,
session_service: web::Data<SessionService>,
cookie_service: web::Data<SessionCookieService>,
) -> impl Responder {
let user_id = path.into_inner();
let req = body.into_inner();
let new_password = match body
.get("new_password")
.or_else(|| body.get("password"))
.and_then(|v| v.as_str())
{
let new_password = match req.new_password.as_deref().or(req.password.as_deref()) {
Some(p) => p,
None => {
return HttpResponse::BadRequest().json(serde_json::json!({"error": "Password is required"}));
return bad_request("Password is required");
}
};
if let Err(msg) = validate_password(new_password) {
return HttpResponse::BadRequest().json(serde_json::json!({"error": msg}));
}
match db.find_user_by_id(user_id).await {
Ok(Some(_)) => {}
Ok(None) => {
return HttpResponse::NotFound().json(serde_json::json!({"error": "User not found"}));
}
Err(e) => {
return HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()}));
}
}
let hash = match password::hash_password(new_password) {
Ok(h) => h,
Err(_) => {
return HttpResponse::InternalServerError().json(serde_json::json!({"error": "Failed to hash password"}));
}
};
ok_or_error(db.reset_user_password(user_id, &hash).await)
}
async fn list_groups(_auth: AuthClaims, db: web::Data<Repo>) -> impl Responder {
match db.list_user_groups().await {
Ok(groups) => {
let mut result = Vec::with_capacity(groups.len());
for g in groups {
let perms: serde_json::Value = parse_permissions(&g.permissions);
let members: Vec<serde_json::Value> = db
.list_group_members(g.id)
.await
.unwrap_or_default()
.into_iter()
.map(|m| serde_json::json!({"id": m.id, "username": m.username}))
.collect();
result.push(serde_json::json!({
"id": g.id,
"name": g.name,
"description": g.description,
"permissions": perms,
"created_at": g.created_at,
"members": members,
}));
match user_svc.reset_password(user_id, new_password).await {
Ok(()) => {
session_service.remove_sessions_for_user(user_id);
let mut response = HttpResponse::Ok();
if user_id == _auth.sub {
append_session_removal_cookies(&mut response, &cookie_service);
}
HttpResponse::Ok().json(result)
response.finish()
}
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
Err(e) => user_error(e),
}
}
async fn create_group(_auth: AuthClaims, body: web::Json<serde_json::Value>, db: web::Data<Repo>) -> impl Responder {
let name = match body.get("name").and_then(|v| v.as_str()) {
Some(n) if !n.is_empty() => n,
_ => {
return HttpResponse::BadRequest().json(serde_json::json!({"error": "Group name is required"}));
}
};
let description = body.get("description").and_then(|v| v.as_str()).unwrap_or("");
let permissions = match body.get("permissions") {
Some(p) if p.is_array() => p.to_string(),
_ => "[]".to_string(),
};
match db.create_user_group(name, description, &permissions).await {
Ok(id) => HttpResponse::Created().json(serde_json::json!({
"id": id,
"name": name,
"description": description,
"permissions": parse_permissions(&permissions),
})),
Err(e) => HttpResponse::Conflict().json(serde_json::json!({"error": e.to_string()})),
fn user_error(err: UserError) -> HttpResponse {
match err {
UserError::Validation { .. } => bad_request(err.to_string()),
UserError::Unauthorized => json_error(StatusCode::UNAUTHORIZED, "Current password is incorrect"),
UserError::Forbidden { .. } => forbidden(err.to_string()),
UserError::NotFound { .. } => not_found(err.to_string()),
UserError::HashFailed => internal_error("Failed to hash password"),
UserError::Conflict { .. } => conflict(err.to_string()),
UserError::Internal { .. } => internal_error(err.to_string()),
}
}
async fn get_group(_auth: AuthClaims, path: web::Path<i64>, db: web::Data<Repo>) -> impl Responder {
fn group_error(err: GroupError) -> HttpResponse {
match err {
GroupError::Validation { .. } => bad_request(err.to_string()),
GroupError::Forbidden { .. } => forbidden(err.to_string()),
GroupError::NotFound { .. } => not_found(err.to_string()),
GroupError::Conflict { .. } => conflict(err.to_string()),
GroupError::Internal { .. } => internal_error(err.to_string()),
}
}
async fn list_groups(_auth: AuthClaims, group_svc: web::Data<GroupService>) -> impl Responder {
match group_svc.list_groups().await {
Ok(groups) => HttpResponse::Ok().json(groups),
Err(e) => internal_error(e),
}
}
async fn create_group(
_auth: AuthClaims,
body: web::Json<CreateGroupRequest>,
group_svc: web::Data<GroupService>,
) -> impl Responder {
let req = body.into_inner();
match group_svc
.create_group(
req.name.as_deref(),
req.description.as_deref(),
req.permissions.as_ref(),
)
.await
{
Ok(group) => HttpResponse::Created().json(group),
Err(e) => group_error(e),
}
}
async fn get_group(_auth: AuthClaims, path: web::Path<i64>, group_svc: web::Data<GroupService>) -> impl Responder {
let group_id = path.into_inner();
match db.get_user_group(group_id).await {
Ok(Some(g)) => {
let perms: serde_json::Value = parse_permissions(&g.permissions);
let members = db.list_group_member_ids(group_id).await.unwrap_or_default();
HttpResponse::Ok().json(serde_json::json!({
"id": g.id,
"name": g.name,
"description": g.description,
"permissions": perms,
"created_at": g.created_at,
"members": members,
}))
}
Ok(None) => HttpResponse::NotFound().json(serde_json::json!({"error": "Group not found"})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
match group_svc.get_group(group_id).await {
Ok(Some(group)) => HttpResponse::Ok().json(group),
Ok(None) => not_found("Group not found"),
Err(e) => internal_error(e),
}
}
async fn update_group(
_auth: AuthClaims,
path: web::Path<i64>,
body: web::Json<serde_json::Value>,
db: web::Data<Repo>,
body: web::Json<UpdateGroupRequest>,
group_svc: web::Data<GroupService>,
session_service: web::Data<SessionService>,
) -> impl Responder {
let group_id = path.into_inner();
let req = body.into_inner();
let existing = match db.get_user_group(group_id).await {
Ok(Some(g)) => {
if g.name == GROUP_ADMIN || g.name == GROUP_VIEWER {
return HttpResponse::Forbidden().json(serde_json::json!({"error": "Cannot modify built-in groups"}));
}
g
match group_svc
.update_group(
group_id,
req.name.as_deref(),
req.description.as_deref(),
req.permissions.as_ref(),
)
.await
{
Ok(group) => {
invalidate_group_member_sessions(&group_svc, &session_service, group_id).await;
HttpResponse::Ok().json(group)
}
Ok(None) => {
return HttpResponse::NotFound().json(serde_json::json!({"error": "Group not found"}));
}
Err(e) => {
return HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()}));
}
};
let name = body.get("name").and_then(|v| v.as_str()).unwrap_or(&existing.name);
let description = body
.get("description")
.and_then(|v| v.as_str())
.unwrap_or(&existing.description);
let permissions = match body.get("permissions") {
Some(p) if p.is_array() => p.to_string(),
_ => existing.permissions.clone(),
};
match db.update_user_group(group_id, name, description, &permissions).await {
Ok(_) => HttpResponse::Ok().json(serde_json::json!({
"id": group_id,
"name": name,
"description": description,
"permissions": parse_permissions(&permissions),
})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
Err(e) => group_error(e),
}
}
async fn delete_group(_auth: AuthClaims, path: web::Path<i64>, db: web::Data<Repo>) -> impl Responder {
async fn delete_group(
_auth: AuthClaims,
path: web::Path<i64>,
group_svc: web::Data<GroupService>,
session_service: web::Data<SessionService>,
) -> impl Responder {
let group_id = path.into_inner();
let member_ids = group_member_ids(&group_svc, group_id).await;
match db.get_user_group(group_id).await {
Ok(Some(ref g)) if g.name == GROUP_ADMIN || g.name == GROUP_VIEWER => {
return HttpResponse::Forbidden().json(serde_json::json!({"error": "Cannot delete built-in groups"}));
match group_svc.delete_group(group_id).await {
Ok(true) => {
for user_id in member_ids {
session_service.remove_sessions_for_user(user_id);
}
HttpResponse::Ok().json(serde_json::json!({"message": "Group deleted successfully"}))
}
_ => {}
}
match db.delete_user_group(group_id).await {
Ok(true) => HttpResponse::Ok().json(serde_json::json!({"message": "Group deleted successfully"})),
Ok(false) => HttpResponse::NotFound().json(serde_json::json!({"error": "Group not found"})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
Ok(false) => not_found("Group not found"),
Err(e) => group_error(e),
}
}
async fn set_user_groups(
_auth: AuthClaims,
path: web::Path<i64>,
body: web::Json<serde_json::Value>,
db: web::Data<Repo>,
body: web::Json<SetUserGroupsRequest>,
user_svc: web::Data<UserService>,
session_service: web::Data<SessionService>,
) -> impl Responder {
let user_id = path.into_inner();
let req = body.into_inner();
match db.find_user_by_id(user_id).await {
Ok(Some(ref u)) if u.username == DEFAULT_ADMIN_USERNAME => {
return HttpResponse::Forbidden()
.json(serde_json::json!({"error": "Cannot modify groups for the built-in admin account"}));
match user_svc.set_user_groups(_auth.sub, user_id, &req.group_ids).await {
Ok(_) => {
session_service.remove_sessions_for_user(user_id);
HttpResponse::Ok()
.json(serde_json::json!({"message": "User groups updated successfully", "group_ids": req.group_ids}))
}
Ok(Some(_)) => {}
Ok(None) => {
return HttpResponse::NotFound().json(serde_json::json!({"error": "User not found"}));
}
Err(e) => {
return HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()}));
}
}
let group_ids: Vec<i64> = match body.get("group_ids").and_then(|v| v.as_array()) {
Some(arr) => arr.iter().filter_map(|v| v.as_i64()).collect(),
None => {
return HttpResponse::BadRequest().json(serde_json::json!({"error": "group_ids array is required"}));
}
};
match db.set_user_groups(user_id, &group_ids).await {
Ok(_) => HttpResponse::Ok()
.json(serde_json::json!({"message": "User groups updated successfully", "group_ids": group_ids})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
Err(e) => user_error(e),
}
}
#[cfg(test)]
mod tests {
use super::SetUserGroupsRequest;
use crate::core::identity::user_service::parse_permissions;
use crate::domain::identity::validation::{validate_password, validate_username};
#[test]
@ -456,6 +456,13 @@ mod tests {
);
}
#[test]
fn parse_permissions_rejects_invalid_json() {
let err = parse_permissions("{bad json").expect_err("invalid permissions JSON should fail");
assert!(err.to_string().contains("deserialize"));
}
#[test]
fn test_validate_username_valid() {
assert!(validate_username("admin").is_ok());
@ -464,6 +471,28 @@ mod tests {
#[test]
fn test_validate_password_valid() {
assert!(validate_password("12345678").is_ok());
assert!(validate_password("Password1!").is_ok());
}
#[test]
fn set_user_groups_request_rejects_malformed_entries() {
let body = serde_json::json!({
"group_ids": [1, "2", null]
});
let result: Result<SetUserGroupsRequest, _> = serde_json::from_value(body);
assert!(result.is_err());
}
#[test]
fn set_user_groups_request_accepts_integer_entries() {
let body = serde_json::json!({
"group_ids": [1, 2, 3]
});
let req: SetUserGroupsRequest = serde_json::from_value(body).expect("valid group ids");
assert_eq!(req.group_ids, vec![1, 2, 3]);
}
}

View File

@ -1,152 +0,0 @@
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode, errors::ErrorKind};
use crate::domain::common::error::Error;
use crate::domain::identity::auth::Claims;
use crate::domain::identity::error::AuthError;
use crate::interface::secret_store::SecretStorePort;
use crate::interface::token_minter::TokenMinter;
pub struct JwtService {
encoding_key: EncodingKey,
decoding_key: DecodingKey,
expiry_hours: u64,
}
impl JwtService {
/// Generate a fresh random JWT signing secret on every boot.
/// This intentionally invalidates all existing tokens on restart.
pub fn new(_secrets: &Arc<dyn SecretStorePort>, expiry_hours: u64) -> Result<Self, Error> {
use rand::Rng;
let secret: [u8; 32] = rand::rng().random();
Ok(Self {
encoding_key: EncodingKey::from_secret(&secret),
decoding_key: DecodingKey::from_secret(&secret),
expiry_hours,
})
}
pub fn create_token(
&self,
user_id: i64,
username: &str,
role: &str,
permissions: Vec<String>,
) -> Result<String, Error> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or(Duration::ZERO)
.as_secs();
let claims = Claims {
sub: user_id,
username: username.to_string(),
role: role.to_string(),
permissions,
exp: (now + self.expiry_hours * 3600) as usize,
};
encode(&Header::default(), &claims, &self.encoding_key).map_err(|_| AuthError::InvalidToken.into())
}
pub fn validate_token(&self, token: &str) -> Result<Claims, Error> {
let token_data =
decode::<Claims>(token, &self.decoding_key, &Validation::new(Algorithm::HS256)).map_err(|e| {
match e.kind() {
ErrorKind::ExpiredSignature => Error::from(AuthError::TokenExpired),
_ => Error::from(AuthError::InvalidToken),
}
})?;
Ok(token_data.claims)
}
}
impl TokenMinter for JwtService {
fn create_token(
&self,
user_id: i64,
username: &str,
role: &str,
permissions: Vec<String>,
) -> Result<String, Error> {
self.create_token(user_id, username, role, permissions)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::adapter::persistence::Database;
use crate::infrastructure::secret_store::SecretStore;
async fn test_jwt_service() -> JwtService {
let db = Arc::new(Database::new(":memory:").await.unwrap());
let secrets: Arc<dyn SecretStorePort> = Arc::new(SecretStore::new(db));
JwtService::new(&secrets, 24).unwrap()
}
#[tokio::test]
async fn test_create_and_validate_token() {
let jwt = test_jwt_service().await;
let perms = vec!["dashboard:read".to_string()];
let token = jwt.create_token(1, "admin", "admin", perms.clone()).unwrap();
let claims = jwt.validate_token(&token).unwrap();
assert_eq!(claims.sub, 1);
assert_eq!(claims.username, "admin");
assert_eq!(claims.role, "admin");
assert_eq!(claims.permissions, perms);
}
#[tokio::test]
async fn test_invalid_token() {
let jwt = test_jwt_service().await;
let result = jwt.validate_token("invalid.token.here");
assert!(result.is_err());
}
#[tokio::test]
async fn test_expired_token() {
let db = Arc::new(Database::new(":memory:").await.unwrap());
let secrets: Arc<dyn SecretStorePort> = Arc::new(SecretStore::new(db));
let jwt = JwtService::new(&secrets, 0).unwrap(); // 0 hours = immediate expiry
// Create token with 0 hour expiry — it expires in the past
let claims = Claims {
sub: 1,
username: "admin".to_string(),
role: "admin".to_string(),
permissions: vec![],
exp: 0, // epoch = expired
};
let token = encode(&Header::default(), &claims, &jwt.encoding_key).unwrap();
let result = jwt.validate_token(&token);
assert!(result.is_err());
}
#[tokio::test]
async fn test_jwt_secret_changes_on_new_instance() {
let db = Arc::new(Database::new(":memory:").await.unwrap());
let secrets: Arc<dyn SecretStorePort> = Arc::new(SecretStore::new(db));
let jwt1 = JwtService::new(&secrets, 24).unwrap();
let token = jwt1.create_token(1, "admin", "admin", vec![]).unwrap();
// New instance = new secret = old token invalid (simulates restart)
let jwt2 = JwtService::new(&secrets, 24).unwrap();
let result = jwt2.validate_token(&token);
assert!(result.is_err());
}
#[tokio::test]
async fn test_different_secrets_reject() {
let jwt1 = test_jwt_service().await;
let jwt2 = test_jwt_service().await; // different in-memory DB = different secret
let token = jwt1.create_token(1, "admin", "admin", vec![]).unwrap();
let result = jwt2.validate_token(&token);
assert!(result.is_err());
}
}

View File

@ -1,20 +1,24 @@
use std::fs;
use std::io::ErrorKind;
use std::path::PathBuf;
use std::io::{self, ErrorKind};
use std::path::{Path, PathBuf};
use std::time::UNIX_EPOCH;
use actix_web::http::StatusCode;
use actix_web::{HttpResponse, Scope, web};
use arc_swap::ArcSwap;
use serde::{Deserialize, Serialize};
use tokio_util::io::ReaderStream;
use crate::adapter::http::helpers::{bad_request, forbidden, internal_error, json_error, not_found};
use crate::common::utils::log_level::level_severity;
use crate::domain::common::config::AppConfig;
use crate::infrastructure::log_buffer::{self, LogBuffer, LogEntry};
use crate::interface::system::live_logs::{LiveLogQuery, LogEntry};
/// Validate log filename: only alphanumeric, dots, underscores, hyphens.
/// Prevents path traversal.
fn is_valid_log_filename(name: &str) -> bool {
!name.is_empty()
&& name.len() <= 128
&& name != "."
&& name != ".."
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-')
@ -48,7 +52,7 @@ struct LiveResponse {
async fn live_logs(
query: web::Query<LiveQuery>,
app_config: web::Data<ArcSwap<AppConfig>>,
buf: web::Data<LogBuffer>,
buf: web::Data<dyn LiveLogQuery>,
) -> HttpResponse {
let since_id = query.since_id.unwrap_or(0);
let obs = app_config.load().observability.clone();
@ -59,13 +63,10 @@ async fn live_logs(
let min_severity = query
.min_level
.as_deref()
.map(|s| log_buffer::level_severity(&s.to_ascii_uppercase()))
.unwrap_or(log_buffer::level_severity("TRACE"));
.map(|s| level_severity(&s.to_ascii_uppercase()))
.unwrap_or(level_severity("TRACE"));
let snap = buf.snapshot(since_id, min_severity, limit);
// Signal to the UI that it lagged enough for the ring to evict rows
// between polls. Frontend can warn "older entries dropped" without
// silently skipping a gap.
let dropped_oldest = since_id > 0 && snap.entries.first().is_some_and(|e| e.id > since_id + 1);
let next_id = snap.entries.last().map(|e| e.id).unwrap_or(snap.latest_id);
@ -86,33 +87,37 @@ struct LogFileEntry {
async fn list_logs(app_config: web::Data<ArcSwap<AppConfig>>) -> HttpResponse {
let log_dir = app_config.load().system.log_dir.clone();
let entries = match fs::read_dir(&log_dir) {
Ok(dir) => dir
.filter_map(|e| e.ok())
.filter_map(|e| {
let name = e.file_name().to_string_lossy().to_string();
let meta = e.metadata().ok()?;
if !meta.is_file() {
return None;
}
let modified = meta
.modified()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_secs());
Some(LogFileEntry {
name,
size: meta.len(),
modified,
})
})
.collect::<Vec<_>>(),
Err(_) => Vec::new(),
let entries = match list_log_files(Path::new(&log_dir)) {
Ok(entries) => entries,
Err(e) => return internal_error(format!("Failed to list log files: {}", e)),
};
HttpResponse::Ok().json(serde_json::json!({ "files": entries }))
}
fn list_log_files(log_dir: &Path) -> io::Result<Vec<LogFileEntry>> {
let mut files = Vec::new();
for entry in fs::read_dir(log_dir)? {
let entry = entry?;
let name = entry.file_name().to_string_lossy().to_string();
let meta = entry.metadata()?;
if !meta.is_file() {
continue;
}
let modified = meta
.modified()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_secs());
files.push(LogFileEntry {
name,
size: meta.len(),
modified,
});
}
Ok(files)
}
async fn download_log(path: web::Path<String>, app_config: web::Data<ArcSwap<AppConfig>>) -> HttpResponse {
let config = app_config.load();
let max_download_size = config.observability.log_max_download_size;
@ -120,67 +125,52 @@ async fn download_log(path: web::Path<String>, app_config: web::Data<ArcSwap<App
let filename = path.into_inner();
if !is_valid_log_filename(&filename) {
return HttpResponse::BadRequest().json(serde_json::json!({
"error": "Invalid filename: only alphanumeric, dots, underscores, hyphens allowed"
}));
return bad_request("Invalid filename: only alphanumeric, dots, underscores, hyphens allowed");
}
let file_path = log_dir.join(&filename);
// Canonicalize to prevent symlink traversal
let canonical = match fs::canonicalize(&file_path) {
Ok(p) => p,
Err(_) => {
return HttpResponse::NotFound().json(serde_json::json!({
"error": format!("Log file '{}' not found", filename)
}));
}
Err(_) => return not_found(format!("Log file '{}' not found", filename)),
};
if let Ok(log_dir_canonical) = fs::canonicalize(&log_dir)
&& !canonical.starts_with(&log_dir_canonical)
{
return HttpResponse::Forbidden().json(serde_json::json!({
"error": "Access denied: file is outside the log directory"
}));
return forbidden("Access denied: file is outside the log directory");
}
// Check file size before reading to prevent OOM on large logs
match fs::metadata(&canonical) {
Ok(meta) if meta.len() > max_download_size => {
return HttpResponse::PayloadTooLarge().json(serde_json::json!({
"error": format!("Log file exceeds maximum download size ({}MB)", max_download_size / 1024 / 1024)
}));
}
Err(e) if e.kind() == ErrorKind::NotFound => {
return HttpResponse::NotFound().json(serde_json::json!({
"error": format!("Log file '{}' not found", filename)
}));
}
Err(e) => {
return HttpResponse::InternalServerError().json(serde_json::json!({
"error": format!("Failed to read log file: {}", e)
}));
return json_error(
StatusCode::PAYLOAD_TOO_LARGE,
format!(
"Log file exceeds maximum download size ({}MB)",
max_download_size / 1024 / 1024
),
);
}
Err(e) if e.kind() == ErrorKind::NotFound => return not_found(format!("Log file '{}' not found", filename)),
Err(e) => return internal_error(format!("Failed to read log file: {}", e)),
Ok(_) => {}
}
let content = match fs::read(&canonical) {
Ok(bytes) => bytes,
Err(e) => {
return HttpResponse::InternalServerError().json(serde_json::json!({
"error": format!("Failed to read log file: {}", e)
}));
}
let file = match tokio::fs::File::open(&canonical).await {
Ok(f) => f,
Err(e) => return internal_error(format!("Failed to open log file: {}", e)),
};
let stream = ReaderStream::new(file);
HttpResponse::Ok()
.insert_header(("Content-Type", "application/octet-stream"))
.insert_header(("Content-Disposition", format!("attachment; filename=\"{}\"", filename)))
.body(content)
.streaming(stream)
}
#[cfg(test)]
mod tests {
use std::env;
use std::fs;
use std::time::{SystemTime, UNIX_EPOCH};
use super::*;
#[test]
@ -195,6 +185,8 @@ mod tests {
assert!(!is_valid_log_filename("../../etc/passwd"));
assert!(!is_valid_log_filename("../secret"));
assert!(!is_valid_log_filename("/etc/shadow"));
assert!(!is_valid_log_filename("."));
assert!(!is_valid_log_filename(".."));
}
#[test]
@ -211,4 +203,34 @@ mod tests {
let exact = "a".repeat(128);
assert!(is_valid_log_filename(&exact));
}
#[test]
fn missing_log_dir_is_reported() {
let path = temp_path("net-guardia-missing");
assert!(list_log_files(&path).is_err());
}
#[test]
fn list_log_files_ignores_directories() {
let dir = temp_path("net-guardia-logs");
fs::create_dir(&dir).unwrap();
fs::write(dir.join("app.log"), b"hello").unwrap();
fs::create_dir(dir.join("nested")).unwrap();
let files = list_log_files(&dir).unwrap();
fs::remove_dir_all(&dir).unwrap();
assert_eq!(files.len(), 1);
assert_eq!(files[0].name, "app.log");
assert_eq!(files[0].size, 5);
}
fn temp_path(prefix: &str) -> PathBuf {
env::temp_dir().join(format!(
"{}-{}",
prefix,
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos()
))
}
}

View File

@ -5,14 +5,20 @@ use std::task::{Context, Poll};
use actix_web::body::EitherBody;
use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform};
use actix_web::http::Method;
use actix_web::http::{Method, StatusCode};
use actix_web::{Error as ActixError, HttpMessage, HttpResponse, web};
use macros::log;
use crate::adapter::http::jwt::JwtService;
use crate::adapter::http::helpers::json_error;
use crate::adapter::http::session::SessionCookieService;
use crate::core::identity::session_service::SessionService;
use crate::domain::common::config::constants::{
PERMISSION_ACCESS_CONTROL_WRITE, PERMISSION_API_KEYS_ADMIN, PERMISSION_USERS_ADMIN,
};
use crate::domain::identity::error::AuthError;
use crate::interface::api_key::ApiKeyRepo;
use crate::interface::app_repo::AppRepo;
use crate::interface::identity::api_key::ApiKeyRepo;
use crate::interface::identity::api_key_hasher::ApiKeyHasher;
use crate::interface::identity::auth_repo::LoginAttemptRepo;
pub struct AuthMiddleware;
@ -39,12 +45,22 @@ pub struct AuthMiddlewareService<S> {
}
fn required_permission(path: &str, method: &Method) -> Option<String> {
let resource = if path == "/api/auth/login" || path == "/api/auth/me" || path == "/api/auth/change-password" {
let resource = if path == "/api/auth/login"
|| path == "/api/auth/logout"
|| path == "/api/auth/me"
|| path == "/api/auth/change-password"
{
return None;
} else if path.starts_with("/api/auth/") {
return Some("users:admin".to_string());
} else if path.starts_with("/api/health/") || path.starts_with("/api/stats/") {
return Some(PERMISSION_USERS_ADMIN.to_string());
} else if path.starts_with("/api/health/") {
"dashboard"
} else if path.starts_with("/api/stats/drops") {
"drops"
} else if path.starts_with("/api/stats/flows") {
"traffic_map"
} else if path.starts_with("/api/stats/summary") {
"statistics"
} else if path.starts_with("/api/ml/") || path.starts_with("/api/byo/") {
"ai_detection"
} else if path.starts_with("/api/fusion/") {
@ -64,13 +80,16 @@ fn required_permission(path: &str, method: &Method) -> Option<String> {
} else if path.starts_with("/api/system/") {
"system"
} else if path == "/api/api-keys" || path.starts_with("/api/api-keys/") {
return Some("api_keys:admin".to_string());
return Some(PERMISSION_API_KEYS_ADMIN.to_string());
} else if path.contains("/soar/blocks/") && path.ends_with("/unblock") {
return Some("access_control:write".to_string());
return Some(PERMISSION_ACCESS_CONTROL_WRITE.to_string());
} else if path.starts_with("/api/soar/")
|| path.starts_with("/api/notifications/")
|| path == "/api/report"
|| path.starts_with("/api/report/")
|| path == "/api/logs"
|| path.starts_with("/api/logs/")
|| path == "/api/audit"
|| path.starts_with("/api/audit/")
{
"system"
@ -86,6 +105,13 @@ fn required_permission(path: &str, method: &Method) -> Option<String> {
Some(format!("{}:{}", resource, action))
}
fn is_auth_exempt_request(path: &str, method: &Method) -> bool {
*method == Method::OPTIONS
|| path == "/api/auth/login"
|| path.starts_with("/api/setup/")
|| !path.starts_with("/api/")
}
impl<S, B> Service<ServiceRequest> for AuthMiddlewareService<S>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
@ -105,111 +131,116 @@ where
Box::pin(async move {
let path = req.path().to_string();
// Skip auth for public endpoints
if path == "/api/auth/login" || path.starts_with("/api/setup/") || !path.starts_with("/api/") {
if is_auth_exempt_request(&path, req.method()) {
let res = service.call(req).await?.map_into_left_body();
return Ok(res);
}
// Extract JWT service from app data
let jwt_service = match req.app_data::<web::Data<JwtService>>() {
Some(s) => s.clone(),
None => {
let resp =
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Auth not configured"}));
return Ok(req.into_response(resp).map_into_right_body());
}
};
// Try JWT first, then fall back to API key
let claims = if let Some(auth_header) = req.headers().get("Authorization") {
// JWT Bearer token auth
let val_str = auth_header.to_str().unwrap_or("");
let token = match val_str.strip_prefix("Bearer ") {
Some(t) => t,
None => {
let resp = HttpResponse::Unauthorized()
.json(serde_json::json!({"error": "Invalid authorization header"}));
return Ok(req.into_response(resp).map_into_right_body());
}
};
match jwt_service.validate_token(token) {
Ok(c) => c,
Err(_) => {
let resp =
HttpResponse::Unauthorized().json(serde_json::json!({"error": "Invalid or expired token"}));
return Ok(req.into_response(resp).map_into_right_body());
}
}
} else if let Some(api_key_header) = req.headers().get("X-API-Key") {
// API key auth with rate limiting
let claims = if let Some(api_key_header) = req.headers().get("X-API-Key") {
let api_key = api_key_header.to_str().unwrap_or("");
let api_key_port = match req.app_data::<web::Data<dyn ApiKeyRepo>>() {
Some(d) => d.clone(),
None => {
let resp = HttpResponse::InternalServerError()
.json(serde_json::json!({"error": "ApiKeyRepo not configured"}));
let resp = json_error(StatusCode::INTERNAL_SERVER_ERROR, "ApiKeyRepo not configured");
return Ok(req.into_response(resp).map_into_right_body());
}
};
let repo = match req.app_data::<web::Data<dyn AppRepo>>() {
let api_key_hasher = match req.app_data::<web::Data<dyn ApiKeyHasher>>() {
Some(d) => d.clone(),
None => {
let resp = HttpResponse::InternalServerError()
.json(serde_json::json!({"error": "AppRepo not configured"}));
let resp = json_error(StatusCode::INTERNAL_SERVER_ERROR, "ApiKeyHasher not configured");
return Ok(req.into_response(resp).map_into_right_body());
}
};
let login_attempt_repo = match req.app_data::<web::Data<dyn LoginAttemptRepo>>() {
Some(d) => d.clone(),
None => {
let resp = json_error(StatusCode::INTERNAL_SERVER_ERROR, "LoginAttemptRepo not configured");
return Ok(req.into_response(resp).map_into_right_body());
}
};
// Rate limit check for API key attempts (reuse login failure tracking)
let rate_key = format!(
"apikey:{}",
req.peer_addr().map(|a| a.ip().to_string()).unwrap_or_default()
);
if let Ok(Some(remaining)) = repo.check_login_locked(&rate_key).await {
let resp = HttpResponse::TooManyRequests().json(serde_json::json!({
"error": "Too many failed API key attempts",
"retry_after_secs": remaining,
}));
return Ok(req.into_response(resp).map_into_right_body());
match login_attempt_repo.get_remaining_lock_secs(&rate_key).await {
Ok(Some(remaining)) => {
let resp = HttpResponse::TooManyRequests().json(serde_json::json!({
"error": "Too many failed API key attempts",
"retry_after_secs": remaining,
}));
return Ok(req.into_response(resp).map_into_right_body());
}
Ok(None) => {
if let Err(e) = login_attempt_repo.clear_expired_login_lock(&rate_key).await {
log!(AuthError::LoginLockoutLookupFailed(e));
let resp = json_error(StatusCode::INTERNAL_SERVER_ERROR, "API key lockout cleanup failed");
return Ok(req.into_response(resp).map_into_right_body());
}
}
Err(e) => {
log!(AuthError::LoginLockoutLookupFailed(e));
let resp = json_error(StatusCode::INTERNAL_SERVER_ERROR, "API key lockout check failed");
return Ok(req.into_response(resp).map_into_right_body());
}
}
match api_key_port.validate_api_key(api_key).await {
let key_hash = api_key_hasher.hash_api_key(api_key);
match api_key_port.validate_api_key(&key_hash).await {
Ok(Some(key_claims)) => {
if let Err(e) = repo.clear_login_failures(&rate_key).await {
if let Err(e) = login_attempt_repo.clear_login_failures(&rate_key).await {
log!(AuthError::LoginClearError(e));
}
key_claims
}
Ok(None) => {
if let Err(e) = repo.record_login_failure(&rate_key).await {
if let Err(e) = login_attempt_repo.record_login_failure(&rate_key).await {
log!(AuthError::LoginFailureTrackingError(e));
}
let resp = HttpResponse::Unauthorized()
.json(serde_json::json!({"error": "Invalid or revoked API key"}));
let resp = json_error(StatusCode::UNAUTHORIZED, "Invalid or revoked API key");
return Ok(req.into_response(resp).map_into_right_body());
}
Err(_) => {
let resp = HttpResponse::InternalServerError()
.json(serde_json::json!({"error": "API key validation failed"}));
let resp = json_error(StatusCode::INTERNAL_SERVER_ERROR, "API key validation failed");
return Ok(req.into_response(resp).map_into_right_body());
}
}
} else {
let resp =
HttpResponse::Unauthorized().json(serde_json::json!({"error": "Missing authorization header"}));
return Ok(req.into_response(resp).map_into_right_body());
let session_service = match req.app_data::<web::Data<SessionService>>() {
Some(service) => service.clone(),
None => {
let resp = json_error(StatusCode::INTERNAL_SERVER_ERROR, "Session auth not configured");
return Ok(req.into_response(resp).map_into_right_body());
}
};
let cookie_service = match req.app_data::<web::Data<SessionCookieService>>() {
Some(service) => service.clone(),
None => {
let resp = json_error(StatusCode::INTERNAL_SERVER_ERROR, "Session cookie auth not configured");
return Ok(req.into_response(resp).map_into_right_body());
}
};
let Some(session_cookie) = req.cookie(cookie_service.cookie_name()) else {
let resp = json_error(StatusCode::UNAUTHORIZED, "Missing authentication cookie");
return Ok(req.into_response(resp).map_into_right_body());
};
match session_service.claims_for_session(session_cookie.value()) {
Some(claims) => claims,
None => {
let resp = json_error(StatusCode::UNAUTHORIZED, "Invalid or expired session");
return Ok(req.into_response(resp).map_into_right_body());
}
}
};
// Permission-based RBAC check
if let Some(required) = required_permission(&path, req.method())
&& !claims.permissions.contains(&required)
{
let resp = HttpResponse::Forbidden().json(serde_json::json!({"error": "Insufficient permissions"}));
let resp = json_error(StatusCode::FORBIDDEN, "Insufficient permissions");
return Ok(req.into_response(resp).map_into_right_body());
}
// Store claims in request extensions
req.extensions_mut().insert(claims);
let res = service.call(req).await?.map_into_left_body();
@ -222,7 +253,7 @@ where
mod tests {
use actix_web::http::Method;
use super::required_permission;
use super::{is_auth_exempt_request, required_permission};
#[test]
fn api_key_collection_requires_admin_permission() {
@ -243,4 +274,56 @@ mod tests {
Some("api_keys:admin".to_string())
);
}
#[test]
fn password_change_routes_do_not_require_feature_permissions() {
assert_eq!(required_permission("/api/auth/me", &Method::GET), None);
assert_eq!(required_permission("/api/auth/change-password", &Method::POST), None);
}
#[test]
fn stats_routes_use_specific_read_permissions() {
assert_eq!(
required_permission("/api/stats/summary", &Method::GET),
Some("statistics:read".to_string())
);
assert_eq!(
required_permission("/api/stats/flows", &Method::GET),
Some("traffic_map:read".to_string())
);
assert_eq!(
required_permission("/api/stats/flows/top/10", &Method::GET),
Some("traffic_map:read".to_string())
);
assert_eq!(
required_permission("/api/stats/drops", &Method::GET),
Some("drops:read".to_string())
);
}
#[test]
fn collection_endpoints_require_system_permissions() {
assert_eq!(
required_permission("/api/logs", &Method::GET),
Some("system:read".to_string())
);
assert_eq!(
required_permission("/api/audit", &Method::GET),
Some("system:read".to_string())
);
assert_eq!(
required_permission("/api/report/data", &Method::GET),
Some("system:read".to_string())
);
assert_eq!(
required_permission("/api/report/generate", &Method::POST),
Some("system:write".to_string())
);
}
#[test]
fn options_requests_are_auth_exempt_preflight() {
assert!(is_auth_exempt_request("/api/acl/rules", &Method::OPTIONS));
assert!(is_auth_exempt_request("/api/report/generate", &Method::OPTIONS));
}
}

View File

@ -1,25 +1,3 @@
//! CSRF defense-in-depth middleware.
//!
//! The primary auth path uses `Authorization: Bearer <jwt>` — a scheme
//! the browser never auto-attaches — so classical CSRF against a
//! malicious same-origin form POST is already neutralized. This
//! middleware adds a belt-and-suspenders layer on top:
//!
//! - State-changing requests (anything that isn't `GET`/`HEAD`/`OPTIONS`)
//! must carry an `X-CSRF-Token` header.
//! - The header's presence alone is the check. Cross-origin attackers
//! cannot set custom request headers on simple requests (browsers
//! block that via the CORS preflight), so a successful request from
//! a third-party page would need to run JS inside our origin, at
//! which point CSRF is the wrong threat label anyway.
//! - Exempt: auth / setup bootstrap endpoints (no session yet),
//! WebSocket upgrade (no body to forge), and `X-API-Key`
//! authentication (sealed credential — the request isn't a browser
//! navigation at all).
//!
//! The decision lives in `should_require_csrf_token` so unit tests can
//! cover the path without spinning up an Actix test harness.
use std::future::{Future, Ready, ready};
use std::pin::Pin;
use std::rc::Rc;
@ -28,15 +6,12 @@ use std::task::{Context, Poll};
use actix_web::body::EitherBody;
use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform};
use actix_web::http::Method;
use actix_web::{Error as ActixError, HttpResponse};
use actix_web::{Error as ActixError, HttpResponse, web};
use crate::adapter::http::session::SessionCookieService;
use crate::core::identity::session_service::{CsrfTokenStatus, SessionService};
/// Request header carrying the CSRF token. Clients (frontend fetch /
/// axios wrappers) set this on every state-changing request; its value
/// is whatever the client produced (we don't validate content).
pub const CSRF_HEADER: &str = "X-CSRF-Token";
/// Header used by non-browser clients for API-key authentication. Such
/// clients are exempt from the CSRF requirement.
const API_KEY_HEADER: &str = "X-API-Key";
pub struct CsrfMiddleware;
@ -81,15 +56,57 @@ where
let path = req.path().to_string();
let method = req.method().clone();
let has_api_key = req.headers().contains_key(API_KEY_HEADER);
let has_csrf_token = req.headers().contains_key(CSRF_HEADER);
let csrf_token = req
.headers()
.get(CSRF_HEADER)
.and_then(|value| value.to_str().ok())
.map(str::to_string);
Box::pin(async move {
if should_require_csrf_token(&path, &method, has_api_key) && !has_csrf_token {
let resp = HttpResponse::Forbidden().json(serde_json::json!({
"error": "Missing CSRF token",
"header": CSRF_HEADER,
}));
return Ok(req.into_response(resp).map_into_right_body());
if should_require_csrf_token(&path, &method, has_api_key) {
let Some(token) = csrf_token else {
let resp = HttpResponse::Forbidden().json(serde_json::json!({
"error": "Missing CSRF token",
"header": CSRF_HEADER,
}));
return Ok(req.into_response(resp).map_into_right_body());
};
let Some(session_service) = req.app_data::<web::Data<SessionService>>() else {
let resp = HttpResponse::InternalServerError().json(serde_json::json!({
"error": "CSRF validation is not configured",
}));
return Ok(req.into_response(resp).map_into_right_body());
};
let Some(cookie_service) = req.app_data::<web::Data<SessionCookieService>>() else {
let resp = HttpResponse::InternalServerError().json(serde_json::json!({
"error": "CSRF cookie validation is not configured",
}));
return Ok(req.into_response(resp).map_into_right_body());
};
let Some(session_cookie) = req.cookie(cookie_service.cookie_name()) else {
let resp = HttpResponse::Forbidden().json(serde_json::json!({
"error": "Missing session cookie",
}));
return Ok(req.into_response(resp).map_into_right_body());
};
match session_service.csrf_token_status(session_cookie.value(), &token) {
CsrfTokenStatus::Valid => {}
CsrfTokenStatus::Expired => {
session_service.remove_session(session_cookie.value());
let resp = HttpResponse::Forbidden().json(serde_json::json!({
"error": "Invalid CSRF token",
"header": CSRF_HEADER,
}));
return Ok(req.into_response(resp).map_into_right_body());
}
CsrfTokenStatus::Invalid | CsrfTokenStatus::MissingSession => {
let resp = HttpResponse::Forbidden().json(serde_json::json!({
"error": "Invalid CSRF token",
"header": CSRF_HEADER,
}));
return Ok(req.into_response(resp).map_into_right_body());
}
}
}
let res = service.call(req).await?.map_into_left_body();
Ok(res)
@ -97,40 +114,16 @@ where
}
}
/// Decide whether a request must present a CSRF token. The rules are
/// extracted as a free function so the middleware is a thin shim and
/// the policy can be unit-tested without an HTTP harness.
pub fn should_require_csrf_token(path: &str, method: &Method, has_api_key: bool) -> bool {
if has_api_key {
return false;
}
if !is_state_changing(method) {
return false;
}
if is_csrf_exempt_path(path) {
return false;
}
true
!has_api_key && is_state_changing(method) && !is_csrf_exempt_path(path)
}
fn is_state_changing(method: &Method) -> bool {
!matches!(*method, Method::GET | Method::HEAD | Method::OPTIONS)
}
/// Paths that cannot meaningfully carry a CSRF token because the
/// session that would mint one hasn't been established yet, or because
/// the route uses a protocol outside the CSRF threat model.
fn is_csrf_exempt_path(path: &str) -> bool {
// Login / setup bootstrap: no session yet, so no token to match.
if path == "/api/auth/login" || path.starts_with("/api/setup/") {
return true;
}
// WebSocket upgrade happens over a GET anyway, but list the prefix
// explicitly so the intent is visible when someone reads the file.
if path.starts_with("/ws/") {
return true;
}
false
path == "/api/auth/login" || path.starts_with("/api/setup/") || path.starts_with("/ws/")
}
#[cfg(test)]
@ -162,7 +155,6 @@ mod tests {
#[test]
fn login_and_setup_are_exempt() {
// Login hasn't yet issued a session, so there's no token to carry.
assert!(!should_require_csrf_token("/api/auth/login", &Method::POST, false));
assert!(!should_require_csrf_token(
"/api/setup/initialize",
@ -173,23 +165,18 @@ mod tests {
#[test]
fn websocket_upgrade_is_exempt() {
// WS upgrade is a GET anyway but stays exempt under any verb.
assert!(!should_require_csrf_token("/ws/events", &Method::GET, false));
assert!(!should_require_csrf_token("/ws/events", &Method::POST, false));
}
#[test]
fn api_key_clients_are_exempt_even_on_state_changing_routes() {
// Non-browser clients present a sealed credential; CSRF is a
// browser threat model.
assert!(!should_require_csrf_token("/api/acl/rules", &Method::POST, true));
assert!(!should_require_csrf_token("/api/soar/playbooks", &Method::DELETE, true));
}
#[test]
fn api_key_exemption_takes_precedence_over_path_rules() {
// Even if the path is a state-changing admin route, the API-key
// header flips the requirement off before the path check runs.
assert!(!should_require_csrf_token("/api/system/reload", &Method::POST, true));
}
}

View File

@ -7,22 +7,11 @@ use actix_web::{Error as ActixError, FromRequest, HttpMessage, HttpRequest};
use crate::domain::identity::auth::Claims;
/// Actix-web extractor that pulls `Claims` from request extensions.
///
/// The `AuthMiddleware` validates JWT/API key and stores Claims in extensions.
/// This extractor simply reads them out, returning 401 if missing.
///
/// Usage:
/// ```ignore
/// async fn handler(auth: AuthClaims, ...) -> HttpResponse {
/// let user_id = auth.sub;
/// // ...
/// }
/// ```
pub struct AuthClaims(pub Claims);
impl Deref for AuthClaims {
type Target = Claims;
fn deref(&self) -> &Self::Target {
&self.0
}

View File

@ -10,37 +10,17 @@ use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform};
use actix_web::http::header;
use actix_web::{Error as ActixError, HttpResponse, web};
use crate::infrastructure::http_server::ForceHttpsFlag;
use crate::infrastructure::http_runtime::ForceHttpsFlag;
/// Validate that the host is safe to use in a redirect Location header.
/// Only allows: private IPs (RFC 1918), loopback, .local hostnames, and bare hostnames
/// without dots (e.g., "netguardia"). Rejects public IPs and arbitrary domains
/// to prevent host-header injection / open redirect attacks.
fn is_safe_redirect_host(host: &str) -> bool {
// Strip port if present (e.g., "192.168.1.1:8443" → "192.168.1.1")
let hostname = if host.starts_with('[') {
// IPv6 bracket: [::1]:8443
host.find(']').map(|i| &host[1..i]).unwrap_or(host)
} else {
host.split(':').next().unwrap_or(host)
let Some(hostname) = redirect_hostname(host) else {
return false;
};
// Localhost
if hostname == "localhost" || hostname == "127.0.0.1" || hostname == "::1" {
if hostname.eq_ignore_ascii_case("localhost") {
return true;
}
// .local mDNS hostnames (e.g., "netguardia.local")
if hostname.ends_with(".local") {
return true;
}
// Bare hostname without dots (e.g., "netguardia", not a public domain)
if !hostname.contains('.') && !hostname.contains(':') {
return true;
}
// Try parsing as IP — allow private ranges only
if let Ok(ip) = hostname.parse::<IpAddr>() {
return match ip {
IpAddr::V4(v4) => {
@ -51,7 +31,75 @@ fn is_safe_redirect_host(host: &str) -> bool {
};
}
false
if !is_valid_redirect_hostname(hostname) {
return false;
}
let hostname = hostname.to_ascii_lowercase();
hostname.ends_with(".local") || !hostname.contains('.')
}
fn redirect_hostname(host: &str) -> Option<&str> {
if host.is_empty() {
return None;
}
if host.starts_with('[') {
let bracket_end = host.find(']')?;
let hostname = &host[1..bracket_end];
let rest = &host[bracket_end + 1..];
if hostname.is_empty() || !valid_optional_port(rest) {
return None;
}
return Some(hostname);
}
if host.contains('[') || host.contains(']') {
return None;
}
let colon_count = host.bytes().filter(|byte| *byte == b':').count();
if colon_count > 1 {
return None;
}
if let Some((hostname, port)) = host.rsplit_once(':') {
if hostname.is_empty() || !valid_port(port) {
return None;
}
return Some(hostname);
}
Some(host)
}
fn valid_optional_port(port_suffix: &str) -> bool {
port_suffix.is_empty() || port_suffix.strip_prefix(':').is_some_and(valid_port)
}
fn valid_port(port: &str) -> bool {
!port.is_empty() && port.parse::<u16>().is_ok()
}
fn is_valid_redirect_hostname(hostname: &str) -> bool {
!hostname.is_empty() && hostname.len() <= 253 && hostname.split('.').all(is_valid_redirect_hostname_label)
}
fn is_valid_redirect_hostname_label(label: &str) -> bool {
let first = label.bytes().next();
let last = label.bytes().last();
!label.is_empty()
&& label.len() <= 63
&& first.is_some_and(|byte| byte.is_ascii_alphanumeric())
&& last.is_some_and(|byte| byte.is_ascii_alphanumeric())
&& label.bytes().all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
}
fn is_forwarded_https(proto: &str) -> bool {
proto
.split(',')
.next()
.is_some_and(|first| first.trim().eq_ignore_ascii_case("https"))
}
pub struct HttpsRedirect;
@ -95,7 +143,6 @@ where
let service = Rc::clone(&self.service);
Box::pin(async move {
// Check if force_https is enabled
let force = req
.app_data::<web::Data<ForceHttpsFlag>>()
.map(|flag| flag.0.load(Ordering::Relaxed))
@ -106,28 +153,23 @@ where
return Ok(res);
}
// Allow health check endpoints without redirect (for load balancer probes)
let path = req.path();
if path.starts_with("/health/") {
let res = service.call(req).await?.map_into_left_body();
return Ok(res);
}
// Check X-Forwarded-Proto (set by reverse proxy / load balancer)
let proto = req
.headers()
.get("X-Forwarded-Proto")
.and_then(|v| v.to_str().ok())
.unwrap_or("http");
if proto == "https" {
if is_forwarded_https(proto) {
let res = service.call(req).await?.map_into_left_body();
return Ok(res);
}
// Build HTTPS redirect URL.
// Validate host to prevent host-header injection / open redirect:
// only allow private IPs, localhost, and .local hostnames.
let host = req.connection_info().host().to_string();
let uri = req.uri().clone();
@ -139,10 +181,49 @@ where
let redirect_url = format!("https://{}{}", host, uri);
let resp = HttpResponse::MovedPermanently()
.insert_header((header::LOCATION, redirect_url))
// HSTS: 1 year, include subdomains
.insert_header(("Strict-Transport-Security", "max-age=31536000; includeSubDomains"))
.finish();
Ok(req.into_response(resp).map_into_right_body())
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn safe_redirect_host_accepts_private_and_local_hosts() {
assert!(is_safe_redirect_host("192.168.1.10:8443"));
assert!(is_safe_redirect_host("[::1]:8443"));
assert!(is_safe_redirect_host("[fd00::1]"));
assert!(is_safe_redirect_host("[fd00::1]:8443"));
assert!(is_safe_redirect_host("localhost"));
assert!(is_safe_redirect_host("netguardia"));
assert!(is_safe_redirect_host("netguardia.local"));
}
#[test]
fn safe_redirect_host_rejects_public_and_malformed_hosts() {
assert!(!is_safe_redirect_host(""));
assert!(!is_safe_redirect_host("8.8.8.8"));
assert!(!is_safe_redirect_host("example.com"));
assert!(!is_safe_redirect_host("evil%2ecom"));
assert!(!is_safe_redirect_host("bad host"));
assert!(!is_safe_redirect_host("-netguardia"));
assert!(!is_safe_redirect_host("netguardia-"));
assert!(!is_safe_redirect_host("fd00::1"));
assert!(!is_safe_redirect_host("[::1]evil"));
assert!(!is_safe_redirect_host("192.168.1.10:http"));
assert!(!is_safe_redirect_host("192.168.1.10:99999"));
}
#[test]
fn forwarded_proto_accepts_proxy_chain_first_hop() {
assert!(is_forwarded_https("https"));
assert!(is_forwarded_https("HTTPS"));
assert!(is_forwarded_https(" https, http"));
assert!(!is_forwarded_https("http, https"));
assert!(!is_forwarded_https(""));
}
}

View File

@ -1,218 +0,0 @@
use std::future::{Future, Ready, ready};
use std::pin::Pin;
use std::rc::Rc;
use std::task::{Context, Poll};
use actix_web::body::EitherBody;
use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform};
use actix_web::http::Method;
use actix_web::{Error as ActixError, HttpMessage, HttpResponse, web};
use macros::log;
use crate::adapter::http::middleware::jwt::JwtService;
use crate::domain::identity::error::AuthError;
use crate::interface::port::api_key::ApiKeyRepo;
use crate::interface::port::app_repo::AppRepo;
pub struct AuthMiddleware;
impl<S, B> Transform<S, ServiceRequest> for AuthMiddleware
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
B: 'static,
{
type Response = ServiceResponse<EitherBody<B>>;
type Error = ActixError;
type Transform = AuthMiddlewareService<S>;
type InitError = ();
type Future = Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future {
ready(Ok(AuthMiddlewareService {
service: Rc::new(service),
}))
}
}
pub struct AuthMiddlewareService<S> {
service: Rc<S>,
}
fn required_permission(path: &str, method: &Method) -> Option<String> {
let resource = if path == "/api/auth/login" || path == "/api/auth/me" || path == "/api/auth/change-password" {
return None;
} else if path.starts_with("/api/auth/") {
return Some("users:admin".to_string());
} else if path.starts_with("/api/health/") || path.starts_with("/api/stats/") {
"dashboard"
} else if path.starts_with("/api/ml/") || path.starts_with("/api/byo/") {
"ai_detection"
} else if path.starts_with("/api/fusion/") {
"fusion"
} else if path.starts_with("/api/flow-trace/") {
"flow_trace"
} else if path.starts_with("/api/acl/geo/") {
"geo_block"
} else if path.starts_with("/api/acl/") {
"access_control"
} else if path.starts_with("/api/filter/dns/") {
"dns_filter"
} else if path.starts_with("/api/filter/http/") || path.starts_with("/api/filter/ssh/") {
"protocol_filter"
} else if path.starts_with("/api/rate-limit/") {
"rate_limit"
} else if path.starts_with("/api/system/") {
"system"
} else if path.contains("/soar/blocks/") && path.ends_with("/unblock") {
return Some("access_control:write".to_string());
} else if path.starts_with("/api/soar/")
|| path.starts_with("/api/notifications/")
|| path.starts_with("/api/report/")
|| path.starts_with("/api/api-keys/")
|| path.starts_with("/api/logs/")
|| path.starts_with("/api/audit/")
{
"system"
} else {
return None;
};
let action = match *method {
Method::GET => "read",
_ => "write",
};
Some(format!("{}:{}", resource, action))
}
impl<S, B> Service<ServiceRequest> for AuthMiddlewareService<S>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
B: 'static,
{
type Response = ServiceResponse<EitherBody<B>>;
type Error = ActixError;
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>>>>;
fn poll_ready(&self, ctx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.service.poll_ready(ctx)
}
fn call(&self, req: ServiceRequest) -> Self::Future {
let service = Rc::clone(&self.service);
Box::pin(async move {
let path = req.path().to_string();
// Skip auth for public endpoints
if path == "/api/auth/login" || path.starts_with("/api/setup/") || !path.starts_with("/api/") {
let res = service.call(req).await?.map_into_left_body();
return Ok(res);
}
// Extract JWT service from app data
let jwt_service = match req.app_data::<web::Data<JwtService>>() {
Some(s) => s.clone(),
None => {
let resp =
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Auth not configured"}));
return Ok(req.into_response(resp).map_into_right_body());
}
};
// Try JWT first, then fall back to API key
let claims = if let Some(auth_header) = req.headers().get("Authorization") {
// JWT Bearer token auth
let val_str = auth_header.to_str().unwrap_or("");
let token = match val_str.strip_prefix("Bearer ") {
Some(t) => t,
None => {
let resp = HttpResponse::Unauthorized()
.json(serde_json::json!({"error": "Invalid authorization header"}));
return Ok(req.into_response(resp).map_into_right_body());
}
};
match jwt_service.validate_token(token) {
Ok(c) => c,
Err(_) => {
let resp =
HttpResponse::Unauthorized().json(serde_json::json!({"error": "Invalid or expired token"}));
return Ok(req.into_response(resp).map_into_right_body());
}
}
} else if let Some(api_key_header) = req.headers().get("X-API-Key") {
// API key auth with rate limiting
let api_key = api_key_header.to_str().unwrap_or("");
let api_key_port = match req.app_data::<web::Data<dyn ApiKeyRepo>>() {
Some(d) => d.clone(),
None => {
let resp = HttpResponse::InternalServerError()
.json(serde_json::json!({"error": "ApiKeyRepo not configured"}));
return Ok(req.into_response(resp).map_into_right_body());
}
};
let repo = match req.app_data::<web::Data<dyn AppRepo>>() {
Some(d) => d.clone(),
None => {
let resp = HttpResponse::InternalServerError()
.json(serde_json::json!({"error": "AppRepo not configured"}));
return Ok(req.into_response(resp).map_into_right_body());
}
};
// Rate limit check for API key attempts (reuse login failure tracking)
let rate_key = format!(
"apikey:{}",
req.peer_addr().map(|a| a.ip().to_string()).unwrap_or_default()
);
if let Ok(Some(remaining)) = repo.check_login_locked(&rate_key) {
let resp = HttpResponse::TooManyRequests().json(serde_json::json!({
"error": "Too many failed API key attempts",
"retry_after_secs": remaining,
}));
return Ok(req.into_response(resp).map_into_right_body());
}
match api_key_port.validate_api_key(api_key) {
Ok(Some(key_claims)) => {
if let Err(e) = repo.clear_login_failures(&rate_key) {
log!(AuthError::LoginClearError(e));
}
key_claims
}
Ok(None) => {
if let Err(e) = repo.record_login_failure(&rate_key) {
log!(AuthError::LoginFailureTrackingError(e));
}
let resp = HttpResponse::Unauthorized()
.json(serde_json::json!({"error": "Invalid or revoked API key"}));
return Ok(req.into_response(resp).map_into_right_body());
}
Err(_) => {
let resp = HttpResponse::InternalServerError()
.json(serde_json::json!({"error": "API key validation failed"}));
return Ok(req.into_response(resp).map_into_right_body());
}
}
} else {
let resp =
HttpResponse::Unauthorized().json(serde_json::json!({"error": "Missing authorization header"}));
return Ok(req.into_response(resp).map_into_right_body());
};
// Permission-based RBAC check
if let Some(required) = required_permission(&path, req.method())
&& !claims.permissions.contains(&required)
{
let resp = HttpResponse::Forbidden().json(serde_json::json!({"error": "Insufficient permissions"}));
return Ok(req.into_response(resp).map_into_right_body());
}
// Store claims in request extensions
req.extensions_mut().insert(claims);
let res = service.call(req).await?.map_into_left_body();
Ok(res)
})
}
}

View File

@ -1,14 +1,15 @@
use std::future::{Future, Ready, ready};
use std::pin::Pin;
use std::rc::Rc;
use std::sync::atomic::Ordering;
use std::task::{Context, Poll};
use actix_web::body::EitherBody;
use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform};
use actix_web::http::StatusCode;
use actix_web::{Error as ActixError, HttpResponse, web};
use crate::infrastructure::http_server::SetupCompleteFlag;
use crate::adapter::http::helpers::json_error;
use crate::infrastructure::http_runtime::SetupCompleteFlag;
pub struct SetupGuard;
@ -52,25 +53,19 @@ where
Box::pin(async move {
let path = req.path().to_string();
// Check setup_complete flag from app data
let setup_complete = req
.app_data::<web::Data<SetupCompleteFlag>>()
.map(|flag| flag.0.load(Ordering::SeqCst))
.unwrap_or(true);
.map(|flag| flag.is_complete())
.unwrap_or(false);
if setup_complete {
// Normal mode: pass through, but block setup mutation endpoints.
// Allow /api/setup/status (read-only) so frontend can check setup state.
if path.starts_with("/api/setup/") && path != "/api/setup/status" {
let resp = HttpResponse::Gone().json(serde_json::json!({"error": "Setup already completed"}));
let resp = json_error(StatusCode::GONE, "Setup already completed");
return Ok(req.into_response(resp).map_into_right_body());
}
let res = service.call(req).await?.map_into_left_body();
return Ok(res);
}
// Setup mode: only allow setup wizard and health endpoints
if path.starts_with("/api/setup/")
|| path.starts_with("/api/health/")
|| path == "/api/auth/login"
@ -79,8 +74,6 @@ where
let res = service.call(req).await?.map_into_left_body();
return Ok(res);
}
// Block all other API routes with 503
let resp = HttpResponse::ServiceUnavailable().json(serde_json::json!({
"error": "System setup in progress",
"setup_required": true,

View File

@ -4,10 +4,11 @@ pub mod default;
pub mod detection;
pub mod helpers;
pub mod identity;
pub mod jwt;
pub mod logs;
pub mod middleware;
pub mod ready;
pub mod response;
pub mod session;
pub mod setup;
pub mod static_files;
pub mod system;

View File

@ -1,23 +1,10 @@
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering::SeqCst;
use actix_web::{HttpResponse, web};
use crate::infrastructure::readiness::ReadinessState;
use crate::infrastructure::http_runtime::ReadyFlag;
use crate::interface::system::http_runtime::ReadinessQuery;
pub async fn health_ready(ready: web::Data<Arc<AtomicBool>>, state: web::Data<ReadinessState>) -> HttpResponse {
let is_ready = ready.load(SeqCst);
let uptime_secs = state.started_at.elapsed().as_secs();
HttpResponse::Ok().json(serde_json::json!({
"ready": is_ready,
"subsystems": {
"db_connected": state.db_connected.load(SeqCst),
"ml_model_loaded": state.ml_model_loaded.load(SeqCst),
"soar_engine_running": state.soar_engine_running.load(SeqCst),
"ebpf_attached": state.ebpf_attached.load(SeqCst),
},
"uptime_secs": uptime_secs,
}))
pub async fn health_ready(ready: web::Data<ReadyFlag>, state: web::Data<dyn ReadinessQuery>) -> HttpResponse {
HttpResponse::Ok().json(state.snapshot(ready.0.load(SeqCst)))
}

View File

@ -1,21 +1,11 @@
use std::fs;
use actix_web::{HttpResponse, Scope, web};
use arc_swap::ArcSwap;
use chrono::Local;
use tokio::task::spawn_blocking;
use tokio_util::io::ReaderStream;
use crate::adapter::http::helpers::ok_json_or_error;
use crate::adapter::http::helpers::{internal_error, ok_json_or_error};
use crate::adapter::http::middleware::extractor::AuthClaims;
use crate::adapter::notification::smtp::SmtpClient;
use crate::adapter::persistence::Database;
use crate::core::reporting::email_report::generate_weekly_report;
use crate::core::reporting::report_engine;
use crate::domain::common::config::AppConfig;
use crate::domain::common::error::misc::MiscError;
use crate::infrastructure::secret_store::SecretStore;
use crate::interface::report_snapshot::ReportSnapshotRepo;
use crate::interface::secret_store::SecretStorePort;
use crate::core::reporting::report_delivery::ReportDeliveryService;
use crate::core::reporting::report_generation::ReportGenerationService;
use crate::domain::report::error::ReportError;
pub fn initialize() -> Scope {
web::scope("/report")
@ -24,108 +14,51 @@ pub fn initialize() -> Scope {
.route("/send", web::post().to(send_report))
}
async fn generate_report(
_auth: AuthClaims,
db: web::Data<Database>,
config: web::Data<ArcSwap<AppConfig>>,
) -> HttpResponse {
let report_dir = config.load().system.report_dir.clone();
if let Err(e) = fs::create_dir_all(&report_dir) {
return HttpResponse::InternalServerError().json(serde_json::json!({
"error": format!("Failed to create report directory: {}", e)
}));
}
let db_ref = db.get_ref();
match report_engine::generate_html_report(db_ref as &dyn ReportSnapshotRepo, &report_dir).await {
Ok(path) => match fs::read(&path) {
Ok(content) => HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.insert_header((
"Content-Disposition",
format!(
"attachment; filename=\"{}\"",
path.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "report.html".into())
),
))
.body(content),
async fn generate_report(_auth: AuthClaims, reports: web::Data<ReportGenerationService>) -> HttpResponse {
match reports.generate_html_report().await {
Ok(path) => match tokio::fs::File::open(&path).await {
Ok(file) => {
let filename = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "report.html".into());
let stream = ReaderStream::new(file);
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.insert_header(("Content-Disposition", format!("attachment; filename=\"{}\"", filename)))
.streaming(stream)
}
Err(_) => HttpResponse::Ok().json(serde_json::json!({
"success": true,
"path": path.to_string_lossy(),
"message": "HTML report generated."
})),
},
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
Err(e) => internal_error(e),
}
}
async fn report_data(_auth: AuthClaims, db: web::Data<Database>) -> HttpResponse {
let db_ref = db.get_ref();
ok_json_or_error(report_engine::generate_report_json(db_ref as &dyn ReportSnapshotRepo).await)
async fn report_data(_auth: AuthClaims, reports: web::Data<ReportGenerationService>) -> HttpResponse {
ok_json_or_error(reports.report_data().await)
}
/// Manually trigger: generate the weekly report and send it via SMTP now.
async fn send_report(
_auth: AuthClaims,
db: web::Data<Database>,
config: web::Data<ArcSwap<AppConfig>>,
secrets: web::Data<SecretStore>,
) -> HttpResponse {
let db_ref = db.get_ref() as &dyn ReportSnapshotRepo;
let secrets_ref = secrets.get_ref() as &dyn SecretStorePort;
let smtp_cfg = config.load().notification.smtp.clone();
let smtp = match SmtpClient::from_config(&smtp_cfg, Some(secrets_ref)).await {
Ok(Some(client)) => client,
Ok(None) => {
return HttpResponse::BadRequest().json(serde_json::json!({
"success": false,
"error": "SMTP not configured. Ensure smtp_host, smtp_port, smtp_username, smtp_password are set, and that the sender address (smtp_sender or smtp_username) contains '@'."
}));
}
Err(e) => {
return HttpResponse::InternalServerError().json(serde_json::json!({
"success": false,
"error": format!("Failed to read SMTP settings: {e}")
}));
}
};
let recipient = smtp_cfg.recipient;
if recipient.is_empty() {
return HttpResponse::BadRequest().json(serde_json::json!({
"success": false,
"error": MiscError::ValidationError("No smtp_recipient configured.").to_string()
}));
}
let html = match generate_weekly_report(db_ref).await {
Ok(h) => h,
Err(e) => {
return HttpResponse::InternalServerError().json(serde_json::json!({
"success": false,
"error": format!("Failed to generate report: {e}")
}));
}
};
let subject = format!("NetGuardia Weekly Report — {}", Local::now().format("%Y-%m-%d"));
let send_result = spawn_blocking(move || smtp.send(&recipient, &subject, &html)).await;
match send_result {
Ok(Ok(())) => HttpResponse::Ok().json(serde_json::json!({
async fn send_report(_auth: AuthClaims, delivery: web::Data<ReportDeliveryService>) -> HttpResponse {
match delivery.send_weekly_report_now().await {
Ok(()) => HttpResponse::Ok().json(serde_json::json!({
"success": true,
"message": "Report sent successfully."
})),
Ok(Err(e)) => HttpResponse::InternalServerError().json(serde_json::json!({
Err(ReportError::SmtpNotConfigured) => HttpResponse::BadRequest().json(serde_json::json!({
"success": false,
"error": format!("Failed to send report: {e}")
"error": "SMTP not configured. Ensure smtp_host, smtp_port, smtp_username, smtp_password are set, and that the sender address (smtp_sender or smtp_username) contains '@'."
})),
Err(ReportError::RecipientMissing) => HttpResponse::BadRequest().json(serde_json::json!({
"success": false,
"error": "No smtp_recipient configured."
})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({
"success": false,
"error": format!("Send task panicked: {e}")
"error": e.to_string()
})),
}
}

View File

@ -4,13 +4,22 @@ use actix_web::{HttpResponse, Scope, web};
use arc_swap::ArcSwap;
use serde::Deserialize;
use crate::adapter::http::helpers::{ok_json_or_error, ok_or_error};
use crate::adapter::http::helpers::{bad_request, internal_error, not_found, ok_json_or_error, ok_or_error};
use crate::adapter::http::middleware::extractor::AuthClaims;
use crate::common::error::Error;
use crate::common::error::codec::CodecError;
use crate::core::response::engine::SoarEngine;
use crate::core::response::playbook_service::PlaybookService;
use crate::domain::common::config::AppConfig;
use crate::domain::common::event::{DetectionSource, ThreatDetectedEvent};
use crate::domain::response::playbook_data::{CreateConditionInput, CreatePlaybookInput};
use crate::domain::data_plane::error::EbpfError;
use crate::domain::response::playbook_validator;
use crate::interface::response::playbook_data::{ActionInput, CreateConditionInput, CreatePlaybookInput};
const DEFAULT_DRY_RUN_DEST_IP: &str = "0.0.0.0";
const DEFAULT_DRY_RUN_FLOW_COUNT: u32 = 1;
const DEFAULT_DRY_RUN_PACKET_RATE: f64 = 0.0;
const DEFAULT_DRY_RUN_PROTOCOL: u8 = 6;
#[derive(Deserialize)]
struct CreatePlaybookRequest {
@ -38,19 +47,38 @@ struct CreateConditionRequest {
value2: Option<String>,
}
fn map_request_to_input(body: &CreatePlaybookRequest, fallback_cooldown: i64) -> CreatePlaybookInput {
fn map_request_to_input(
body: &CreatePlaybookRequest,
fallback_cooldown: i64,
max_ttl_secs: u64,
) -> Result<CreatePlaybookInput, String> {
playbook_validator::validate_optional_positive_i64("condition_count", body.condition_count)
.map_err(|e| e.to_string())?;
playbook_validator::validate_optional_positive_i64("condition_window_secs", body.condition_window_secs)
.map_err(|e| e.to_string())?;
let cooldown_secs = body.cooldown_secs.unwrap_or(fallback_cooldown);
playbook_validator::validate_cooldown_secs(cooldown_secs).map_err(|e| e.to_string())?;
let actions = body
.actions
.iter()
.map(|a| {
.enumerate()
.map(|(index, a)| {
playbook_validator::validate_action(&a.action_type, a.params.as_ref(), max_ttl_secs)
.map_err(|e| e.to_string())?;
let params_str = a
.params
.as_ref()
.map(|v| serde_json::to_string(v).unwrap_or_else(|_| "{}".into()))
.map(|v| serde_json::to_string(v).map_err(|err| CodecError::SerializeFailed(err).to_string()))
.transpose()?
.unwrap_or_else(|| "{}".into());
(a.action_type.clone(), params_str)
Ok(ActionInput {
action_order: (index + 1) as i64,
action_type: a.action_type.clone(),
params_json: params_str,
})
})
.collect();
.collect::<Result<Vec<_>, String>>()?;
let conditions = body
.conditions
@ -58,25 +86,28 @@ fn map_request_to_input(body: &CreatePlaybookRequest, fallback_cooldown: i64) ->
.unwrap_or_default()
.iter()
.map(|c| {
CreateConditionInput::new(
let input = CreateConditionInput::new(
c.condition_type.clone(),
c.operator.clone(),
c.value.clone(),
c.value2.clone(),
)
.map_err(|_| format!("unknown condition_type: {}", c.condition_type))?;
playbook_validator::validate_condition_input(&input).map_err(|e| e.to_string())?;
Ok(input)
})
.collect();
.collect::<Result<Vec<_>, String>>()?;
CreatePlaybookInput {
Ok(CreatePlaybookInput {
name: body.name.clone(),
trigger_event: body.trigger_event.clone(),
condition_threshold: body.condition_threshold,
condition_count: body.condition_count,
condition_window_secs: body.condition_window_secs,
cooldown_secs: body.cooldown_secs.unwrap_or(fallback_cooldown),
cooldown_secs,
actions,
conditions,
}
})
}
pub fn initialize() -> Scope {
@ -105,10 +136,14 @@ async fn create_playbook(
app_config: web::Data<ArcSwap<AppConfig>>,
body: web::Json<CreatePlaybookRequest>,
) -> HttpResponse {
let input = map_request_to_input(&body, app_config.load().soar.fallback_cooldown_secs);
let soar_cfg = app_config.load().soar.clone();
let input = match map_request_to_input(&body, soar_cfg.fallback_cooldown_secs, soar_cfg.max_ttl_secs) {
Ok(input) => input,
Err(e) => return bad_request(e),
};
match svc.create_playbook(&input).await {
Ok(id) => HttpResponse::Created().json(serde_json::json!({"id": id})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
Err(e) => internal_error(e),
}
}
@ -120,11 +155,15 @@ async fn update_playbook(
body: web::Json<CreatePlaybookRequest>,
) -> HttpResponse {
let id = path.into_inner();
let input = map_request_to_input(&body, app_config.load().soar.fallback_cooldown_secs);
let soar_cfg = app_config.load().soar.clone();
let input = match map_request_to_input(&body, soar_cfg.fallback_cooldown_secs, soar_cfg.max_ttl_secs) {
Ok(input) => input,
Err(e) => return bad_request(e),
};
match svc.update_playbook(id, &input).await {
Ok(true) => HttpResponse::Ok().json(serde_json::json!({"updated": true})),
Ok(false) => HttpResponse::NotFound().json(serde_json::json!({"error": "Playbook not found"})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
Ok(false) => not_found("Playbook not found"),
Err(e) => internal_error(e),
}
}
@ -141,16 +180,16 @@ async fn toggle_playbook(
) -> HttpResponse {
match svc.toggle_playbook(path.into_inner(), body.enabled).await {
Ok(true) => HttpResponse::Ok().json(serde_json::json!({"updated": true})),
Ok(false) => HttpResponse::NotFound().json(serde_json::json!({"error": "Playbook not found"})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
Ok(false) => not_found("Playbook not found"),
Err(e) => internal_error(e),
}
}
async fn delete_playbook(_auth: AuthClaims, svc: web::Data<PlaybookService>, path: web::Path<i64>) -> HttpResponse {
match svc.delete_playbook(path.into_inner()).await {
Ok(true) => HttpResponse::Ok().json(serde_json::json!({"deleted": true})),
Ok(false) => HttpResponse::NotFound().json(serde_json::json!({"error": "Playbook not found"})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
Ok(false) => not_found("Playbook not found"),
Err(e) => internal_error(e),
}
}
@ -186,20 +225,24 @@ async fn add_whitelist(
) -> HttpResponse {
match svc.add_whitelist(&body.ip).await {
Ok(()) => HttpResponse::Created().json(serde_json::json!({"added": true})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
Err(e) => whitelist_error(e),
}
}
async fn remove_whitelist(_auth: AuthClaims, svc: web::Data<PlaybookService>, path: web::Path<String>) -> HttpResponse {
ok_or_error(svc.remove_whitelist(&path.into_inner()).await)
match svc.remove_whitelist(&path.into_inner()).await {
Ok(()) => HttpResponse::Ok().finish(),
Err(e) => whitelist_error(e),
}
}
fn whitelist_error(error: Error) -> HttpResponse {
match &error {
Error::Ebpf(EbpfError::InvalidIpAddress { .. }) => bad_request(error),
_ => internal_error(error),
}
}
/// Client shape for `POST /api/soar/dry-run`. Only the fields a SOAR
/// matcher actually reads are carried — `dest_ip`, `protocol`,
/// `packet_rate`, `flow_count` participate in neither trigger-matching
/// nor condition evaluation, so accepting them would just invite
/// confusion. Sensible defaults fill in the rest of the synthetic
/// `ThreatDetectedEvent` body.
#[derive(Deserialize)]
struct DryRunRequest {
attack_type: String,
@ -217,16 +260,11 @@ struct DryRunRequest {
is_repeat_offender: Option<bool>,
}
/// `POST /api/soar/dry-run` — simulate every enabled playbook against
/// a synthetic event. No actions execute, no cooldown or frequency
/// state gets recorded. Useful for an admin who just edited a
/// playbook's conditions and wants to sanity-check the match logic
/// before enabling it.
async fn dry_run(_auth: AuthClaims, engine: web::Data<SoarEngine>, body: web::Json<DryRunRequest>) -> HttpResponse {
let event = match build_event(body.into_inner()) {
Ok(e) => e,
Err(msg) => {
return HttpResponse::BadRequest().json(serde_json::json!({ "error": msg }));
return bad_request(msg);
}
};
let matches = engine.dry_run(&event);
@ -237,39 +275,273 @@ async fn dry_run(_auth: AuthClaims, engine: web::Data<SoarEngine>, body: web::Js
}))
}
/// Translate a wire `DryRunRequest` into a synthetic `ThreatDetectedEvent`.
/// Errors on typo'd `DetectionSource` names so an admin dry-running a
/// `SingleSourceHigh` condition doesn't silently get an empty sources
/// vector and a "doesn't match" result they misread as the playbook
/// being broken.
fn build_event(req: DryRunRequest) -> Result<ThreatDetectedEvent, String> {
let sources: Vec<DetectionSource> = match req.sources {
Some(names) => names
.iter()
.map(|n| DetectionSource::from_str(n).map_err(|_| format!("unknown DetectionSource: {n}")))
.collect::<Result<Vec<_>, _>>()?,
None => vec![DetectionSource::ML],
};
if sources.is_empty() {
return Err("sources[] must contain at least one DetectionSource (send null to default to [ML])".to_string());
}
let active_source_count = req.active_source_count.unwrap_or(sources.len());
let sources = parse_dry_run_sources(req.sources)?;
let active_source_count = resolve_dry_run_active_source_count(req.active_source_count, sources.len())?;
let fused_confidence = req.fused_confidence.unwrap_or(req.confidence);
Ok(ThreatDetectedEvent {
attack_type: req.attack_type,
confidence: req.confidence,
source_ip: req.source_ip,
dest_ip: "0.0.0.0".to_string(),
flow_count: 1,
packet_rate: 0.0,
protocol: 6,
dest_ip: DEFAULT_DRY_RUN_DEST_IP.to_string(),
flow_count: DEFAULT_DRY_RUN_FLOW_COUNT,
packet_rate: DEFAULT_DRY_RUN_PACKET_RATE,
protocol: DEFAULT_DRY_RUN_PROTOCOL,
geoip_country: req.geoip_country,
is_repeat_offender: req.is_repeat_offender.unwrap_or(false),
sources,
active_source_count,
fused_confidence,
ae_score: 0.0,
anomaly_score: 0.0,
c2_score: 0.0,
diagnostics: Vec::new(),
})
}
fn parse_dry_run_sources(names: Option<Vec<String>>) -> Result<Vec<DetectionSource>, String> {
let sources = match names {
Some(names) => names
.iter()
.map(|name| DetectionSource::from_str(name).map_err(|_| format!("unknown DetectionSource: {name}")))
.collect::<Result<Vec<_>, _>>()?,
None => vec![DetectionSource::ML],
};
if sources.is_empty() {
return Err("sources[] must contain at least one DetectionSource (send null to default to [ML])".to_string());
}
Ok(sources)
}
fn resolve_dry_run_active_source_count(
active_source_count: Option<usize>,
sources_len: usize,
) -> Result<usize, String> {
let active_source_count = active_source_count.unwrap_or(sources_len);
if active_source_count != sources_len {
return Err(format!(
"active_source_count must match sources.len() for dry-run events (got {active_source_count}, expected {sources_len})"
));
}
Ok(active_source_count)
}
#[cfg(test)]
mod tests {
use super::*;
fn playbook_request() -> CreatePlaybookRequest {
CreatePlaybookRequest {
name: "block scan".to_string(),
trigger_event: "port_scan".to_string(),
condition_threshold: None,
condition_count: None,
condition_window_secs: None,
cooldown_secs: Some(60),
actions: vec![CreateActionRequest {
action_type: "block_ip".to_string(),
params: None,
}],
conditions: None,
}
}
fn input_error(req: &CreatePlaybookRequest) -> String {
match map_request_to_input(req, 300, 3_600) {
Ok(_) => panic!("expected playbook input validation error"),
Err(err) => err,
}
}
fn dry_run_request(sources: Option<Vec<String>>, active_source_count: Option<usize>) -> DryRunRequest {
DryRunRequest {
attack_type: "c2".to_string(),
confidence: 0.9,
source_ip: "192.0.2.10".to_string(),
sources,
active_source_count,
fused_confidence: None,
geoip_country: None,
is_repeat_offender: None,
}
}
#[test]
fn map_request_rejects_negative_cooldown() {
let mut req = playbook_request();
req.cooldown_secs = Some(-1);
let err = input_error(&req);
assert_eq!(err, "cooldown_secs must be greater than or equal to 0");
}
#[test]
fn map_request_rejects_non_positive_condition_windows() {
let mut req = playbook_request();
req.condition_count = Some(0);
let err = input_error(&req);
assert_eq!(err, "condition_count must be greater than 0");
req.condition_count = Some(1);
req.condition_window_secs = Some(-1);
let err = input_error(&req);
assert_eq!(err, "condition_window_secs must be greater than 0");
}
#[test]
fn map_request_rejects_invalid_action_params() {
let mut req = playbook_request();
req.actions[0].params = Some(serde_json::json!({"ttl_secs": 0}));
let err = input_error(&req);
assert_eq!(err, "ttl_secs must be greater than 0");
req.actions[0].params = Some(serde_json::json!({"ttl_secs": 3_601}));
let err = input_error(&req);
assert_eq!(err, "ttl_secs must be less than or equal to 3600");
}
#[test]
fn map_request_rejects_unknown_action_type() {
let mut req = playbook_request();
req.actions[0].action_type = "typo".to_string();
let err = input_error(&req);
assert_eq!(err, "unknown action_type: typo");
}
#[test]
fn map_request_rejects_unknown_condition_type() {
let mut req = playbook_request();
req.conditions = Some(vec![CreateConditionRequest {
condition_type: "typo".to_string(),
operator: None,
value: "0.9".to_string(),
value2: None,
}]);
let err = input_error(&req);
assert_eq!(err, "unknown condition_type: typo");
}
#[test]
fn map_request_rejects_invalid_condition_operator() {
let mut req = playbook_request();
req.conditions = Some(vec![CreateConditionRequest {
condition_type: "threshold".to_string(),
operator: Some("in".to_string()),
value: "0.9".to_string(),
value2: None,
}]);
let err = input_error(&req);
assert_eq!(err, "invalid operator 'in' for condition_type 'threshold'");
}
#[test]
fn map_request_rejects_invalid_condition_values() {
let cases = [
("threshold", "not-a-number", "threshold value must be a finite number"),
("frequency", "0", "frequency value must be a positive integer"),
(
"ip_pattern",
"not-cidr",
"ip_pattern value must be a valid CIDR: not-cidr",
),
(
"repeat_offender",
"maybe",
"repeat_offender value must be 'true' or 'false'",
),
(
"single_source_high",
"UnknownSource",
"single_source_high value must be a valid DetectionSource: UnknownSource",
),
];
for (condition_type, value, expected) in cases {
let mut req = playbook_request();
req.conditions = Some(vec![CreateConditionRequest {
condition_type: condition_type.to_string(),
operator: None,
value: value.to_string(),
value2: None,
}]);
let err = input_error(&req);
assert_eq!(err, expected);
}
}
#[test]
fn map_request_accepts_false_repeat_offender_condition() {
let mut req = playbook_request();
req.conditions = Some(vec![CreateConditionRequest {
condition_type: "repeat_offender".to_string(),
operator: None,
value: "false".to_string(),
value2: None,
}]);
let input = map_request_to_input(&req, 300, 3_600).expect("valid repeat_offender false condition");
assert_eq!(input.conditions[0].value, "false");
}
#[test]
fn map_request_rejects_invalid_webhook_params() {
let mut req = playbook_request();
req.actions[0].action_type = "webhook".to_string();
req.actions[0].params = Some(serde_json::json!({"timeout_secs": 0}));
let err = input_error(&req);
assert_eq!(err, "url is required");
req.actions[0].params = Some(serde_json::json!({"url": "https://example.test/hook", "timeout_secs": 0}));
let err = input_error(&req);
assert_eq!(err, "timeout_secs must be greater than 0");
}
#[test]
fn build_event_defaults_active_source_count_from_sources() {
let req = dry_run_request(Some(vec!["ML".to_string(), "Suricata".to_string()]), None);
let event = build_event(req).unwrap();
assert_eq!(event.active_source_count, 2);
assert_eq!(event.sources, vec![DetectionSource::ML, DetectionSource::Suricata]);
}
#[test]
fn build_event_rejects_active_source_count_mismatch() {
let req = dry_run_request(Some(vec!["ML".to_string(), "Suricata".to_string()]), Some(1));
let err = build_event(req).unwrap_err();
assert!(err.contains("active_source_count must match sources.len()"));
}
#[test]
fn build_event_rejects_unknown_detection_source() {
let req = dry_run_request(Some(vec!["ML".to_string(), "typo".to_string()]), None);
let err = build_event(req).unwrap_err();
assert_eq!(err, "unknown DetectionSource: typo");
}
#[test]
fn whitelist_invalid_ip_errors_are_bad_requests() {
let response = whitelist_error(EbpfError::InvalidIpAddress("not an ip".to_string()).into());
assert_eq!(response.status(), actix_web::http::StatusCode::BAD_REQUEST);
}
}

View File

@ -0,0 +1,109 @@
use std::sync::Arc;
use actix_web::cookie::time::Duration as CookieDuration;
use actix_web::cookie::{Cookie, SameSite};
use arc_swap::ArcSwap;
use crate::core::identity::session_service::CreatedSession;
use crate::domain::common::config::AppConfig;
pub const SESSION_COOKIE_NAME: &str = "netguardia_session";
pub const SECURE_SESSION_COOKIE_NAME: &str = "__Host-netguardia_session";
pub struct SessionCookieService {
config: Arc<ArcSwap<AppConfig>>,
}
impl SessionCookieService {
pub fn new(config: Arc<ArcSwap<AppConfig>>) -> Self {
Self { config }
}
pub fn session_cookie(&self, session: &CreatedSession) -> Cookie<'static> {
Cookie::build(self.cookie_name(), session.id.clone())
.path("/")
.http_only(true)
.secure(self.secure_cookie())
.same_site(SameSite::Strict)
.max_age(cookie_duration(session.max_age_secs))
.finish()
}
pub fn removal_cookies(&self) -> Vec<Cookie<'static>> {
let mut cookies = vec![self.removal_cookie_for(self.cookie_name())];
if self.cookie_name() != SESSION_COOKIE_NAME {
cookies.push(self.removal_cookie_for(SESSION_COOKIE_NAME));
}
cookies
}
pub fn cookie_name(&self) -> &'static str {
if self.secure_cookie() {
SECURE_SESSION_COOKIE_NAME
} else {
SESSION_COOKIE_NAME
}
}
fn removal_cookie_for(&self, name: &'static str) -> Cookie<'static> {
Cookie::build(name, "")
.path("/")
.http_only(true)
.secure(self.secure_cookie())
.same_site(SameSite::Strict)
.max_age(CookieDuration::seconds(0))
.finish()
}
fn secure_cookie(&self) -> bool {
self.config.load().http_server.force_https
}
}
fn cookie_duration(secs: u64) -> CookieDuration {
let secs = i64::try_from(secs).unwrap_or(i64::MAX);
CookieDuration::seconds(secs)
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use arc_swap::ArcSwap;
use super::*;
fn cookie_service(force_https: bool) -> SessionCookieService {
let mut cfg = AppConfig::defaults();
cfg.http_server.force_https = force_https;
SessionCookieService::new(Arc::new(ArcSwap::from_pointee(cfg)))
}
fn created_session() -> CreatedSession {
CreatedSession {
id: "session-id".to_string(),
csrf_token: "csrf-token".to_string(),
max_age_secs: 3600,
}
}
#[test]
fn session_cookie_is_http_only_and_strict() {
let service = cookie_service(false);
let cookie = service.session_cookie(&created_session());
assert_eq!(cookie.name(), SESSION_COOKIE_NAME);
assert!(cookie.http_only().unwrap_or(false));
assert_eq!(cookie.same_site(), Some(SameSite::Strict));
assert_eq!(cookie.value(), "session-id");
}
#[test]
fn secure_cookie_uses_host_prefix_when_https_is_forced() {
let service = cookie_service(true);
let cookie = service.session_cookie(&created_session());
assert_eq!(cookie.name(), SECURE_SESSION_COOKIE_NAME);
assert!(cookie.secure().unwrap_or(false));
}
}

View File

@ -1,21 +1,34 @@
use std::fs;
use std::io;
use std::path::Path;
use std::sync::atomic::Ordering;
use actix_web::{HttpResponse, Scope, web};
use actix_web::{HttpRequest, HttpResponse, Scope, web};
use macros::log;
use serde::Deserialize;
use serde_json::Value;
use serde::{Deserialize, Serialize};
use crate::adapter::persistence::Database;
use crate::domain::common::error::Error;
use crate::domain::common::error::system::SystemError;
use crate::domain::identity::auth::DEFAULT_ADMIN_USERNAME;
use crate::domain::identity::password;
use crate::infrastructure::http_server::SetupCompleteFlag;
use crate::infrastructure::secret_store::SecretStore;
use crate::interface::secret_store::SecretStorePort;
use crate::interface::system_state::SystemStateRepo;
use crate::adapter::http::helpers::{bad_request, conflict, internal_error};
use crate::common::error::Error;
use crate::common::error::system::SystemError;
use crate::common::utils::security::constant_time_eq;
use crate::core::common::setup_service::{CompleteSetupInput, SetupService, is_valid_interface_name};
use crate::infrastructure::http_runtime::SetupCompleteFlag;
pub const SETUP_TOKEN_HEADER: &str = "X-Setup-Token";
#[derive(Clone)]
pub struct SetupToken {
token: String,
}
impl SetupToken {
pub fn new(token: String) -> Self {
Self { token }
}
fn validate(&self, candidate: &str) -> bool {
constant_time_eq(&self.token, candidate)
}
}
pub fn initialize() -> Scope {
web::scope("/setup")
@ -25,26 +38,16 @@ pub fn initialize() -> Scope {
}
async fn setup_status(setup_flag: web::Data<SetupCompleteFlag>) -> HttpResponse {
let complete = setup_flag.0.load(Ordering::SeqCst);
let complete = setup_flag.is_complete();
HttpResponse::Ok().json(serde_json::json!({
"setup_complete": complete,
}))
}
async fn list_interfaces() -> HttpResponse {
// List available network interfaces
let interfaces: Vec<Value> = match fs::read_dir("/sys/class/net") {
Ok(entries) => entries
.filter_map(|e| e.ok())
.map(|e| {
let name = e.file_name().to_string_lossy().to_string();
serde_json::json!({
"name": name,
"is_loopback": name == "lo",
})
})
.collect(),
Err(_) => Vec::new(),
let interfaces = match list_network_interfaces(Path::new("/sys/class/net")) {
Ok(interfaces) => interfaces,
Err(e) => return internal_error(format!("Failed to list network interfaces: {}", e)),
};
HttpResponse::Ok().json(serde_json::json!({
@ -52,120 +55,78 @@ async fn list_interfaces() -> HttpResponse {
}))
}
#[derive(Serialize)]
struct NetworkInterface {
name: String,
is_loopback: bool,
}
fn list_network_interfaces(interface_dir: &Path) -> io::Result<Vec<NetworkInterface>> {
let mut interfaces = Vec::new();
for entry in fs::read_dir(interface_dir)? {
let entry = entry?;
let name = entry.file_name().to_string_lossy().to_string();
interfaces.push(NetworkInterface {
is_loopback: name == "lo",
name,
});
}
interfaces.sort_by(|left, right| left.name.cmp(&right.name));
Ok(interfaces)
}
#[derive(Deserialize)]
struct SetupRequest {
/// Ingress network interface (external-facing)
ingress_interface: String,
/// Egress network interface (internal-facing)
egress_interface: String,
/// Admin password
admin_password: String,
/// HTTP port (optional, default 8080)
http_port: Option<u16>,
/// SMTP config (optional)
smtp_host: Option<String>,
smtp_port: Option<u16>,
smtp_username: Option<String>,
smtp_password: Option<String>,
smtp_recipient: Option<String>,
/// Telegram config (optional)
telegram_bot_token: Option<String>,
telegram_chat_id: Option<String>,
}
/// Validate interface name: only alphanumeric, dots, underscores, hyphens allowed.
/// Prevents path traversal via crafted interface names like "../../etc/shadow".
fn is_valid_interface_name(name: &str) -> bool {
!name.is_empty()
&& name.len() <= 16
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-')
}
async fn complete_setup(
db: web::Data<Database>,
secret_store: web::Data<SecretStore>,
req: HttpRequest,
setup_service: web::Data<SetupService>,
setup_flag: web::Data<SetupCompleteFlag>,
setup_token: web::Data<SetupToken>,
body: web::Json<SetupRequest>,
) -> HttpResponse {
if setup_flag.0.load(Ordering::SeqCst) {
return HttpResponse::Conflict().json(serde_json::json!({
"error": "Setup already completed"
if setup_flag.is_complete() {
return conflict("Setup already completed");
}
let token = req
.headers()
.get(SETUP_TOKEN_HEADER)
.and_then(|value| value.to_str().ok())
.unwrap_or("");
if !setup_token.validate(token) {
return HttpResponse::Forbidden().json(serde_json::json!({
"error": "Invalid setup token",
}));
}
// Validate interface names (prevent path traversal)
for iface in [&body.ingress_interface, &body.egress_interface] {
if !is_valid_interface_name(iface) {
return HttpResponse::BadRequest().json(serde_json::json!({
"error": format!("Invalid interface name '{}': only alphanumeric, dots, underscores, hyphens allowed (max 16 chars)", iface)
}));
return bad_request(format!("Invalid network interface name '{}'", iface));
}
let iface_path = format!("/sys/class/net/{}", iface);
if !Path::new(&iface_path).exists() {
return HttpResponse::BadRequest().json(serde_json::json!({
"error": format!("Network interface '{}' not found", iface)
}));
if !Path::new("/sys/class/net").join(iface).exists() {
return bad_request(format!("Network interface '{}' not found", iface));
}
}
// Validate ingress != egress
if body.ingress_interface == body.egress_interface {
return HttpResponse::BadRequest().json(serde_json::json!({
"error": "Ingress and egress interfaces must be different"
}));
if let Err(e) = setup_service.complete_setup(body.into_inner().into()).await {
log!(SystemError::SetupCompleteFlagFailed(e.to_string()));
return setup_error_response(e);
}
// Validate password strength: min 8 chars, must contain letter + digit + symbol
let pw = &body.admin_password;
if pw.len() < 8 {
return HttpResponse::BadRequest().json(serde_json::json!({
"error": "Password must be at least 8 characters"
}));
}
let has_letter = pw.chars().any(|c| c.is_ascii_alphabetic());
let has_digit = pw.chars().any(|c| c.is_ascii_digit());
let has_symbol = pw.chars().any(|c| !c.is_ascii_alphanumeric());
if !has_letter || !has_digit || !has_symbol {
return HttpResponse::BadRequest().json(serde_json::json!({
"error": "Password must contain at least one letter, one digit, and one symbol"
}));
}
// Save configuration to database
if let Err(e) = save_config(&db, secret_store.as_ref(), &body).await {
return HttpResponse::InternalServerError().json(serde_json::json!({
"error": format!("Failed to save configuration: {}", e)
}));
}
// Update admin password
match password::hash_password(&body.admin_password) {
Ok(hash) => {
// Find admin user and update password
if let Ok(Some(user)) = db.find_user(DEFAULT_ADMIN_USERNAME).await
&& let Err(e) = db.update_user_password(user.id, &hash).await
{
log!(SystemError::SetupPasswordUpdateFailed(e));
}
}
Err(e) => {
return HttpResponse::InternalServerError().json(serde_json::json!({
"error": format!("Failed to hash password: {}", e)
}));
}
}
// Mark setup as complete
let state_repo = db.get_ref() as &dyn SystemStateRepo;
if let Err(e) = state_repo.set_system_state("setup_complete", "true").await {
log!(SystemError::SetupCompleteFlagFailed(e));
}
setup_flag.0.store(true, Ordering::SeqCst);
// System::run() polls the setup_complete flag and will automatically
// start eBPF, ML, and SOAR services once this flag becomes true.
setup_flag.mark_complete();
HttpResponse::Ok().json(serde_json::json!({
"success": true,
@ -174,50 +135,39 @@ async fn complete_setup(
}))
}
async fn save_config(db: &Database, secrets: &dyn SecretStorePort, req: &SetupRequest) -> Result<(), Error> {
// Save network config
db.set_config_value("ingress_interface", &req.ingress_interface).await?;
db.set_config_value("egress_interface", &req.egress_interface).await?;
impl From<SetupRequest> for CompleteSetupInput {
fn from(req: SetupRequest) -> Self {
Self {
ingress_interface: req.ingress_interface,
egress_interface: req.egress_interface,
admin_password: req.admin_password,
http_port: req.http_port,
smtp_host: req.smtp_host,
smtp_port: req.smtp_port,
smtp_username: req.smtp_username,
smtp_password: req.smtp_password,
smtp_recipient: req.smtp_recipient,
telegram_bot_token: req.telegram_bot_token,
telegram_chat_id: req.telegram_chat_id,
}
}
}
if let Some(port) = req.http_port {
db.set_config_value("http_port", &port.to_string()).await?;
fn setup_error_response(e: Error) -> HttpResponse {
match &e {
Error::System(SystemError::SetupAlreadyComplete) => conflict(e),
Error::System(SystemError::InvalidSetupInput { .. }) => bad_request(e),
_ => internal_error(format!("Failed to complete setup: {}", e)),
}
// Save SMTP config (non-secret fields go to settings)
if let Some(host) = &req.smtp_host {
db.set_config_value("smtp_host", host).await?;
}
if let Some(port) = req.smtp_port {
db.set_config_value("smtp_port", &port.to_string()).await?;
}
if let Some(user) = &req.smtp_username {
db.set_config_value("smtp_username", user).await?;
}
if let Some(pass) = &req.smtp_password {
// Store password through secret store (encrypted)
secrets.set_secret("smtp_password", pass).await?;
db.set_config_value("smtp_password", "__encrypted__").await?;
}
if let Some(recipient) = &req.smtp_recipient {
db.set_config_value("smtp_recipient", recipient).await?;
}
// Save Telegram config (bot_token through secret store, chat_id in JSON)
if let (Some(token), Some(chat_id)) = (&req.telegram_bot_token, &req.telegram_chat_id) {
secrets.set_secret("telegram_bot_token", token).await?;
let config_json = serde_json::json!({
"bot_token": "__encrypted__",
"chat_id": chat_id,
})
.to_string();
db.set_notification_config("telegram", &config_json).await?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::env;
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use super::*;
#[test]
@ -237,7 +187,6 @@ mod tests {
fn test_invalid_interface_too_long() {
let long = "a".repeat(17);
assert!(!is_valid_interface_name(&long));
// Exactly 16 should be valid
let exact = "a".repeat(16);
assert!(is_valid_interface_name(&exact));
}
@ -247,6 +196,8 @@ mod tests {
assert!(!is_valid_interface_name("../etc"));
assert!(!is_valid_interface_name("../../shadow"));
assert!(!is_valid_interface_name("/sys/class"));
assert!(!is_valid_interface_name("."));
assert!(!is_valid_interface_name(".."));
}
#[test]
@ -255,4 +206,45 @@ mod tests {
assert!(!is_valid_interface_name("lo&&cat"));
assert!(!is_valid_interface_name("eth0 space"));
}
#[test]
fn missing_interface_dir_is_reported() {
let path = temp_path("net-guardia-missing-ifaces");
assert!(list_network_interfaces(&path).is_err());
}
#[test]
fn setup_token_validates_exact_value_only() {
let token = SetupToken::new("secret-token".to_string());
assert!(token.validate("secret-token"));
assert!(!token.validate("secret-token-2"));
assert!(!token.validate("secret"));
}
#[test]
fn network_interfaces_are_sorted_and_mark_loopback() {
let dir = temp_path("net-guardia-ifaces");
fs::create_dir(&dir).unwrap();
fs::create_dir(dir.join("zeth0")).unwrap();
fs::create_dir(dir.join("lo")).unwrap();
let interfaces = list_network_interfaces(&dir).unwrap();
fs::remove_dir_all(&dir).unwrap();
assert_eq!(interfaces.len(), 2);
assert_eq!(interfaces[0].name, "lo");
assert!(interfaces[0].is_loopback);
assert_eq!(interfaces[1].name, "zeth0");
assert!(!interfaces[1].is_loopback);
}
fn temp_path(prefix: &str) -> PathBuf {
env::temp_dir().join(format!(
"{}-{}",
prefix,
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos()
))
}
}

View File

@ -1,16 +1,13 @@
use actix_web::{HttpResponse, Responder, Scope, web};
use serde::Deserialize;
use crate::adapter::http::helpers::{bad_request, conflict, forbidden, internal_error};
use crate::adapter::http::middleware::extractor::AuthClaims;
use crate::core::common::config_service::ConfigService;
use crate::core::common::enforce_mode_handler::EnforceModeHandler;
use crate::domain::common::config::constants::{
ENFORCE_MODE_ENFORCE, ENFORCE_MODE_ML_ONLY, ENFORCE_MODE_MONITOR, PERMISSION_SYSTEM_ADMIN,
};
use crate::infrastructure::logger::Logger;
use crate::infrastructure::runtime_state::RuntimeState;
use crate::infrastructure::system::{ShutdownHandle, ShutdownMode};
use crate::utils::boot_time;
use crate::domain::common::config::constants::PERMISSION_SYSTEM_ADMIN;
use crate::domain::common::config::system::EnforceMode;
use crate::interface::system::system_control::{BootTimeQuery, LogLevelControl, SystemCommandPort, XdpModeQuery};
#[derive(Deserialize)]
struct EnforceModeRequest {
@ -31,32 +28,30 @@ pub fn initialize() -> Scope {
.route("/restart", web::post().to(restart))
}
async fn get_boot_time() -> impl Responder {
HttpResponse::Ok().json(boot_time::boot_time())
async fn get_boot_time(boot_time: web::Data<dyn BootTimeQuery>) -> impl Responder {
HttpResponse::Ok().json(boot_time.boot_time_ns())
}
async fn get_enforce_mode(handler: web::Data<EnforceModeHandler>) -> impl Responder {
HttpResponse::Ok().json(serde_json::json!({"mode": handler.get_mode()}))
HttpResponse::Ok().json(serde_json::json!({"mode": handler.get_mode().to_string()}))
}
async fn set_enforce_mode(
body: web::Json<EnforceModeRequest>,
handler: web::Data<EnforceModeHandler>,
) -> impl Responder {
let mode = &body.mode;
if mode != ENFORCE_MODE_MONITOR && mode != ENFORCE_MODE_ML_ONLY && mode != ENFORCE_MODE_ENFORCE {
return HttpResponse::BadRequest()
.json(serde_json::json!({"error": "Mode must be 'monitor', 'ml_only', or 'enforce'"}));
}
let Ok(mode) = body.mode.parse::<EnforceMode>() else {
return bad_request("Mode must be 'monitor', 'ml_only', or 'enforce'");
};
match handler.change_mode(mode.clone()).await {
Ok(_) => HttpResponse::Ok().json(serde_json::json!({"mode": mode})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
match handler.change_mode(mode).await {
Ok(_) => HttpResponse::Ok().json(serde_json::json!({"mode": mode.to_string()})),
Err(e) => internal_error(e),
}
}
async fn get_xdp_mode(runtime_state: web::Data<arc_swap::ArcSwap<RuntimeState>>) -> impl Responder {
let xdp = runtime_state.load().xdp.clone();
async fn get_xdp_mode(runtime_state: web::Data<dyn XdpModeQuery>) -> impl Responder {
let xdp = runtime_state.get_xdp_modes();
HttpResponse::Ok().json(serde_json::json!({
"ingress_mode": xdp.ingress_mode,
@ -68,7 +63,7 @@ async fn get_config(svc: web::Data<ConfigService>) -> impl Responder {
HttpResponse::Ok().json(svc.get_config().await)
}
async fn get_log_level(logging: web::Data<Logger>) -> impl Responder {
async fn get_log_level(logging: web::Data<dyn LogLevelControl>) -> impl Responder {
HttpResponse::Ok().json(serde_json::json!({
"level": logging.current_level(),
}))
@ -79,30 +74,31 @@ struct LogLevelRequest {
level: String,
}
async fn set_log_level(body: web::Json<LogLevelRequest>, logging: web::Data<Logger>) -> impl Responder {
async fn set_log_level(body: web::Json<LogLevelRequest>, logging: web::Data<dyn LogLevelControl>) -> impl Responder {
match logging.set_level(&body.level) {
Ok(new_level) => HttpResponse::Ok().json(serde_json::json!({
"level": new_level,
"message": "Log level updated",
})),
Err(e) => HttpResponse::BadRequest().json(serde_json::json!({"error": e})),
Err(e) => bad_request(e),
}
}
/// HTTP config keys that require a server restart to take effect.
const HTTP_RELOAD_KEYS: &[&str] = &["http_port", "cors_allowed_origins", "force_https"];
fn updated_keys_need_http_restart(updated: &[String]) -> bool {
updated.iter().any(|key| HTTP_RELOAD_KEYS.contains(&key.as_str()))
}
async fn update_config(
body: web::Json<serde_json::Value>,
svc: web::Data<ConfigService>,
handle: web::Data<ShutdownHandle>,
handle: web::Data<dyn SystemCommandPort>,
) -> impl Responder {
match svc.update_config(&body).await {
Ok(updated) => {
let needs_restart = updated.iter().any(|k| HTTP_RELOAD_KEYS.contains(&k.as_str()));
if needs_restart {
// Auto-trigger restart for HTTP config changes
let triggered = handle.trigger(ShutdownMode::Restart);
if updated_keys_need_http_restart(&updated) {
let triggered = handle.trigger_restart();
HttpResponse::Ok().json(serde_json::json!({
"updated": updated,
"message": if triggered {
@ -119,28 +115,46 @@ async fn update_config(
}))
}
}
Err(e) => HttpResponse::BadRequest().json(serde_json::json!({"error": e.to_string()})),
Err(e) => bad_request(e),
}
}
async fn shutdown(auth: AuthClaims, handle: web::Data<ShutdownHandle>) -> impl Responder {
async fn shutdown(auth: AuthClaims, handle: web::Data<dyn SystemCommandPort>) -> impl Responder {
if !auth.permissions.iter().any(|p| p == PERMISSION_SYSTEM_ADMIN) {
return HttpResponse::Forbidden().json(serde_json::json!({"error": "Requires system:admin permission"}));
return forbidden("Requires system:admin permission");
}
if handle.trigger(ShutdownMode::Shutdown) {
if handle.trigger_shutdown() {
HttpResponse::Ok().json(serde_json::json!({"message": "Shutdown initiated"}))
} else {
HttpResponse::Conflict().json(serde_json::json!({"error": "Shutdown already in progress"}))
conflict("Shutdown already in progress")
}
}
async fn restart(auth: AuthClaims, handle: web::Data<ShutdownHandle>) -> impl Responder {
async fn restart(auth: AuthClaims, handle: web::Data<dyn SystemCommandPort>) -> impl Responder {
if !auth.permissions.iter().any(|p| p == PERMISSION_SYSTEM_ADMIN) {
return HttpResponse::Forbidden().json(serde_json::json!({"error": "Requires system:admin permission"}));
return forbidden("Requires system:admin permission");
}
if handle.trigger(ShutdownMode::Restart) {
if handle.trigger_restart() {
HttpResponse::Ok().json(serde_json::json!({"message": "Restart initiated"}))
} else {
HttpResponse::Conflict().json(serde_json::json!({"error": "Shutdown already in progress"}))
conflict("Shutdown already in progress")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn session_expiry_update_does_not_require_http_restart() {
assert!(!updated_keys_need_http_restart(&["session_expiry_hours".to_string()]));
}
#[test]
fn non_http_runtime_update_does_not_require_http_restart() {
assert!(!updated_keys_need_http_restart(&[
"smtp_host".to_string(),
"beaconing_cv_threshold".to_string()
]));
}
}

View File

@ -0,0 +1,59 @@
use std::fmt::Write;
use hmac::{Hmac, KeyInit, Mac};
use sha2::Sha256;
use crate::interface::identity::api_key_hasher::ApiKeyHasher;
type HmacSha256 = Hmac<Sha256>;
pub struct HmacApiKeyHasher {
key: [u8; 32],
}
impl HmacApiKeyHasher {
pub fn new(key: [u8; 32]) -> Self {
Self { key }
}
}
impl ApiKeyHasher for HmacApiKeyHasher {
fn hash_api_key(&self, raw_key: &str) -> String {
let mut mac = match HmacSha256::new_from_slice(&self.key) {
Ok(mac) => mac,
// SAFETY: HMAC accepts keys of any length; `key` is a fixed 32-byte array.
Err(_) => unreachable!("HMAC-SHA256 accepts fixed 32-byte keys"),
};
mac.update(raw_key.as_bytes());
let result = mac.finalize().into_bytes();
let mut hex = String::with_capacity(64);
for byte in result {
// SAFETY: write! on a String is infallible.
let _ = write!(&mut hex, "{:02x}", byte);
}
hex
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deterministic_hash() {
let hasher = HmacApiKeyHasher::new([0xAB; 32]);
let h1 = hasher.hash_api_key("test-key");
let h2 = hasher.hash_api_key("test-key");
assert_eq!(h1, h2);
assert_eq!(h1.len(), 64);
}
#[test]
fn different_keys_produce_different_hashes() {
let hasher = HmacApiKeyHasher::new([0xAB; 32]);
let h1 = hasher.hash_api_key("key-a");
let h2 = hasher.hash_api_key("key-b");
assert_ne!(h1, h2);
}
}

View File

@ -0,0 +1,2 @@
pub mod api_key_hasher;
pub mod password_hasher;

View File

@ -0,0 +1,58 @@
use argon2::password_hash::SaltString;
use argon2::password_hash::rand_core::OsRng;
use argon2::{Argon2, PasswordHash, PasswordHasher as Argon2PasswordHasherTrait, PasswordVerifier};
use crate::common::error::Error;
use crate::domain::identity::error::AuthError;
use crate::interface::identity::password_hasher::PasswordHasher;
pub struct Argon2PasswordHasher;
impl PasswordHasher for Argon2PasswordHasher {
fn hash_password(&self, password: &str) -> Result<String, Error> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
let hash = argon2
.hash_password(password.as_bytes(), &salt)
.map_err(|_| AuthError::InvalidCredentials)?;
Ok(hash.to_string())
}
fn verify_password(&self, password: &str, hash: &str) -> Result<bool, Error> {
let parsed = PasswordHash::new(hash).map_err(|_| AuthError::InvalidCredentials)?;
Ok(Argon2::default().verify_password(password.as_bytes(), &parsed).is_ok())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hashes_and_verifies_passwords() {
let hasher = Argon2PasswordHasher;
let hash = hasher.hash_password("mypassword123").unwrap();
assert!(hasher.verify_password("mypassword123", &hash).unwrap());
assert!(!hasher.verify_password("wrongpassword", &hash).unwrap());
}
#[test]
fn uses_different_salts_for_same_password() {
let hasher = Argon2PasswordHasher;
let hash1 = hasher.hash_password("same").unwrap();
let hash2 = hasher.hash_password("same").unwrap();
assert_ne!(hash1, hash2);
assert!(hasher.verify_password("same", &hash1).unwrap());
assert!(hasher.verify_password("same", &hash2).unwrap());
}
#[test]
fn invalid_hash_is_an_error() {
let hasher = Argon2PasswordHasher;
let result = hasher.verify_password("password", "not-a-valid-hash");
assert!(result.is_err());
}
}

View File

@ -1,8 +1,17 @@
pub mod access_control;
pub mod ebpf;
pub mod flow_trace_store;
pub mod geoip;
pub mod html_report_writer;
pub mod http;
pub mod identity;
pub mod model_change_source;
pub mod model_loading;
pub mod model_promotion_store;
pub mod notification;
pub mod persistence;
pub mod secret_store;
pub mod suricata_monitor;
pub mod telegram;
pub mod webhook_sender;
pub mod websocket;

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