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

215
tests/subnet_detection.rs Normal file
View file

@ -0,0 +1,215 @@
//! Интеграционные тесты subnet-level детекции: трекер → детектор → вердикты.
use rampart::config::Config;
use rampart::engine::subnet_tracker::SubnetTracker;
use rampart::traffic::prefix::{
PrefixKey, PrefixSnapshot, PrefixStatsSource, PrefixStatsVal, SubnetDetector, SubnetVerdict, prefix_of,
};
use std::collections::HashMap;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
fn v4(octets: [u8; 4]) -> IpAddr {
IpAddr::V4(Ipv4Addr::from(octets))
}
/// Фиксированный источник снапшотов — имитирует XDP-карту без ядра.
struct FixedSource(Vec<(IpAddr, PrefixSnapshot)>);
impl PrefixStatsSource for FixedSource {
async fn snapshot(&self) -> anyhow::Result<Vec<(IpAddr, PrefixSnapshot)>> {
Ok(self.0.clone())
}
}
#[tokio::test]
async fn tracker_feeds_detector_and_blocks_distributed_attack() {
let tracker = SubnetTracker::new();
// 40 уникальных источников из одного /24 — распределённая атака
// (6 SYN от каждого: 240 >= 4 × threshold(50)).
for _ in 0..6 {
for last in 1..=40u8 {
tracker.record_connection(v4([203, 0, 113, last]));
}
}
let snaps = tracker.take_snapshot();
let detector = SubnetDetector::new(50, 16);
let verdicts = detector.evaluate(&snaps);
assert_eq!(verdicts.len(), 1);
assert_eq!(verdicts[0].prefix, v4([203, 0, 113, 0]));
assert_eq!(verdicts[0].verdict, SubnetVerdict::Block);
assert_eq!(verdicts[0].snapshot.syn_count, 240);
assert_eq!(verdicts[0].snapshot.unique_sources, 40);
// Полный конвейер через трейт-источник (как в бинарнике).
let source_snaps = FixedSource(snaps).snapshot().await.expect("source snapshot");
assert_eq!(detector.evaluate(&source_snaps)[0].verdict, SubnetVerdict::Block);
}
#[tokio::test]
async fn spoof_gate_many_syn_few_sources_no_block() {
let tracker = SubnetTracker::new();
// 500 SYN всего из 3 адресов — всплеск, но не распределённый.
for _ in 0..167 {
for ip in [v4([198, 51, 100, 1]), v4([198, 51, 100, 2]), v4([198, 51, 100, 3])] {
tracker.record_connection(ip);
}
}
let snaps = tracker.take_snapshot();
let detector = SubnetDetector::new(50, 16);
let verdicts = detector.evaluate(&snaps);
assert_eq!(verdicts.len(), 1);
assert_eq!(verdicts[0].snapshot.syn_count, 501);
assert_eq!(verdicts[0].snapshot.unique_sources, 3);
assert_ne!(verdicts[0].verdict, SubnetVerdict::Block);
assert_eq!(verdicts[0].verdict, SubnetVerdict::Challenge);
}
#[test]
fn ipv6_prefixes_aggregated_separately_from_ipv4() {
let tracker = SubnetTracker::new();
let v6a = IpAddr::V6("2001:db8:1:2::1".parse::<Ipv6Addr>().expect("valid"));
let v6b = IpAddr::V6("2001:db8:1:2::ffff".parse::<Ipv6Addr>().expect("valid"));
tracker.record_connection(v6a);
tracker.record_connection(v6b);
tracker.record_connection(v4([10, 0, 0, 1]));
let snaps: HashMap<IpAddr, PrefixSnapshot> = tracker.take_snapshot().into_iter().collect();
assert_eq!(snaps.len(), 2);
assert_eq!(
snaps[&IpAddr::V6("2001:db8:1:2::".parse::<Ipv6Addr>().expect("valid"))].unique_sources,
2
);
assert_eq!(snaps[&v4([10, 0, 0, 0])].unique_sources, 1);
}
#[test]
fn mapped_v6_joins_ipv4_prefix_bucket() {
// ::ffff:192.0.2.9
assert_eq!(
prefix_of(IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0xffff, 0xc000, 0x0209))),
v4([192, 0, 2, 0])
);
}
#[test]
fn detect_prefix_config_section_parses_end_to_end() {
let config = Config::parse_str(
r#"
[detect.prefix]
enabled = true
syn_threshold = 250
window_secs = 30
min_unique_sources = 4
"#,
)
.expect("[detect.prefix] must parse");
assert!(config.detect.prefix.enabled);
let detector = SubnetDetector::from_config(&config.detect.prefix);
let snaps = vec![(
v4([203, 0, 113, 0]),
PrefixSnapshot {
syn_count: 1200,
unique_sources: 5,
..PrefixSnapshot::default()
},
)];
let verdicts = detector.evaluate(&snaps);
assert_eq!(verdicts[0].verdict, SubnetVerdict::Block);
}
fn verdict_for(detector: &SubnetDetector, snap: &PrefixSnapshot) -> SubnetVerdict {
let verdicts = detector.evaluate(&[(v4([10, 9, 9, 0]), *snap)]);
verdicts[0].verdict
}
#[test]
fn ipv4_prefix_masked_to_24() {
assert_eq!(prefix_of(v4([203, 0, 113, 77])), v4([203, 0, 113, 0]));
assert_eq!(prefix_of(v4([198, 51, 100, 1])), v4([198, 51, 100, 0]));
}
#[test]
fn ipv6_prefix_masked_to_64() {
let ip = IpAddr::V6("2001:db8:1:2:3:4:5:6".parse::<Ipv6Addr>().expect("valid"));
assert_eq!(
prefix_of(ip),
IpAddr::V6("2001:db8:1:2::".parse::<Ipv6Addr>().expect("valid"))
);
}
#[test]
fn mapped_ipv6_treated_as_ipv4() {
// ::ffff:203.0.113.77
let mapped = IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0xffff, 0xcb00, 0x714d));
assert_eq!(prefix_of(mapped), v4([203, 0, 113, 0]));
}
#[test]
fn prefix_key_roundtrip_and_layout() {
assert_eq!(size_of::<PrefixKey>(), 24);
assert_eq!(size_of::<PrefixStatsVal>(), 24);
let key = PrefixKey::new(v4([10, 1, 2, 3]));
assert_eq!(key.family, rampart::traffic::prefix::PREFIX_FAMILY_V4);
assert_eq!(key.to_prefix(), Some(v4([10, 1, 2, 0])));
assert_eq!(PrefixKey::from_bytes(key.to_bytes()), key);
let key6 = PrefixKey::new(IpAddr::V6("2001:db8::1".parse::<Ipv6Addr>().expect("valid")));
assert_eq!(key6.family, rampart::traffic::prefix::PREFIX_FAMILY_V6);
assert_eq!(
key6.to_prefix(),
Some(IpAddr::V6("2001:db8::".parse::<Ipv6Addr>().expect("valid")))
);
}
#[test]
fn escalation_ladder_monitor_to_block() {
let detector = SubnetDetector::new(100, 16);
let snap = |syn: u64| PrefixSnapshot {
syn_count: syn,
unique_sources: 32,
..PrefixSnapshot::default()
};
assert_eq!(verdict_for(&detector, &snap(99)), SubnetVerdict::Monitor);
assert_eq!(verdict_for(&detector, &snap(100)), SubnetVerdict::StrictLimit);
assert_eq!(verdict_for(&detector, &snap(150)), SubnetVerdict::StrictLimit);
assert_eq!(verdict_for(&detector, &snap(200)), SubnetVerdict::Challenge);
assert_eq!(verdict_for(&detector, &snap(400)), SubnetVerdict::Block);
}
#[test]
fn spoof_gate_downgrades_block_to_challenge() {
let detector = SubnetDetector::new(100, 16);
// Много SYN из одного источника (или неизвестное разнообразие) → нет Block.
let few = PrefixSnapshot {
syn_count: 1000,
unique_sources: 3,
..PrefixSnapshot::default()
};
let unknown = PrefixSnapshot {
syn_count: 1000,
..PrefixSnapshot::default()
};
assert_eq!(verdict_for(&detector, &few), SubnetVerdict::Challenge);
assert_eq!(verdict_for(&detector, &unknown), SubnetVerdict::Challenge);
}
#[test]
fn evaluate_skips_empty_prefixes() {
let detector = SubnetDetector::new(100, 0);
let snaps = vec![
(v4([10, 0, 0, 0]), PrefixSnapshot::default()),
(
v4([10, 0, 1, 0]),
PrefixSnapshot {
syn_count: 500,
unique_sources: 40,
..PrefixSnapshot::default()
},
),
];
let verdicts = detector.evaluate(&snaps);
assert_eq!(verdicts.len(), 1);
assert_eq!(verdicts[0].prefix, v4([10, 0, 1, 0]));
assert_eq!(verdicts[0].verdict, SubnetVerdict::Block);
}