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