feat: subnet-level attack detection (prefix_stats)

- XDP: prefix_stats LRU map, per-/24 (v4) and /64 (v6) SYN/packet counters,
  incremented post-blacklist/throttle (xdp/core/prefix_stats.h)
- userspace: SubnetDetector escalation ladder Monitor->StrictLimit->Challenge->Block
  with spoof-gate (Block requires >= min_unique_sources, CGNAT-safe)
- config: [detect.prefix] section (enabled=false by default)
- fallback without XDP: engine SubnetTracker aggregates connections per-prefix
- fix: PrefixStatsVal::from_bytes for xdp feature build; prefix_len 24 vs 64

cargo build/clippy(-D warnings, all features)/test green: 83 tests
This commit is contained in:
loki5512344 2026-08-24 09:47:21 +02:00
parent 15f474486a
commit 40bfe956e2
Signed by: boba
GPG key ID: 253067914055423B
14 changed files with 1044 additions and 11 deletions

View file

@ -1,9 +1,11 @@
use anyhow::{Context, Result, bail};
use libbpf_rs::{MapCore, MapFlags, Object, ObjectBuilder, RingBuffer, RingBufferBuilder, Xdp, XdpFlags};
use std::net::IpAddr;
use std::net::Ipv4Addr;
use std::os::unix::io::AsFd;
use super::XdpStats;
use crate::traffic::prefix::{PrefixKey, PrefixStatsVal};
pub struct XdpFilter {
obj: Option<Object>,
@ -78,17 +80,28 @@ impl XdpFilter {
}
pub fn ban_ip(&self, ip: Ipv4Addr, duration_secs: u64) -> Result<()> {
self.blacklist_update(32, &ip.octets(), duration_secs)
}
/// Бан CIDR через `blacklist_map` (LPM trie): длина префикса в key[0].
///
/// # Errors
/// XDP не загружен, IPv6-префиксы (ядро их пока не банит) или ошибка карты.
pub fn ban_cidr(&self, prefix: IpAddr, prefix_len: u8, duration_secs: u64) -> Result<()> {
let IpAddr::V4(net) = prefix else {
bail!("ipv6 cidr bans not supported yet");
};
self.blacklist_update(prefix_len, &net.octets(), duration_secs)
}
fn blacklist_update(&self, prefix_len: u8, v4: &[u8; 4], duration_secs: u64) -> Result<()> {
let map = self.find_map("blacklist_map")?;
let mut key = [0u8; 8];
key[0] = 32;
key[4..8].copy_from_slice(&ip.octets());
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos() as u64;
key[0] = prefix_len;
key[4..8].copy_from_slice(v4);
map.update(
&key,
&(now + duration_secs * 1_000_000_000).to_le_bytes(),
&(unix_ns() + duration_secs * 1_000_000_000).to_le_bytes(),
MapFlags::ANY,
)?;
Ok(())
@ -103,6 +116,28 @@ impl XdpFilter {
Ok(())
}
/// Читает карту `prefix_stats` (агрегаты по префиксам /24 и /64).
///
/// # Errors
/// XDP не загружен, карта отсутствует в объекте или ошибка BPF-чтения.
pub fn read_prefix_stats(&self) -> Result<Vec<(PrefixKey, PrefixStatsVal)>> {
let map = self.find_map("prefix_stats")?;
let mut out = Vec::new();
for key in map.keys() {
let Ok(kb) = <[u8; 24]>::try_from(key.as_slice()) else {
continue;
};
let Some(val) = map.lookup(&kb, MapFlags::ANY).context("prefix_stats lookup failed")? else {
continue;
};
let Ok(vb) = <[u8; 24]>::try_from(val.as_slice()) else {
continue;
};
out.push((PrefixKey::from_bytes(kb), PrefixStatsVal::from_bytes(vb)));
}
Ok(out)
}
pub fn get_stats(&self) -> Result<XdpStats> {
let map = self.find_map("stats_map")?;
let sum = |idx: u32| -> u64 {
@ -137,6 +172,13 @@ impl Drop for XdpFilter {
}
}
fn unix_ns() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos() as u64
}
fn build_ringbuf(obj: &Object) -> Result<RingBuffer<'static>> {
let map = obj
.maps()

View file

@ -1,5 +1,7 @@
use anyhow::Result;
use std::net::Ipv4Addr;
use anyhow::{Ok, Result};
use std::net::{IpAddr, Ipv4Addr};
use crate::traffic::prefix::{PrefixKey, PrefixStatsVal};
pub struct XdpFilter;
@ -17,9 +19,21 @@ impl XdpFilter {
pub fn ban_ip(&self, _ip: Ipv4Addr, _duration_secs: u64) -> Result<()> {
Ok(())
}
pub fn ban_cidr(&self, _prefix: IpAddr, _prefix_len: u8, _duration_secs: u64) -> Result<()> {
Ok(())
}
pub fn unban_ip(&self, _ip: Ipv4Addr) -> Result<()> {
Ok(())
}
/// Честная ошибка: чтение `prefix_stats` не подключено без feature `xdp`.
///
/// # Errors
/// Всегда — сборка без XDP не имеет доступа к картам ядра.
pub fn read_prefix_stats(&self) -> Result<Vec<(PrefixKey, PrefixStatsVal)>> {
anyhow::bail!("prefix_stats reading not wired yet: built without xdp feature")
}
pub fn get_stats(&self) -> Result<super::XdpStats> {
Ok(super::XdpStats::default())
}