feat: traffic intel hot path, SYN RST-challenge, XDP environment diagnostics

Traffic Intel (was dead code, now wired):
- TrafficHook in listener accept path: cps/pps windows -> AttackDetector
- auto-ban IPs below reputation threshold under attack ([detect.autoban])
- AlertDispatcher: webhook on attack state transition only (dedup), metrics
  AUTO_BANS_TOTAL / INTEL_* ; [detect.alert].webhook_url

XDP SYN RST-challenge (Oubliette pattern, off by default):
- G_SYN_CHALLENGE_ENABLED=0: kernel replies bad-ACK SYN-ACK via XDP_TX,
  spoofed sources stay silent, live clients answer RST with secret echo ->
  challenge_verified (LRU, sliding TTL); brute-force of marker impossible
- maps challenge_verified/challenge_pending, STAT_CHALLENGE_*, all logic
  in xdp/core/syn_challenge.h (221 lines)

XDP diagnostics (src/xdp/diagnostics.rs):
- EnvironmentReport: kernel version/BTF/driver->AttachMode verdict,
  fail-fast before load on unsupported kernels; wired into  CLI
- SystemProbe trait for kernel-less testing

fix: .gitignore 'bin/' matched src/bin/ — rampart.rs was never committed

cargo build/clippy(-D warnings, --features xdp)/test green: 107 tests
This commit is contained in:
loki5512344 2026-08-24 10:11:10 +02:00
parent 40bfe956e2
commit aa787a558c
Signed by: boba
GPG key ID: 253067914055423B
25 changed files with 1825 additions and 28 deletions

View file

@ -61,3 +61,31 @@ fn rejects_invalid_whitelist_ip() {
let result = Config::parse_str("whitelist = [\"10.0.0.999\"]");
assert!(result.is_err());
}
#[test]
fn detect_autoban_defaults_disabled() {
let config = Config::parse_str("").expect("empty config must parse");
assert!(!config.detect.autoban.enabled);
assert_eq!(config.detect.autoban.reputation_threshold, -50);
}
#[test]
fn parses_detect_sections() {
let config = Config::parse_str(
r#"
[detect.autoban]
enabled = true
reputation_threshold = -30
[detect.alert]
webhook_url = "https://hooks.example.test/rampart"
"#,
)
.expect("detect sections must parse");
assert!(config.detect.autoban.enabled);
assert_eq!(config.detect.autoban.reputation_threshold, -30);
assert_eq!(
config.detect.alert.webhook_url.as_deref(),
Some("https://hooks.example.test/rampart")
);
}

112
tests/traffic_intel.rs Normal file
View file

@ -0,0 +1,112 @@
//! Интеграционные тесты Traffic Intelligence в hot path: авто-бан по
//! репутации, детектор атак на синтетическом всплеске, алерты на переходах.
use rampart::config::DetectAutobanConfig;
use rampart::filter::blacklist::Blacklist;
use rampart::traffic::alert::AlertDispatcher;
use rampart::traffic::detector::{AttackDetector, AttackStatus};
use rampart::traffic::hook::TrafficHook;
use rampart::traffic::reputation::IpReputation;
use std::net::{IpAddr, Ipv4Addr};
use std::sync::Arc;
use std::time::Duration;
fn ip(octets: [u8; 4]) -> IpAddr {
IpAddr::V4(Ipv4Addr::from(octets))
}
fn autoban_cfg(enabled: bool) -> DetectAutobanConfig {
DetectAutobanConfig {
enabled,
reputation_threshold: -50,
}
}
#[test]
fn reputation_fall_after_drops_triggers_autoban() {
let reputation = Arc::new(IpReputation::new());
let blacklist = Arc::new(Blacklist::new());
let hook = TrafficHook::new(Arc::clone(&reputation), Arc::clone(&blacklist), autoban_cfg(true), 60);
let attacker = ip([198, 51, 100, 7]);
for _ in 0..6 {
// Шесть дропов rate-limit'ом: -10 за каждый, порог auto-ban = -50.
reputation.record_bad(attacker);
hook.on_connection_end(attacker);
}
assert!(
reputation.score(attacker) <= -50,
"reputation must fall below threshold"
);
assert!(
blacklist.is_blocked(attacker),
"auto-ban must fire after repeated drops"
);
}
#[test]
fn autoban_ttl_expires_ban() {
let reputation = Arc::new(IpReputation::new());
let blacklist = Arc::new(Blacklist::new());
let hook = TrafficHook::new(
Arc::clone(&reputation),
Arc::clone(&blacklist),
autoban_cfg(true),
// Короткий TTL из «конфига» как мок времени.
0,
);
let attacker = ip([198, 51, 100, 8]);
for _ in 0..20 {
reputation.record_bad(attacker);
}
hook.on_connection_end(attacker);
std::thread::sleep(Duration::from_millis(5));
assert!(
!blacklist.is_blocked(attacker),
"zero TTL must not produce an active ban"
);
}
#[test]
fn detector_flags_synthetic_traffic_spike() {
let mut detector = AttackDetector::new();
// Базовая линия: тихий профиль.
for _ in 0..12 {
detector.analyze(500.0, 50.0);
}
// Синтетический всплеск: x200 pps и cps три окна подряд.
assert_eq!(
detector.analyze(100_000.0, 10_000.0),
AttackStatus::Suspicious,
"first anomalous window is suspicious"
);
assert_eq!(detector.analyze(100_000.0, 10_000.0), AttackStatus::Suspicious);
assert_eq!(
detector.analyze(100_000.0, 10_000.0),
AttackStatus::UnderAttack,
"three anomalous windows in a row must escalate to UnderAttack"
);
}
#[test]
fn alerts_fire_only_on_state_transitions() {
let dispatcher = AlertDispatcher::new();
let sequence = [
AttackStatus::Normal,
AttackStatus::Normal,
AttackStatus::Suspicious,
AttackStatus::UnderAttack,
AttackStatus::UnderAttack,
AttackStatus::UnderAttack,
AttackStatus::Suspicious,
AttackStatus::Normal,
AttackStatus::Normal,
];
let alerts: Vec<_> = sequence.iter().filter_map(|s| dispatcher.on_status(*s)).collect();
assert_eq!(alerts.len(), 2, "two transitions must yield exactly two alerts");
assert!(alerts[0].message.contains("attack started"));
assert!(alerts[1].message.contains("attack ended"));
}

134
tests/xdp_diagnostics.rs Normal file
View file

@ -0,0 +1,134 @@
use rampart::xdp::{AttachMode, EnvironmentReport, KernelVersion, MIN_KERNEL, SystemProbe, driver_supports_native_xdp};
use std::collections::HashMap;
struct MockProbe {
files: HashMap<String, String>,
symlinks: HashMap<String, String>,
uid: u32,
}
impl MockProbe {
fn linux(driver: &str, release: &str) -> Self {
let mut files = HashMap::new();
files.insert("/proc/sys/kernel/osrelease".to_owned(), format!("{release}\n"));
files.insert("/sys/kernel/btf/vmlinux".to_owned(), String::new());
files.insert("/proc/self/status".to_owned(), "Uid:\t0\t0\t0\t0\n".to_owned());
let mut symlinks = HashMap::new();
if !driver.is_empty() {
symlinks.insert(
"/sys/class/net/eth0/device/driver".to_owned(),
format!("/sys/bus/pci/drivers/{driver}"),
);
}
Self {
files,
symlinks,
uid: 0,
}
}
fn collect(&self) -> EnvironmentReport {
EnvironmentReport::collect(self, "eth0")
}
}
impl SystemProbe for MockProbe {
fn read_file(&self, path: &str) -> std::io::Result<String> {
self.files
.get(path)
.cloned()
.ok_or_else(|| std::io::Error::other("file not found"))
}
fn path_exists(&self, path: &str) -> bool {
self.files.contains_key(path) || self.symlinks.contains_key(path)
}
fn symlink_target(&self, path: &str) -> Option<String> {
self.symlinks.get(path).cloned()
}
fn effective_uid(&self) -> u32 {
self.uid
}
}
#[test]
fn parses_kernel_release_with_distro_suffixes() {
assert_eq!(
KernelVersion::parse("6.8.0-45-generic"),
Some(KernelVersion::new(6, 8, 0))
);
assert_eq!(KernelVersion::parse("5.15.0"), Some(KernelVersion::new(5, 15, 0)));
assert_eq!(KernelVersion::parse("6.12"), Some(KernelVersion::new(6, 12, 0)));
assert_eq!(KernelVersion::parse(""), None);
assert_eq!(KernelVersion::parse("generic"), None);
}
#[test]
fn selects_attach_mode_by_driver_table() {
let virtio = MockProbe::linux("virtio_net", "6.8.0-45-generic").collect();
assert_eq!(virtio.attach_mode(), AttachMode::Native);
let realtek = MockProbe::linux("r8169", "6.8.0-45-generic").collect();
assert_eq!(realtek.attach_mode(), AttachMode::Generic);
}
#[test]
fn unknown_driver_falls_back_to_generic() {
assert!(!driver_supports_native_xdp("r8169"));
assert!(driver_supports_native_xdp("mlx5_core"));
let no_driver = MockProbe::linux("", "6.1.0").collect();
assert_eq!(no_driver.driver_name, None);
assert_eq!(no_driver.attach_mode(), rampart::xdp::AttachMode::Generic);
}
#[test]
fn verdict_explains_generic_cpu_cost() {
let report = MockProbe::linux("r8169", "6.8.0-45-generic").collect();
let verdict = report.verdict();
assert!(verdict.contains("r8169"), "verdict must name the driver: {verdict}");
assert!(
verdict.contains("generic mode"),
"verdict must mention generic: {verdict}"
);
assert!(verdict.contains("CPU"), "verdict must explain CPU cost: {verdict}");
let native = MockProbe::linux("ixgbe", "6.1.0").collect();
assert!(native.verdict().contains("native XDP"));
}
#[test]
fn fails_fast_on_kernel_below_minimum() {
let old = MockProbe::linux("virtio_net", "5.10.0-rc6");
let err = old.collect().validate().expect_err("kernel 5.10 must be rejected");
assert!(err.to_string().contains("5.10"));
let mut unknown = MockProbe::linux("virtio_net", "6.8.0");
unknown.files.remove("/proc/sys/kernel/osrelease");
assert!(unknown.collect().validate().is_err(), "unknown kernel must be rejected");
}
#[test]
fn accepts_supported_kernels() {
assert!(MIN_KERNEL == KernelVersion::new(5, 15, 0));
for release in ["5.15.0", "6.1.0-13-amd64", "6.12.8"] {
let report = MockProbe::linux("virtio_net", release).collect();
report.validate().expect("supported kernel must pass validation");
}
}
#[test]
fn warns_when_not_privileged_and_btf_missing() {
let mut probe = MockProbe::linux("virtio_net", "6.8.0");
probe.uid = 1000;
probe.files.remove("/sys/kernel/btf/vmlinux");
let report = probe.collect();
assert!(!report.privileged);
assert!(!report.btf_available);
let joined = report.warnings.join("\n");
assert!(joined.contains("CAP_BPF"), "must warn about capabilities: {joined}");
assert!(joined.contains("CO-RE"), "must warn about missing BTF: {joined}");
}