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:
parent
40bfe956e2
commit
aa787a558c
25 changed files with 1825 additions and 28 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -41,7 +41,7 @@ Thumbs.db
|
||||||
.project
|
.project
|
||||||
.settings/
|
.settings/
|
||||||
.factorypath
|
.factorypath
|
||||||
bin/
|
/bin/
|
||||||
|
|
||||||
# Reference projects (cloned for research)
|
# Reference projects (cloned for research)
|
||||||
ref/
|
ref/
|
||||||
|
|
|
||||||
72
src/bin/rampart-cli.rs
Normal file
72
src/bin/rampart-cli.rs
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
#![allow(clippy::print_stdout, clippy::print_stderr)]
|
||||||
|
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
use rampart::cli::commands;
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(name = "rampart-cli", about = "Rampart management CLI")]
|
||||||
|
struct Cli {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Commands,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum Commands {
|
||||||
|
/// Show overall system status
|
||||||
|
Status,
|
||||||
|
/// Run full diagnostics
|
||||||
|
Doctor,
|
||||||
|
/// Get/set configuration
|
||||||
|
Config {
|
||||||
|
#[arg(required = false)]
|
||||||
|
key: Option<String>,
|
||||||
|
#[arg(required = false)]
|
||||||
|
value: Option<String>,
|
||||||
|
},
|
||||||
|
/// Manage blacklist
|
||||||
|
Blacklist {
|
||||||
|
#[command(subcommand)]
|
||||||
|
action: BlacklistAction,
|
||||||
|
},
|
||||||
|
/// Emergency mode
|
||||||
|
Emergency {
|
||||||
|
#[arg(value_enum)]
|
||||||
|
mode: EmergencyMode,
|
||||||
|
},
|
||||||
|
/// Gracefully drain a node
|
||||||
|
Drain { node: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum BlacklistAction {
|
||||||
|
Add { target: String, reason: Option<String> },
|
||||||
|
Remove { target: String },
|
||||||
|
List,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(clap::ValueEnum, Clone)]
|
||||||
|
enum EmergencyMode {
|
||||||
|
Enable,
|
||||||
|
Disable,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
match cli.command {
|
||||||
|
Commands::Status => commands::status::run().await,
|
||||||
|
Commands::Doctor => commands::doctor::run().await,
|
||||||
|
Commands::Config { key, value } => commands::config::run(key, value).await,
|
||||||
|
Commands::Blacklist { action } => match action {
|
||||||
|
BlacklistAction::Add { target, reason } => commands::blacklist::add(target, reason).await,
|
||||||
|
BlacklistAction::Remove { target } => commands::blacklist::remove(target).await,
|
||||||
|
BlacklistAction::List => commands::blacklist::list().await,
|
||||||
|
},
|
||||||
|
Commands::Emergency { mode } => match mode {
|
||||||
|
EmergencyMode::Enable => commands::emergency::enable().await,
|
||||||
|
EmergencyMode::Disable => commands::emergency::disable().await,
|
||||||
|
},
|
||||||
|
Commands::Drain { node } => commands::drain::run(&node).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
79
src/bin/rampart-manager.rs
Normal file
79
src/bin/rampart-manager.rs
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
use axum::{
|
||||||
|
Router,
|
||||||
|
http::HeaderValue,
|
||||||
|
middleware,
|
||||||
|
routing::{get, post},
|
||||||
|
};
|
||||||
|
use dashmap::DashMap;
|
||||||
|
use rampart::manager::{AppState, api, auth, sync};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tower_http::cors::CorsLayer;
|
||||||
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(EnvFilter::from_default_env().add_directive("rampart=info".parse()?))
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let redis_url = std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379/0".to_string());
|
||||||
|
let redis_client = redis::Client::open(redis_url)?;
|
||||||
|
|
||||||
|
let jwt_secret = std::env::var("JWT_SECRET").map_err(|_| anyhow::anyhow!("JWT_SECRET must be set"))?;
|
||||||
|
if jwt_secret.len() < 32 {
|
||||||
|
return Err(anyhow::anyhow!("JWT_SECRET must be at least 32 bytes"));
|
||||||
|
}
|
||||||
|
let jwt_audience = std::env::var("JWT_AUDIENCE").unwrap_or_else(|_| "rampart".to_string());
|
||||||
|
let jwt_expiration = std::env::var("JWT_EXPIRATION_SECS")
|
||||||
|
.unwrap_or_else(|_| "86400".to_string())
|
||||||
|
.parse::<u64>()
|
||||||
|
.map_err(|_| anyhow::anyhow!("JWT_EXPIRATION_SECS must be a valid u64"))?;
|
||||||
|
|
||||||
|
let api_password = std::env::var("API_PASSWORD").map_err(|_| anyhow::anyhow!("API_PASSWORD must be set"))?;
|
||||||
|
if api_password == "changeme" {
|
||||||
|
return Err(anyhow::anyhow!("API_PASSWORD must not be the default 'changeme'"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = Arc::new(AppState {
|
||||||
|
redis_client,
|
||||||
|
jwt_secret,
|
||||||
|
jwt_audience,
|
||||||
|
jwt_expiration,
|
||||||
|
api_password,
|
||||||
|
login_limiter: DashMap::new(),
|
||||||
|
});
|
||||||
|
|
||||||
|
tokio::spawn(sync::heartbeat::start_heartbeat_check(state.clone()));
|
||||||
|
|
||||||
|
let public = Router::new()
|
||||||
|
.route("/api/v1/health", get(api::health::health_check))
|
||||||
|
.route("/api/v1/auth/login", post(api::auth::login));
|
||||||
|
|
||||||
|
let protected = Router::new()
|
||||||
|
.route("/api/v1/servers", get(api::servers::list_servers))
|
||||||
|
.route(
|
||||||
|
"/api/v1/blacklist",
|
||||||
|
get(api::blacklist::list_blacklist).post(api::blacklist::add_blacklist),
|
||||||
|
)
|
||||||
|
.route("/api/v1/nodes", get(api::nodes::list_nodes))
|
||||||
|
.route_layer(middleware::from_fn(auth::auth_middleware));
|
||||||
|
|
||||||
|
let cors = match std::env::var("CORS_ORIGIN") {
|
||||||
|
Ok(origin) if origin.is_empty() || origin == "*" => CorsLayer::new().allow_origin(tower_http::cors::Any),
|
||||||
|
Ok(origin) => CorsLayer::new().allow_origin(HeaderValue::from_str(&origin)?),
|
||||||
|
Err(_) => CorsLayer::new().allow_origin(HeaderValue::from_static("http://localhost:5173")),
|
||||||
|
};
|
||||||
|
|
||||||
|
let app = Router::new()
|
||||||
|
.merge(public)
|
||||||
|
.merge(protected)
|
||||||
|
.layer(cors)
|
||||||
|
.with_state(state);
|
||||||
|
|
||||||
|
let addr = "0.0.0.0:8080";
|
||||||
|
tracing::info!("Manager API listening on {addr}");
|
||||||
|
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||||
|
let app = app.into_make_service_with_connect_info::<std::net::SocketAddr>();
|
||||||
|
axum::serve(listener, app).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
311
src/bin/rampart.rs
Normal file
311
src/bin/rampart.rs
Normal file
|
|
@ -0,0 +1,311 @@
|
||||||
|
use rampart::config::Config;
|
||||||
|
use rampart::engine::challenge::DifficultyAdjuster;
|
||||||
|
use rampart::engine::listener;
|
||||||
|
use rampart::engine::subnet_monitor;
|
||||||
|
use rampart::engine::subnet_tracker::SubnetTracker;
|
||||||
|
use rampart::engine::tunnel::Gateway;
|
||||||
|
use rampart::filter::blacklist::Blacklist;
|
||||||
|
use rampart::filter::rate_limit::RateLimiter;
|
||||||
|
use rampart::metrics;
|
||||||
|
use rampart::protocol::ProtocolRegistry;
|
||||||
|
use rampart::store::clickhouse::{ClickHouseEvent, ClickHouseWriter};
|
||||||
|
use rampart::traffic::alert::{AlertDispatcher, send_webhook};
|
||||||
|
use rampart::traffic::detector::{AttackDetector, AttackStatus};
|
||||||
|
use rampart::traffic::hook::TrafficHook;
|
||||||
|
use rampart::traffic::reputation::IpReputation;
|
||||||
|
use rampart::xdp::XdpFilter;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::net::IpAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::sync::watch;
|
||||||
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
|
fn attack_status_value(status: AttackStatus) -> i64 {
|
||||||
|
match status {
|
||||||
|
AttackStatus::Normal => 0,
|
||||||
|
AttackStatus::Suspicious => 1,
|
||||||
|
AttackStatus::UnderAttack => 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Собирает реестр протоколов из скомпилированных реализаций.
|
||||||
|
///
|
||||||
|
/// В ядре реализаций нет: `protocol-http` — зарезервированная пустая feature,
|
||||||
|
/// показывающая модель plugin-by-feature. Внешние крейты регистрируют свои
|
||||||
|
/// обработчики в этом месте.
|
||||||
|
#[allow(unused_mut)]
|
||||||
|
fn build_registry() -> ProtocolRegistry {
|
||||||
|
let mut registry = ProtocolRegistry::new();
|
||||||
|
#[cfg(feature = "protocol-http")]
|
||||||
|
register_http(&mut registry);
|
||||||
|
registry
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "protocol-http")]
|
||||||
|
fn register_http(_registry: &mut ProtocolRegistry) {}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(EnvFilter::from_default_env().add_directive("rampart=info".parse()?))
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let registry = Arc::new(build_registry());
|
||||||
|
if registry.is_empty() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"no protocol plugins compiled; build with --features protocol-http \
|
||||||
|
or link an external ProtocolHandler implementation"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let config_path = std::env::var("RAMPART_CONFIG").unwrap_or_else(|_| "/etc/rampart/config.toml".to_string());
|
||||||
|
let config = Arc::new(Config::from_file(&config_path)?);
|
||||||
|
let whitelist = build_whitelist(&config)?;
|
||||||
|
|
||||||
|
let rate_limiter = Arc::new(RateLimiter::new(
|
||||||
|
config.limits.rate_limit_pps,
|
||||||
|
config.limits.rate_limit_burst,
|
||||||
|
));
|
||||||
|
let blacklist = Arc::new(Blacklist::new());
|
||||||
|
let reputation = Arc::new(IpReputation::new());
|
||||||
|
let detector = Arc::new(Mutex::new(AttackDetector::new()));
|
||||||
|
let hook = Arc::new(TrafficHook::new(
|
||||||
|
reputation.clone(),
|
||||||
|
blacklist.clone(),
|
||||||
|
config.detect.autoban.clone(),
|
||||||
|
config.ban.ban_duration_secs,
|
||||||
|
));
|
||||||
|
let alert_dispatcher = AlertDispatcher::new();
|
||||||
|
let webhook_url = config.detect.alert.webhook_url.clone();
|
||||||
|
let allowed_1s = Arc::new(AtomicU64::new(0));
|
||||||
|
|
||||||
|
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||||
|
|
||||||
|
let sig_tx = shutdown_tx.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
wait_for_signal().await;
|
||||||
|
tracing::info!("shutdown signal received, draining connections...");
|
||||||
|
let _ = sig_tx.send(true);
|
||||||
|
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||||
|
tracing::info!("shutdown timeout reached, exiting");
|
||||||
|
std::process::exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
#[cfg(feature = "store-redis")]
|
||||||
|
if let Some(redis_url) = &config.store.redis_url
|
||||||
|
&& !redis_url.is_empty()
|
||||||
|
{
|
||||||
|
let bl = blacklist.clone();
|
||||||
|
let sd = shutdown_rx.clone();
|
||||||
|
let url = redis_url.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let client = match redis::Client::open(url.as_str()) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Invalid redis_url: {e}, blacklist sync disabled");
|
||||||
|
return;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
rampart::store::start_blacklist_sync(&client, bl, sd).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.metrics.enabled {
|
||||||
|
let metrics_addr = format!("0.0.0.0:{}", config.metrics.port);
|
||||||
|
tracing::info!("Metrics server listening on {metrics_addr}");
|
||||||
|
tokio::spawn(async move {
|
||||||
|
metrics::run_metrics_server(&metrics_addr).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let clickhouse: Option<Arc<tokio::sync::Mutex<ClickHouseWriter>>> = match &config.store.clickhouse_url {
|
||||||
|
Some(url) if !url.is_empty() => {
|
||||||
|
let writer = Arc::new(tokio::sync::Mutex::new(ClickHouseWriter::new(url)));
|
||||||
|
rampart::store::clickhouse::start_flush_task(writer.clone(), shutdown_rx.clone());
|
||||||
|
Some(writer)
|
||||||
|
},
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let xdp_filter = start_xdp(&config, &shutdown_rx)?;
|
||||||
|
let subnet_tracker = start_subnet_tracker(&config);
|
||||||
|
subnet_monitor::spawn(
|
||||||
|
&config.detect.prefix,
|
||||||
|
subnet_tracker.clone(),
|
||||||
|
xdp_filter.clone(),
|
||||||
|
shutdown_rx.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let rl = rate_limiter.clone();
|
||||||
|
let bl = blacklist.clone();
|
||||||
|
let det = detector.clone();
|
||||||
|
let intel = hook.clone();
|
||||||
|
let a1s = allowed_1s.clone();
|
||||||
|
let ch = clickhouse.clone();
|
||||||
|
let mut sd = shutdown_rx.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut sec_tick = tokio::time::interval(Duration::from_secs(1));
|
||||||
|
let mut min_tick = tokio::time::interval(Duration::from_secs(60));
|
||||||
|
sec_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||||
|
min_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
biased;
|
||||||
|
_ = sd.changed() => {
|
||||||
|
if *sd.borrow() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = sec_tick.tick() => {
|
||||||
|
let pps = a1s.swap(0, Ordering::Relaxed) as f64;
|
||||||
|
let cps = intel.take_cps() as f64;
|
||||||
|
metrics::INTEL_CPS.set(cps as i64);
|
||||||
|
let status = det.lock().expect("detector lock poisoned").analyze(pps, cps);
|
||||||
|
metrics::ATTACK_STATUS.set(attack_status_value(status));
|
||||||
|
if let Some(alert) = alert_dispatcher.on_status(status) {
|
||||||
|
// Дедупликация: алерт только на переходе состояния атаки.
|
||||||
|
metrics::INTEL_ALERTS_TOTAL.inc();
|
||||||
|
tracing::warn!(pps, cps, "{alert}");
|
||||||
|
push_attack_event(&ch, pps).await;
|
||||||
|
if let Some(url) = webhook_url.clone() {
|
||||||
|
tokio::spawn(send_webhook(url, alert.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if status == AttackStatus::UnderAttack {
|
||||||
|
let banned = intel.ban_offenders();
|
||||||
|
if banned > 0 {
|
||||||
|
tracing::info!(banned, "escalation: offenders auto-banned during attack");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = min_tick.tick() => {
|
||||||
|
rl.sweep();
|
||||||
|
bl.clear_expired();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tracing::info!("Rampart edge starting on {}:{}", config.bind.address, config.bind.port);
|
||||||
|
tracing::info!("Upstreams: {:?}", config.backend.upstreams);
|
||||||
|
|
||||||
|
let adjuster = Arc::new(Mutex::new(DifficultyAdjuster::default()));
|
||||||
|
let gateway = Arc::new(Gateway::new(
|
||||||
|
config.clone(),
|
||||||
|
rate_limiter,
|
||||||
|
blacklist,
|
||||||
|
adjuster,
|
||||||
|
whitelist,
|
||||||
|
reputation,
|
||||||
|
xdp_filter,
|
||||||
|
clickhouse,
|
||||||
|
allowed_1s,
|
||||||
|
registry,
|
||||||
|
subnet_tracker,
|
||||||
|
));
|
||||||
|
listener::run(config, gateway, hook, shutdown_rx).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn push_attack_event(writer: &Option<Arc<tokio::sync::Mutex<ClickHouseWriter>>>, pps: f64) {
|
||||||
|
let Some(writer) = writer else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let event = ClickHouseEvent {
|
||||||
|
timestamp: chrono::Utc::now(),
|
||||||
|
event_type: "attack".to_string(),
|
||||||
|
ip: String::new(),
|
||||||
|
data_float: pps,
|
||||||
|
data_int: 0,
|
||||||
|
data_string: "under_attack".to_string(),
|
||||||
|
};
|
||||||
|
if let Err(e) = writer.lock().await.push(event).await {
|
||||||
|
tracing::debug!("clickhouse push error: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "xdp")]
|
||||||
|
fn start_xdp(
|
||||||
|
config: &Arc<Config>,
|
||||||
|
shutdown_rx: &watch::Receiver<bool>,
|
||||||
|
) -> anyhow::Result<Option<Arc<Mutex<XdpFilter>>>> {
|
||||||
|
use rampart::xdp::XdpMetrics;
|
||||||
|
|
||||||
|
if !config.xdp.enabled {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let filter = XdpFilter::new(&config.xdp.interface);
|
||||||
|
let shared = Arc::new(Mutex::new(filter));
|
||||||
|
shared.lock().expect("xdp lock poisoned").load()?;
|
||||||
|
let xdp_metrics = XdpMetrics::register()?;
|
||||||
|
|
||||||
|
let sd = shutdown_rx.clone();
|
||||||
|
let shared_thread = shared.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
while !*sd.borrow() {
|
||||||
|
let guard = match shared_thread.lock() {
|
||||||
|
Ok(g) => g,
|
||||||
|
Err(_) => break,
|
||||||
|
};
|
||||||
|
guard.drain_events();
|
||||||
|
if let Ok(stats) = guard.get_stats() {
|
||||||
|
xdp_metrics.update(&stats);
|
||||||
|
}
|
||||||
|
drop(guard);
|
||||||
|
std::thread::sleep(Duration::from_secs(5));
|
||||||
|
}
|
||||||
|
if let Ok(mut guard) = shared_thread.lock() {
|
||||||
|
guard.unload().ok();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok(Some(shared))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "xdp"))]
|
||||||
|
fn start_xdp(
|
||||||
|
_config: &Arc<Config>,
|
||||||
|
_shutdown_rx: &watch::Receiver<bool>,
|
||||||
|
) -> anyhow::Result<Option<Arc<Mutex<XdpFilter>>>> {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Юзерспейс-агрегатор префиксов: активен, когда detect.prefix включён,
|
||||||
|
/// а XDP-путь не работает (не собран или выключен в конфиге).
|
||||||
|
fn start_subnet_tracker(config: &Config) -> Option<Arc<SubnetTracker>> {
|
||||||
|
let xdp_active = cfg!(feature = "xdp") && config.xdp.enabled;
|
||||||
|
if config.detect.prefix.enabled && !xdp_active {
|
||||||
|
tracing::info!(
|
||||||
|
window_secs = config.detect.prefix.window_secs,
|
||||||
|
syn_threshold = config.detect.prefix.syn_threshold,
|
||||||
|
"subnet tracker enabled (userspace prefix aggregation)"
|
||||||
|
);
|
||||||
|
Some(Arc::new(SubnetTracker::new()))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_whitelist(config: &Config) -> anyhow::Result<Arc<HashSet<IpAddr>>> {
|
||||||
|
let mut set = HashSet::with_capacity(config.whitelist.len());
|
||||||
|
for entry in &config.whitelist {
|
||||||
|
let ip: IpAddr = entry
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| anyhow::anyhow!("invalid whitelist entry: {entry}"))?;
|
||||||
|
set.insert(ip);
|
||||||
|
}
|
||||||
|
Ok(Arc::new(set))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_signal() {
|
||||||
|
let ctrl_c = tokio::signal::ctrl_c();
|
||||||
|
let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
||||||
|
.expect("failed to install SIGTERM handler");
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
_ = ctrl_c => {}
|
||||||
|
_ = term.recv() => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,62 @@
|
||||||
|
use crate::xdp::{EnvironmentReport, FilesystemProbe, SystemProbe};
|
||||||
|
|
||||||
|
/// Ищет интерфейс с маршрутом по умолчанию в /proc/net/route
|
||||||
|
/// (destination == 00000000). Работает без root.
|
||||||
|
fn default_route_interface(probe: &impl SystemProbe) -> Option<String> {
|
||||||
|
let routes = probe.read_file("/proc/net/route").ok()?;
|
||||||
|
routes.lines().skip(1).find_map(|line| {
|
||||||
|
let fields: Vec<&str> = line.split_whitespace().collect();
|
||||||
|
(fields.len() > 7 && fields[1] == "00000000").then(|| fields[0].to_owned())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Подсекция network/XDP: что видно окружению без root.
|
||||||
|
fn print_network_section(all_ok: &mut bool) {
|
||||||
|
println!("\nNetwork / XDP");
|
||||||
|
println!("-------------");
|
||||||
|
|
||||||
|
let probe = FilesystemProbe;
|
||||||
|
let Some(interface) = default_route_interface(&probe) else {
|
||||||
|
println!("[WARN] Default route interface not found, XDP checks skipped");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
println!("Interface: {interface}");
|
||||||
|
|
||||||
|
let report = EnvironmentReport::collect(&probe, &interface);
|
||||||
|
let kernel = report
|
||||||
|
.kernel_version
|
||||||
|
.map_or_else(|| "unknown".to_owned(), |v| v.to_string());
|
||||||
|
println!("Kernel: {kernel}");
|
||||||
|
println!(
|
||||||
|
"BTF: {}",
|
||||||
|
if report.btf_available {
|
||||||
|
"available"
|
||||||
|
} else {
|
||||||
|
"missing (CO-RE disabled)"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"Privileges: {}",
|
||||||
|
if report.privileged {
|
||||||
|
"root (capabilities assumed)"
|
||||||
|
} else {
|
||||||
|
"non-root"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
println!("Driver: {}", report.driver_name.as_deref().unwrap_or("unknown"));
|
||||||
|
println!("Attach mode: {}", report.attach_mode());
|
||||||
|
println!("Verdict: {}", report.verdict());
|
||||||
|
for warning in &report.warnings {
|
||||||
|
println!("[WARN] {warning}");
|
||||||
|
}
|
||||||
|
if let Err(e) = report.validate() {
|
||||||
|
println!("[FAIL] {e}");
|
||||||
|
*all_ok = false;
|
||||||
|
} else {
|
||||||
|
println!("[OK] XDP environment");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn run() -> anyhow::Result<()> {
|
pub async fn run() -> anyhow::Result<()> {
|
||||||
println!("Rampart Diagnostics");
|
println!("Rampart Diagnostics");
|
||||||
println!("=====================\n");
|
println!("=====================\n");
|
||||||
|
|
@ -34,6 +93,8 @@ pub async fn run() -> anyhow::Result<()> {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
print_network_section(&mut all_ok);
|
||||||
|
|
||||||
println!();
|
println!();
|
||||||
if all_ok {
|
if all_ok {
|
||||||
println!("All checks passed.");
|
println!("All checks passed.");
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@
|
||||||
mod sections;
|
mod sections;
|
||||||
|
|
||||||
pub use sections::{
|
pub use sections::{
|
||||||
BackendConfig, BanConfig, BindConfig, DetectConfig, DetectPrefixConfig, LimitsConfig, LoggingConfig, MetricsConfig,
|
BackendConfig, BanConfig, BindConfig, DetectAlertConfig, DetectAutobanConfig, DetectConfig, DetectPrefixConfig,
|
||||||
PowConfig, StoreConfig, WorkerConfig, XdpConfig,
|
LimitsConfig, LoggingConfig, MetricsConfig, PowConfig, StoreConfig, WorkerConfig, XdpConfig,
|
||||||
};
|
};
|
||||||
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
|
||||||
|
|
@ -232,6 +232,42 @@ fn default_pow_difficulty() -> u8 {
|
||||||
pub struct DetectConfig {
|
pub struct DetectConfig {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub prefix: DetectPrefixConfig,
|
pub prefix: DetectPrefixConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub autoban: DetectAutobanConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub alert: DetectAlertConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Секция `[detect.autoban]`: авто-бан источников по репутации и сигналам
|
||||||
|
/// детектора атак. Порог сравнивается со скором [`crate::traffic::reputation::IpReputation`],
|
||||||
|
/// TTL берётся из `ban.ban_duration_secs`.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct DetectAutobanConfig {
|
||||||
|
#[serde(default)]
|
||||||
|
pub enabled: bool,
|
||||||
|
#[serde(default = "default_autoban_reputation_threshold")]
|
||||||
|
pub reputation_threshold: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for DetectAutobanConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: false,
|
||||||
|
reputation_threshold: default_autoban_reputation_threshold(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_autoban_reputation_threshold() -> i32 {
|
||||||
|
-50
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Секция `[detect.alert]`: webhook-алерты на переходах состояния атаки
|
||||||
|
/// («атака началась» / «атака закончилась»).
|
||||||
|
#[derive(Debug, Clone, Default, Deserialize)]
|
||||||
|
pub struct DetectAlertConfig {
|
||||||
|
#[serde(default)]
|
||||||
|
pub webhook_url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Секция `[detect.prefix]`: subnet-level детектор распределённых атак.
|
/// Секция `[detect.prefix]`: subnet-level детектор распределённых атак.
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::engine::tunnel::Gateway;
|
use crate::engine::tunnel::Gateway;
|
||||||
|
use crate::traffic::hook::TrafficHook;
|
||||||
use socket2::{Domain, Socket, Type};
|
use socket2::{Domain, Socket, Type};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
|
|
@ -12,8 +13,12 @@ use tokio::sync::watch;
|
||||||
/// # Errors
|
/// # Errors
|
||||||
/// Возвращает ошибку при невозможности забиндить адрес или падении одного
|
/// Возвращает ошибку при невозможности забиндить адрес или падении одного
|
||||||
/// из accept-loop'ов.
|
/// из accept-loop'ов.
|
||||||
pub async fn run(config: Arc<Config>, gateway: Arc<Gateway>, shutdown: watch::Receiver<bool>) -> anyhow::Result<()> {
|
pub async fn run(
|
||||||
let _ = &config;
|
config: Arc<Config>,
|
||||||
|
gateway: Arc<Gateway>,
|
||||||
|
hook: Arc<TrafficHook>,
|
||||||
|
shutdown: watch::Receiver<bool>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
let addr: std::net::SocketAddr = format!("{}:{}", config.bind.address, config.bind.port).parse()?;
|
let addr: std::net::SocketAddr = format!("{}:{}", config.bind.address, config.bind.port).parse()?;
|
||||||
let workers = config.workers.count.max(1);
|
let workers = config.workers.count.max(1);
|
||||||
|
|
||||||
|
|
@ -23,6 +28,7 @@ pub async fn run(config: Arc<Config>, gateway: Arc<Gateway>, shutdown: watch::Re
|
||||||
handles.push(tokio::spawn(accept_loop(
|
handles.push(tokio::spawn(accept_loop(
|
||||||
listener,
|
listener,
|
||||||
Arc::clone(&gateway),
|
Arc::clone(&gateway),
|
||||||
|
Arc::clone(&hook),
|
||||||
shutdown.clone(),
|
shutdown.clone(),
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
@ -43,7 +49,12 @@ fn build_listener(addr: std::net::SocketAddr) -> anyhow::Result<TcpListener> {
|
||||||
Ok(TcpListener::from_std(socket.into())?)
|
Ok(TcpListener::from_std(socket.into())?)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn accept_loop(listener: TcpListener, gateway: Arc<Gateway>, mut shutdown: watch::Receiver<bool>) {
|
async fn accept_loop(
|
||||||
|
listener: TcpListener,
|
||||||
|
gateway: Arc<Gateway>,
|
||||||
|
hook: Arc<TrafficHook>,
|
||||||
|
mut shutdown: watch::Receiver<bool>,
|
||||||
|
) {
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
biased;
|
biased;
|
||||||
|
|
@ -62,10 +73,14 @@ async fn accept_loop(listener: TcpListener, gateway: Arc<Gateway>, mut shutdown:
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let gateway = Arc::clone(&gateway);
|
let gateway = Arc::clone(&gateway);
|
||||||
|
let hook = Arc::clone(&hook);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
|
// Hot path Traffic Intelligence: только атомики и DashMap.
|
||||||
|
hook.on_accept();
|
||||||
if let Err(e) = gateway.handle(stream, peer_addr).await {
|
if let Err(e) = gateway.handle(stream, peer_addr).await {
|
||||||
tracing::debug!("connection from {peer_addr}: {e}");
|
tracing::debug!("connection from {peer_addr}: {e}");
|
||||||
}
|
}
|
||||||
|
hook.on_connection_end(peer_addr.ip());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
use prometheus::{Encoder, IntCounterVec, IntGauge, register_int_counter_vec, register_int_gauge};
|
use prometheus::{
|
||||||
|
Encoder, IntCounter, IntCounterVec, IntGauge, register_int_counter, register_int_counter_vec, register_int_gauge,
|
||||||
|
};
|
||||||
use std::sync::LazyLock;
|
use std::sync::LazyLock;
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
|
|
@ -38,6 +40,31 @@ pub static SUBNET_VERDICTS: LazyLock<IntCounterVec> = LazyLock::new(|| {
|
||||||
.expect("SUBNET_VERDICTS")
|
.expect("SUBNET_VERDICTS")
|
||||||
});
|
});
|
||||||
|
|
||||||
|
pub static AUTO_BANS_TOTAL: LazyLock<IntCounterVec> = LazyLock::new(|| {
|
||||||
|
register_int_counter_vec!(
|
||||||
|
"rampart_auto_bans_total",
|
||||||
|
"Auto-bans issued by Traffic Intelligence",
|
||||||
|
&["reason"]
|
||||||
|
)
|
||||||
|
.expect("AUTO_BANS_TOTAL")
|
||||||
|
});
|
||||||
|
|
||||||
|
pub static INTEL_RATE_LIMIT_DROPS_TOTAL: LazyLock<IntCounter> = LazyLock::new(|| {
|
||||||
|
register_int_counter!(
|
||||||
|
"rampart_intel_rate_limit_drops_total",
|
||||||
|
"Connections dropped by rate limit"
|
||||||
|
)
|
||||||
|
.expect("INTEL_RATE_LIMIT_DROPS_TOTAL")
|
||||||
|
});
|
||||||
|
|
||||||
|
pub static INTEL_CPS: LazyLock<IntGauge> =
|
||||||
|
LazyLock::new(|| register_int_gauge!("rampart_intel_cps", "New connections per second window").expect("INTEL_CPS"));
|
||||||
|
|
||||||
|
pub static INTEL_ALERTS_TOTAL: LazyLock<IntCounter> = LazyLock::new(|| {
|
||||||
|
register_int_counter!("rampart_intel_alerts_total", "Attack state-transition alerts emitted")
|
||||||
|
.expect("INTEL_ALERTS_TOTAL")
|
||||||
|
});
|
||||||
|
|
||||||
/// Отдаёт Prometheus-метрики по голому HTTP/0.9-совместимому ответу.
|
/// Отдаёт Prometheus-метрики по голому HTTP/0.9-совместимому ответу.
|
||||||
pub async fn run_metrics_server(addr: &str) {
|
pub async fn run_metrics_server(addr: &str) {
|
||||||
let listener = match TcpListener::bind(addr).await {
|
let listener = match TcpListener::bind(addr).await {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::net::Ipv4Addr;
|
use std::net::Ipv4Addr;
|
||||||
use std::time::Instant;
|
use std::sync::Mutex;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use crate::traffic::detector::AttackStatus;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum AlertLevel {
|
pub enum AlertLevel {
|
||||||
|
|
@ -19,7 +22,7 @@ impl fmt::Display for AlertLevel {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub struct Alert {
|
pub struct Alert {
|
||||||
pub level: AlertLevel,
|
pub level: AlertLevel,
|
||||||
pub message: String,
|
pub message: String,
|
||||||
|
|
@ -53,6 +56,61 @@ impl fmt::Display for Alert {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Дедуплицирующий диспетчер алертов: алерт выдаётся только на переходе
|
||||||
|
/// состояния детектора (вход в `UnderAttack` и выход из него), не на каждом
|
||||||
|
/// тике. Логика — poll-and-persist (LNVPS issue #331).
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct AlertDispatcher {
|
||||||
|
previous: Mutex<Option<AttackStatus>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AlertDispatcher {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Возвращает `Some(Alert)` только при переходе через границу атаки:
|
||||||
|
/// вход в `UnderAttack` («attack started») или выход из него
|
||||||
|
/// («attack ended»). Первое наблюдение алерта не порождает.
|
||||||
|
pub fn on_status(&self, status: AttackStatus) -> Option<Alert> {
|
||||||
|
let mut previous = self.previous.lock().expect("alert dispatcher lock poisoned");
|
||||||
|
if *previous == Some(status) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let was_under_attack = *previous == Some(AttackStatus::UnderAttack);
|
||||||
|
*previous = Some(status);
|
||||||
|
match (was_under_attack, status) {
|
||||||
|
(false, AttackStatus::UnderAttack) => {
|
||||||
|
Some(Alert::new(AlertLevel::Critical, "attack started".into(), None, 0.0))
|
||||||
|
},
|
||||||
|
(true, _) => Some(Alert::new(AlertLevel::Info, "attack ended".into(), None, 0.0)),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Отправляет алерт на webhook (fire-and-forget, с таймаутом).
|
||||||
|
///
|
||||||
|
/// Вызывать следует из фоновых задач: сетевой ввод-вывод недопустим в hot path.
|
||||||
|
pub async fn send_webhook(url: String, alert: Alert) {
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"level": alert.level.to_string(),
|
||||||
|
"message": alert.message,
|
||||||
|
"pps": alert.pps,
|
||||||
|
});
|
||||||
|
let client = reqwest::Client::builder().timeout(Duration::from_secs(5)).build();
|
||||||
|
let client = match client {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("webhook client build failed: {e}");
|
||||||
|
return;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if let Err(e) = client.post(url).json(&payload).send().await {
|
||||||
|
tracing::warn!("webhook delivery failed: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -77,4 +135,35 @@ mod tests {
|
||||||
assert!(s.contains("INFO"));
|
assert!(s.contains("INFO"));
|
||||||
assert!(s.contains("traffic spike"));
|
assert!(s.contains("traffic spike"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_dispatcher_two_transitions_two_alerts() {
|
||||||
|
let dispatcher = AlertDispatcher::new();
|
||||||
|
assert_eq!(dispatcher.on_status(AttackStatus::Normal), None);
|
||||||
|
assert_eq!(dispatcher.on_status(AttackStatus::Suspicious), None);
|
||||||
|
let started = dispatcher.on_status(AttackStatus::UnderAttack);
|
||||||
|
assert_eq!(dispatcher.on_status(AttackStatus::UnderAttack), None);
|
||||||
|
assert_eq!(dispatcher.on_status(AttackStatus::UnderAttack), None);
|
||||||
|
let ended = dispatcher.on_status(AttackStatus::Normal);
|
||||||
|
assert_eq!(dispatcher.on_status(AttackStatus::Normal), None);
|
||||||
|
assert!(started.is_some());
|
||||||
|
assert!(ended.is_some());
|
||||||
|
assert!(
|
||||||
|
started.expect("started alert").message.contains("attack started")
|
||||||
|
&& ended.expect("ended alert").message.contains("attack ended")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_dispatcher_suspicious_does_not_flap_alerts() {
|
||||||
|
let dispatcher = AlertDispatcher::new();
|
||||||
|
for status in [
|
||||||
|
AttackStatus::Normal,
|
||||||
|
AttackStatus::Suspicious,
|
||||||
|
AttackStatus::Normal,
|
||||||
|
AttackStatus::Suspicious,
|
||||||
|
] {
|
||||||
|
assert_eq!(dispatcher.on_status(status), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,8 @@ pub enum AttackStatus {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct AttackDetector {
|
pub struct AttackDetector {
|
||||||
profiler: TrafficProfiler,
|
pps_profiler: TrafficProfiler,
|
||||||
|
cps_profiler: TrafficProfiler,
|
||||||
consecutive_anomalies: u32,
|
consecutive_anomalies: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -21,14 +22,21 @@ impl Default for AttackDetector {
|
||||||
impl AttackDetector {
|
impl AttackDetector {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
profiler: TrafficProfiler::new(),
|
pps_profiler: TrafficProfiler::new(),
|
||||||
|
cps_profiler: TrafficProfiler::new(),
|
||||||
consecutive_anomalies: 0,
|
consecutive_anomalies: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn analyze(&mut self, pps: f64) -> AttackStatus {
|
/// Анализирует окно метрик (пакеты/с и новые соединения/с) относительно
|
||||||
self.profiler.record(pps);
|
/// накопленных профилей. Аномалией считается всплеск любой из метрик.
|
||||||
let score = self.profiler.anomaly_score(pps);
|
pub fn analyze(&mut self, pps: f64, cps: f64) -> AttackStatus {
|
||||||
|
self.pps_profiler.record(pps);
|
||||||
|
self.cps_profiler.record(cps);
|
||||||
|
let score = self
|
||||||
|
.pps_profiler
|
||||||
|
.anomaly_score(pps)
|
||||||
|
.max(self.cps_profiler.anomaly_score(cps));
|
||||||
|
|
||||||
if score > 3.0 {
|
if score > 3.0 {
|
||||||
self.consecutive_anomalies += 1;
|
self.consecutive_anomalies += 1;
|
||||||
|
|
@ -54,40 +62,49 @@ mod tests {
|
||||||
fn test_attack_status_normal() {
|
fn test_attack_status_normal() {
|
||||||
let mut d = AttackDetector::new();
|
let mut d = AttackDetector::new();
|
||||||
for _ in 0..10 {
|
for _ in 0..10 {
|
||||||
d.analyze(100.0);
|
d.analyze(100.0, 10.0);
|
||||||
}
|
}
|
||||||
assert_eq!(d.analyze(100.0), AttackStatus::Normal);
|
assert_eq!(d.analyze(100.0, 10.0), AttackStatus::Normal);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_attack_status_suspicious() {
|
fn test_attack_status_suspicious_pps_spike() {
|
||||||
let mut d = AttackDetector::new();
|
let mut d = AttackDetector::new();
|
||||||
for _ in 0..10 {
|
for _ in 0..10 {
|
||||||
d.analyze(1.0);
|
d.analyze(1.0, 1.0);
|
||||||
}
|
}
|
||||||
assert_eq!(d.analyze(5.0), AttackStatus::Suspicious);
|
assert_eq!(d.analyze(5.0, 1.0), AttackStatus::Suspicious);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_attack_status_suspicious_cps_spike() {
|
||||||
|
let mut d = AttackDetector::new();
|
||||||
|
for _ in 0..10 {
|
||||||
|
d.analyze(1.0, 1.0);
|
||||||
|
}
|
||||||
|
assert_eq!(d.analyze(1.0, 5.0), AttackStatus::Suspicious);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_attack_status_under_attack() {
|
fn test_attack_status_under_attack() {
|
||||||
let mut d = AttackDetector::new();
|
let mut d = AttackDetector::new();
|
||||||
for _ in 0..10 {
|
for _ in 0..10 {
|
||||||
d.analyze(1.0);
|
d.analyze(1.0, 1.0);
|
||||||
}
|
}
|
||||||
d.analyze(10_000.0);
|
d.analyze(10_000.0, 100.0);
|
||||||
d.analyze(10_000.0);
|
d.analyze(10_000.0, 100.0);
|
||||||
assert_eq!(d.analyze(10_000.0), AttackStatus::UnderAttack);
|
assert_eq!(d.analyze(10_000.0, 100.0), AttackStatus::UnderAttack);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_consecutive_resets_on_normal() {
|
fn test_consecutive_resets_on_normal() {
|
||||||
let mut d = AttackDetector::new();
|
let mut d = AttackDetector::new();
|
||||||
for _ in 0..10 {
|
for _ in 0..10 {
|
||||||
d.analyze(1.0);
|
d.analyze(1.0, 1.0);
|
||||||
}
|
}
|
||||||
d.analyze(100.0);
|
d.analyze(100.0, 10.0);
|
||||||
d.analyze(100.0);
|
d.analyze(100.0, 10.0);
|
||||||
d.analyze(1.0);
|
d.analyze(1.0, 1.0);
|
||||||
assert_eq!(d.analyze(1.0), AttackStatus::Normal);
|
assert_eq!(d.analyze(1.0, 1.0), AttackStatus::Normal);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
219
src/traffic/hook.rs
Normal file
219
src/traffic/hook.rs
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
//! Traffic Intelligence в hot path: репутация → авто-бан → метрики окна.
|
||||||
|
//!
|
||||||
|
//! Все вызовы из accept-пути дешёвые (DashMap/атомики), блокирующего
|
||||||
|
//! ввода-вывода нет.
|
||||||
|
|
||||||
|
use crate::config::DetectAutobanConfig;
|
||||||
|
use crate::filter::blacklist::Blacklist;
|
||||||
|
use crate::metrics;
|
||||||
|
use crate::traffic::reputation::IpReputation;
|
||||||
|
use std::net::IpAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
const BAN_REASON_LOW_REPUTATION: &str = "low_reputation";
|
||||||
|
const BAN_REASON_ATTACK: &str = "attack_detector";
|
||||||
|
|
||||||
|
/// Точка входа Traffic Intelligence для сетевого пути.
|
||||||
|
///
|
||||||
|
/// События: новое соединение (`on_accept`), завершение обработки соединения
|
||||||
|
/// (`on_connection_end`), эскалация детектора атак (`ban_offenders`).
|
||||||
|
pub struct TrafficHook {
|
||||||
|
reputation: Arc<IpReputation>,
|
||||||
|
blacklist: Arc<Blacklist>,
|
||||||
|
autoban: DetectAutobanConfig,
|
||||||
|
ban_ttl_secs: u64,
|
||||||
|
conn_window: AtomicU64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TrafficHook {
|
||||||
|
pub fn new(
|
||||||
|
reputation: Arc<IpReputation>,
|
||||||
|
blacklist: Arc<Blacklist>,
|
||||||
|
autoban: DetectAutobanConfig,
|
||||||
|
ban_ttl_secs: u64,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
reputation,
|
||||||
|
blacklist,
|
||||||
|
autoban,
|
||||||
|
ban_ttl_secs,
|
||||||
|
conn_window: AtomicU64::new(0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Новое соединение принято accept-loop'ом. Только счётчик окна cps.
|
||||||
|
pub fn on_accept(&self) {
|
||||||
|
self.conn_window.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Соединение обработано. Если источник попал под rate-limit (репутация
|
||||||
|
/// упала до порога) — авто-бан с TTL из конфига.
|
||||||
|
pub fn on_connection_end(&self, ip: IpAddr) {
|
||||||
|
if !self.autoban.enabled || self.blacklist.is_blocked(ip) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.autoban_if_toxic(ip, BAN_REASON_LOW_REPUTATION);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Эскалация детектора атак: бан всех источников ниже порога репутации.
|
||||||
|
/// Вызывается один раз на переход в `UnderAttack`, не на каждый тик.
|
||||||
|
pub fn ban_offenders(&self) -> usize {
|
||||||
|
if !self.autoban.enabled {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let offenders = self.reputation.offenders_below(self.autoban.reputation_threshold);
|
||||||
|
let mut banned = 0;
|
||||||
|
for ip in offenders {
|
||||||
|
if self.blacklist.is_blocked(ip) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
self.ban(ip, BAN_REASON_ATTACK);
|
||||||
|
banned += 1;
|
||||||
|
}
|
||||||
|
banned
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Забирает накопленное окно новых соединений за секунду (cps).
|
||||||
|
pub fn take_cps(&self) -> u64 {
|
||||||
|
self.conn_window.swap(0, Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn autoban_if_toxic(&self, ip: IpAddr, reason: &str) {
|
||||||
|
if self.reputation.score(ip) > self.autoban.reputation_threshold {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.ban(ip, reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ban(&self, ip: IpAddr, reason: &str) {
|
||||||
|
self.blacklist.add(ip, Duration::from_secs(self.ban_ttl_secs), reason);
|
||||||
|
metrics::AUTO_BANS_TOTAL.with_label_values(&[reason]).inc();
|
||||||
|
tracing::warn!(%ip, reason, ttl_secs = self.ban_ttl_secs, "auto-ban issued by traffic intelligence");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::net::{IpAddr, Ipv4Addr};
|
||||||
|
|
||||||
|
fn ip(octets: [u8; 4]) -> IpAddr {
|
||||||
|
IpAddr::V4(Ipv4Addr::from(octets))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hook(rep: &Arc<IpReputation>, bl: &Arc<Blacklist>) -> TrafficHook {
|
||||||
|
TrafficHook::new(
|
||||||
|
Arc::clone(rep),
|
||||||
|
Arc::clone(bl),
|
||||||
|
DetectAutobanConfig {
|
||||||
|
enabled: true,
|
||||||
|
reputation_threshold: -50,
|
||||||
|
},
|
||||||
|
60,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cps_window_counts_and_resets() {
|
||||||
|
let rep = Arc::new(IpReputation::new());
|
||||||
|
let bl = Arc::new(Blacklist::new());
|
||||||
|
let hook = hook(&rep, &bl);
|
||||||
|
hook.on_accept();
|
||||||
|
hook.on_accept();
|
||||||
|
hook.on_accept();
|
||||||
|
assert_eq!(hook.take_cps(), 3);
|
||||||
|
assert_eq!(hook.take_cps(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_reputation_fall_triggers_autoban() {
|
||||||
|
let rep = Arc::new(IpReputation::new());
|
||||||
|
let bl = Arc::new(Blacklist::new());
|
||||||
|
let hook = hook(&rep, &bl);
|
||||||
|
let target = ip([10, 1, 0, 1]);
|
||||||
|
for _ in 0..6 {
|
||||||
|
rep.record_bad(target);
|
||||||
|
}
|
||||||
|
assert_eq!(rep.score(target), -60);
|
||||||
|
assert!(!bl.is_blocked(target));
|
||||||
|
hook.on_connection_end(target);
|
||||||
|
assert!(bl.is_blocked(target));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_no_autoban_above_threshold() {
|
||||||
|
let rep = Arc::new(IpReputation::new());
|
||||||
|
let bl = Arc::new(Blacklist::new());
|
||||||
|
let hook = hook(&rep, &bl);
|
||||||
|
let target = ip([10, 1, 0, 2]);
|
||||||
|
for _ in 0..4 {
|
||||||
|
rep.record_bad(target);
|
||||||
|
}
|
||||||
|
assert_eq!(rep.score(target), -40);
|
||||||
|
hook.on_connection_end(target);
|
||||||
|
assert!(!bl.is_blocked(target));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_disabled_autoban_never_bans() {
|
||||||
|
let rep = Arc::new(IpReputation::new());
|
||||||
|
let bl = Arc::new(Blacklist::new());
|
||||||
|
let hook = TrafficHook::new(
|
||||||
|
Arc::clone(&rep),
|
||||||
|
Arc::clone(&bl),
|
||||||
|
DetectAutobanConfig {
|
||||||
|
enabled: false,
|
||||||
|
reputation_threshold: -50,
|
||||||
|
},
|
||||||
|
60,
|
||||||
|
);
|
||||||
|
let target = ip([10, 1, 0, 3]);
|
||||||
|
for _ in 0..20 {
|
||||||
|
rep.record_bad(target);
|
||||||
|
}
|
||||||
|
hook.on_connection_end(target);
|
||||||
|
assert!(hook.ban_offenders() == 0);
|
||||||
|
assert!(!bl.is_blocked(target));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ban_offenders_bans_only_below_threshold() {
|
||||||
|
let rep = Arc::new(IpReputation::new());
|
||||||
|
let bl = Arc::new(Blacklist::new());
|
||||||
|
let hook = hook(&rep, &bl);
|
||||||
|
let bad = ip([10, 2, 0, 1]);
|
||||||
|
let ok = ip([10, 2, 0, 2]);
|
||||||
|
for _ in 0..8 {
|
||||||
|
rep.record_bad(bad);
|
||||||
|
}
|
||||||
|
rep.record_good(ok);
|
||||||
|
assert_eq!(hook.ban_offenders(), 1);
|
||||||
|
assert!(bl.is_blocked(bad));
|
||||||
|
assert!(!bl.is_blocked(ok));
|
||||||
|
assert_eq!(hook.ban_offenders(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_autoban_ttl_expires() {
|
||||||
|
let rep = Arc::new(IpReputation::new());
|
||||||
|
let bl = Arc::new(Blacklist::new());
|
||||||
|
let hook = TrafficHook::new(
|
||||||
|
Arc::clone(&rep),
|
||||||
|
Arc::clone(&bl),
|
||||||
|
DetectAutobanConfig {
|
||||||
|
enabled: true,
|
||||||
|
reputation_threshold: -50,
|
||||||
|
},
|
||||||
|
// Короткий TTL как мок времени: бан жив, потом истекает.
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
let target = ip([10, 3, 0, 1]);
|
||||||
|
for _ in 0..20 {
|
||||||
|
rep.record_bad(target);
|
||||||
|
}
|
||||||
|
hook.on_connection_end(target);
|
||||||
|
assert!(!bl.is_blocked(target), "zero TTL must not produce an active ban");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
pub mod alert;
|
pub mod alert;
|
||||||
pub mod detector;
|
pub mod detector;
|
||||||
pub mod ewma;
|
pub mod ewma;
|
||||||
|
pub mod hook;
|
||||||
pub mod prefix;
|
pub mod prefix;
|
||||||
pub mod profiler;
|
pub mod profiler;
|
||||||
pub mod reputation;
|
pub mod reputation;
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,15 @@ impl IpReputation {
|
||||||
self.scores.get(&ip).map(|v| *v).unwrap_or(0)
|
self.scores.get(&ip).map(|v| *v).unwrap_or(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Возвращает источники со скором не выше `threshold` (нарушители).
|
||||||
|
pub fn offenders_below(&self, threshold: i32) -> Vec<IpAddr> {
|
||||||
|
self.scores
|
||||||
|
.iter()
|
||||||
|
.filter(|entry| *entry.value() <= threshold)
|
||||||
|
.map(|entry| *entry.key())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn is_trusted(&self, ip: IpAddr) -> bool {
|
pub fn is_trusted(&self, ip: IpAddr) -> bool {
|
||||||
self.score(ip) > 50
|
self.score(ip) > 50
|
||||||
}
|
}
|
||||||
|
|
@ -107,4 +116,23 @@ mod tests {
|
||||||
rep.record_bad(ip);
|
rep.record_bad(ip);
|
||||||
assert_eq!(rep.score(ip), -10);
|
assert_eq!(rep.score(ip), -10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_offenders_below_threshold() {
|
||||||
|
let rep = IpReputation::new();
|
||||||
|
let bad = v4([10, 0, 0, 1]);
|
||||||
|
let good = v4([10, 0, 0, 2]);
|
||||||
|
for _ in 0..6 {
|
||||||
|
rep.record_bad(bad);
|
||||||
|
}
|
||||||
|
rep.record_good(good);
|
||||||
|
let offenders = rep.offenders_below(-50);
|
||||||
|
assert_eq!(offenders, vec![bad]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_offenders_empty_when_none_below() {
|
||||||
|
let rep = IpReputation::new();
|
||||||
|
assert!(rep.offenders_below(-50).is_empty());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
284
src/xdp/diagnostics.rs
Normal file
284
src/xdp/diagnostics.rs
Normal file
|
|
@ -0,0 +1,284 @@
|
||||||
|
//! Диагностика окружения перед XDP-attach: ядро, BTF, драйвер NIC, привилегии.
|
||||||
|
//!
|
||||||
|
//! Основной источник боли при XDP — непонятные ошибки attach на неподдерживаемых
|
||||||
|
//! ядрах и драйверах. Модуль собирает [`EnvironmentReport`] ДО загрузки BPF-программы
|
||||||
|
//! и даёт человекочитаемый вердикт с предупреждениями.
|
||||||
|
|
||||||
|
use anyhow::{Result, bail};
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
/// Минимально поддерживаемая версия ядра: 5.15 LTS.
|
||||||
|
pub const MIN_KERNEL: KernelVersion = KernelVersion::new(5, 15, 0);
|
||||||
|
|
||||||
|
const OSRELEASE_PATH: &str = "/proc/sys/kernel/osrelease";
|
||||||
|
const BTF_PATH: &str = "/sys/kernel/btf/vmlinux";
|
||||||
|
const STATUS_PATH: &str = "/proc/self/status";
|
||||||
|
|
||||||
|
/// Драйверы с известной поддержкой native XDP (по данным xdpgeneric/native
|
||||||
|
/// матриц upstream-ядра). Отсутствие в списке не означает отсутствие поддержки,
|
||||||
|
/// но для таких драйверов предсказываем generic mode.
|
||||||
|
const NATIVE_XDP_DRIVERS: &[&str] = &[
|
||||||
|
"virtio_net",
|
||||||
|
"ixgbe",
|
||||||
|
"ixgbevf",
|
||||||
|
"i40e",
|
||||||
|
"iavf",
|
||||||
|
"ice",
|
||||||
|
"mlx4_core",
|
||||||
|
"mlx5_core",
|
||||||
|
"igb",
|
||||||
|
"igc",
|
||||||
|
"e1000e",
|
||||||
|
"vmxnet3",
|
||||||
|
"bnxt_en",
|
||||||
|
"nfp",
|
||||||
|
"sfc",
|
||||||
|
"ena",
|
||||||
|
"hv_netvsc",
|
||||||
|
"macvlan",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Версия ядра Linux, пригодная для сравнения (`major.minor.patch`).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
pub struct KernelVersion {
|
||||||
|
pub major: u16,
|
||||||
|
pub minor: u16,
|
||||||
|
pub patch: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KernelVersion {
|
||||||
|
#[must_use]
|
||||||
|
pub const fn new(major: u16, minor: u16, patch: u16) -> Self {
|
||||||
|
Self { major, minor, patch }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Парсит строку формата `uname -r`. Берёт первые три числовых компонента,
|
||||||
|
/// дистрибутивные суффиксы (`6.8.0-45-generic`, `5.15.0-rc2`) отбрасываются.
|
||||||
|
/// Строка без цифр не парсится.
|
||||||
|
#[must_use]
|
||||||
|
pub fn parse(release: &str) -> Option<Self> {
|
||||||
|
let mut nums = release
|
||||||
|
.split(|c: char| !c.is_ascii_digit())
|
||||||
|
.filter_map(|part| part.parse::<u16>().ok());
|
||||||
|
let major = nums.next()?;
|
||||||
|
let minor = nums.next().unwrap_or(0);
|
||||||
|
let patch = nums.next().unwrap_or(0);
|
||||||
|
Some(Self::new(major, minor, patch))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for KernelVersion {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Режим присоединения XDP-программы к интерфейсу.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum AttachMode {
|
||||||
|
/// Программа исполняется в контексте драйвера NIC — минимальные накладные расходы.
|
||||||
|
Native,
|
||||||
|
/// Программа вызывается из сетевого стека после ingress — работает везде, дороже по CPU.
|
||||||
|
Generic,
|
||||||
|
/// Аппаратная разгрузка в NIC — требует явного включения и поддержки железа,
|
||||||
|
/// автоматически не выбирается.
|
||||||
|
Offload,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for AttachMode {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.write_str(match self {
|
||||||
|
Self::Native => "native",
|
||||||
|
Self::Generic => "generic",
|
||||||
|
Self::Offload => "offload",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Абстракция над procfs/sysfs: реальная реализация — [`FilesystemProbe`],
|
||||||
|
/// в тестах — мок. Изолирует диагностику от реального ядра.
|
||||||
|
pub trait SystemProbe {
|
||||||
|
/// Читает файл целиком.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Проксирует ошибку чтения файла.
|
||||||
|
fn read_file(&self, path: &str) -> std::io::Result<String>;
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
fn path_exists(&self, path: &str) -> bool;
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
fn symlink_target(&self, path: &str) -> Option<String>;
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
fn effective_uid(&self) -> u32;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Реальная реализация [`SystemProbe`] поверх `std::fs`.
|
||||||
|
pub struct FilesystemProbe;
|
||||||
|
|
||||||
|
impl SystemProbe for FilesystemProbe {
|
||||||
|
fn read_file(&self, path: &str) -> std::io::Result<String> {
|
||||||
|
std::fs::read_to_string(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path_exists(&self, path: &str) -> bool {
|
||||||
|
std::path::Path::new(path).exists()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn symlink_target(&self, path: &str) -> Option<String> {
|
||||||
|
Some(std::fs::read_link(path).ok()?.to_string_lossy().into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Эвристика: эффективный uid берётся из `/proc/self/status`.
|
||||||
|
/// root почти всегда имеет CAP_BPF/CAP_NET_ADMIN (если явно не урезаны);
|
||||||
|
/// для не-root подтверждение возможно только попыткой attach.
|
||||||
|
/// Нечитаемый статус трактуем как непривилегированный процесс.
|
||||||
|
fn effective_uid(&self) -> u32 {
|
||||||
|
std::fs::read_to_string(STATUS_PATH)
|
||||||
|
.ok()
|
||||||
|
.and_then(|status| parse_euid(&status))
|
||||||
|
.unwrap_or(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_euid(status: &str) -> Option<u32> {
|
||||||
|
status.lines().find_map(|line| {
|
||||||
|
let mut fields = line.strip_prefix("Uid:")?.split_whitespace();
|
||||||
|
fields.nth(1)?.parse().ok()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn driver_from_link(target: &str) -> Option<String> {
|
||||||
|
let name = target.rsplit('/').next()?;
|
||||||
|
(!name.is_empty()).then(|| name.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Поддерживает ли драйвер native XDP (по таблице известных драйверов).
|
||||||
|
#[must_use]
|
||||||
|
pub fn driver_supports_native_xdp(driver: &str) -> bool {
|
||||||
|
NATIVE_XDP_DRIVERS.contains(&driver)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Структурированный отчёт о пригодности окружения для XDP.
|
||||||
|
pub struct EnvironmentReport {
|
||||||
|
pub kernel_version: Option<KernelVersion>,
|
||||||
|
pub btf_available: bool,
|
||||||
|
pub privileged: bool,
|
||||||
|
pub driver_name: Option<String>,
|
||||||
|
pub warnings: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EnvironmentReport {
|
||||||
|
/// Собирает отчёт через произвольную реализацию [`SystemProbe`].
|
||||||
|
#[must_use]
|
||||||
|
pub fn collect(probe: &dyn SystemProbe, interface: &str) -> Self {
|
||||||
|
let kernel_version = probe
|
||||||
|
.read_file(OSRELEASE_PATH)
|
||||||
|
.ok()
|
||||||
|
.and_then(|r| KernelVersion::parse(r.trim()));
|
||||||
|
let btf_available = probe.path_exists(BTF_PATH);
|
||||||
|
let privileged = probe.effective_uid() == 0;
|
||||||
|
let driver_link = probe.symlink_target(&format!("/sys/class/net/{interface}/device/driver"));
|
||||||
|
let driver_name = driver_link.as_deref().and_then(driver_from_link);
|
||||||
|
|
||||||
|
let mut warnings = Vec::new();
|
||||||
|
if !privileged {
|
||||||
|
warnings.push(
|
||||||
|
"процесс не от root: CAP_BPF/CAP_NET_ADMIN не подтверждены, \
|
||||||
|
attach скорее всего завершится EPERM"
|
||||||
|
.to_owned(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !btf_available {
|
||||||
|
warnings.push(format!(
|
||||||
|
"{BTF_PATH} недоступен: CO-RE релокации невозможны, переносимость BPF-программы ограничена"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if kernel_version.is_some_and(|v| v < KernelVersion::new(5, 11, 0)) {
|
||||||
|
warnings.push(
|
||||||
|
"ядро < 5.11: память BPF-карт ограничена RLIMIT_MEMLOCK — увеличьте `ulimit -l` или обновите ядро"
|
||||||
|
.to_owned(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if driver_name.is_none() {
|
||||||
|
warnings.push(format!(
|
||||||
|
"драйвер интерфейса '{interface}' не определён (нет device/driver symlink): ожидается generic mode"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Self {
|
||||||
|
kernel_version,
|
||||||
|
btf_available,
|
||||||
|
privileged,
|
||||||
|
driver_name,
|
||||||
|
warnings,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ожидаемый режим attach по таблице драйверов.
|
||||||
|
#[must_use]
|
||||||
|
pub fn attach_mode(&self) -> AttachMode {
|
||||||
|
match self.driver_name.as_deref() {
|
||||||
|
Some(driver) if driver_supports_native_xdp(driver) => AttachMode::Native,
|
||||||
|
_ => AttachMode::Generic,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Человекочитаемое объяснение вердикта.
|
||||||
|
#[must_use]
|
||||||
|
pub fn verdict(&self) -> String {
|
||||||
|
let kernel = self
|
||||||
|
.kernel_version
|
||||||
|
.map_or_else(|| "неизвестна".to_owned(), |v| v.to_string());
|
||||||
|
match (self.attach_mode(), self.driver_name.as_deref()) {
|
||||||
|
(AttachMode::Native, Some(driver)) => format!(
|
||||||
|
"ядро {kernel}: драйвер '{driver}' поддерживает native XDP — программа работает в драйвере, минимальные накладные расходы"
|
||||||
|
),
|
||||||
|
(_, driver) => format!(
|
||||||
|
"ядро {kernel}: драйвер '{}' не поддерживает native XDP → будет generic mode, CPU дороже",
|
||||||
|
driver.unwrap_or("неизвестный")
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fail-fast проверка минимальной версии ядра.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Версия ядра не определена или ниже [`MIN_KERNEL`].
|
||||||
|
pub fn validate(&self) -> Result<()> {
|
||||||
|
let Some(version) = self.kernel_version else {
|
||||||
|
bail!("не удалось определить версию ядра ({OSRELEASE_PATH}) — XDP attach отклонён");
|
||||||
|
};
|
||||||
|
if version < MIN_KERNEL {
|
||||||
|
bail!("ядро {version} ниже минимально поддерживаемой {MIN_KERNEL}: XDP attach отклонён, обновите ядро");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Предстартовая диагностика перед загрузкой XDP-программы:
|
||||||
|
/// структурный отчёт в лог, предупреждения, fail-fast на старом ядре.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// См. [`EnvironmentReport::validate`].
|
||||||
|
pub(crate) fn preflight(interface: &str) -> Result<()> {
|
||||||
|
let report = EnvironmentReport::collect(&FilesystemProbe, interface);
|
||||||
|
let kernel = report
|
||||||
|
.kernel_version
|
||||||
|
.map_or_else(|| "unknown".to_owned(), |v| v.to_string());
|
||||||
|
tracing::info!(
|
||||||
|
interface,
|
||||||
|
kernel = %kernel,
|
||||||
|
btf = report.btf_available,
|
||||||
|
privileged = report.privileged,
|
||||||
|
driver = report.driver_name.as_deref().unwrap_or("unknown"),
|
||||||
|
mode = %report.attach_mode(),
|
||||||
|
"XDP environment: {}",
|
||||||
|
report.verdict()
|
||||||
|
);
|
||||||
|
for warning in &report.warnings {
|
||||||
|
tracing::warn!(interface, "{warning}");
|
||||||
|
}
|
||||||
|
report.validate()
|
||||||
|
}
|
||||||
|
|
@ -25,6 +25,9 @@ impl XdpFilter {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn load(&mut self) -> Result<()> {
|
pub fn load(&mut self) -> Result<()> {
|
||||||
|
// Диагностика ДО загрузки: fail-fast на неподдерживаемом ядре/драйвере.
|
||||||
|
super::diagnostics::preflight(&self.interface)?;
|
||||||
|
|
||||||
let bpf_obj = include_bytes!(concat!(env!("OUT_DIR"), "/universal_filter.o"));
|
let bpf_obj = include_bytes!(concat!(env!("OUT_DIR"), "/universal_filter.o"));
|
||||||
let obj = ObjectBuilder::default()
|
let obj = ObjectBuilder::default()
|
||||||
.open_memory(bpf_obj)
|
.open_memory(bpf_obj)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,11 @@
|
||||||
mod stats;
|
mod stats;
|
||||||
pub use stats::XdpStats;
|
pub use stats::XdpStats;
|
||||||
|
|
||||||
|
mod diagnostics;
|
||||||
|
pub use diagnostics::{
|
||||||
|
AttachMode, EnvironmentReport, FilesystemProbe, KernelVersion, MIN_KERNEL, SystemProbe, driver_supports_native_xdp,
|
||||||
|
};
|
||||||
|
|
||||||
#[cfg(feature = "xdp")]
|
#[cfg(feature = "xdp")]
|
||||||
mod filter;
|
mod filter;
|
||||||
#[cfg(feature = "xdp")]
|
#[cfg(feature = "xdp")]
|
||||||
|
|
|
||||||
|
|
@ -61,3 +61,31 @@ fn rejects_invalid_whitelist_ip() {
|
||||||
let result = Config::parse_str("whitelist = [\"10.0.0.999\"]");
|
let result = Config::parse_str("whitelist = [\"10.0.0.999\"]");
|
||||||
assert!(result.is_err());
|
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
112
tests/traffic_intel.rs
Normal 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
134
tests/xdp_diagnostics.rs
Normal 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}");
|
||||||
|
}
|
||||||
|
|
@ -33,4 +33,12 @@ static volatile const __u64 G_UDP_WINDOW_NS = 1000000000ULL; // 1 sec
|
||||||
static volatile const __u8 G_FEATURE_SYN_THROTTLE = 1;
|
static volatile const __u8 G_FEATURE_SYN_THROTTLE = 1;
|
||||||
static volatile const __u8 G_FEATURE_EVENTS = 1;
|
static volatile const __u8 G_FEATURE_EVENTS = 1;
|
||||||
|
|
||||||
|
// ── RST-challenge (Oubliette liveness proof) ──
|
||||||
|
// 0 = disabled: single branch, zero cost for legitimate traffic.
|
||||||
|
// Secret is patched by the loader at attach time (default is a placeholder).
|
||||||
|
static volatile const __u8 G_SYN_CHALLENGE_ENABLED = 0;
|
||||||
|
static volatile const __u64 G_CHALLENGE_SECRET = 0xA5A55A5A5A5AA5A5ULL;
|
||||||
|
static volatile const __u32 G_CHALLENGE_TIMEOUT_MS = 3000; // pending TTL
|
||||||
|
static volatile const __u64 G_CHALLENGE_VERIFIED_TTL_NS = 300000000000ULL; // 5 min
|
||||||
|
|
||||||
#endif /* RAMPART_CONFIG_H */
|
#endif /* RAMPART_CONFIG_H */
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,30 @@ struct {
|
||||||
__type(value, struct throttle_entry);
|
__type(value, struct throttle_entry);
|
||||||
} udp_rate_limit SEC(".maps");
|
} udp_rate_limit SEC(".maps");
|
||||||
|
|
||||||
|
// ── RST-challenge pending state (challenge reflected, awaiting proof) ──
|
||||||
|
struct challenge_pending {
|
||||||
|
__u64 sent_at; // ktime_ns of the reflected SYN-ACK
|
||||||
|
__u32 marker; // our ISN (client may echo it as ack)
|
||||||
|
__u32 bad_ack; // bogus ack sent (live client echoes it as RST seq)
|
||||||
|
};
|
||||||
|
|
||||||
|
// 🔗 RST-challenge: verified sources (liveness proven) — sliding TTL via
|
||||||
|
// last_seen, LRU eviction as the hard cap
|
||||||
|
struct {
|
||||||
|
__uint(type, BPF_MAP_TYPE_LRU_HASH);
|
||||||
|
__uint(max_entries, 65536);
|
||||||
|
__type(key, __u32); // src_ip
|
||||||
|
__type(value, __u64); // last_seen (ktime_ns)
|
||||||
|
} challenge_verified SEC(".maps");
|
||||||
|
|
||||||
|
// 🔗 RST-challenge: outstanding challenges keyed by flow 4-tuple
|
||||||
|
struct {
|
||||||
|
__uint(type, BPF_MAP_TYPE_LRU_HASH);
|
||||||
|
__uint(max_entries, 16384);
|
||||||
|
__type(key, struct flow_key);
|
||||||
|
__type(value, struct challenge_pending);
|
||||||
|
} challenge_pending SEC(".maps");
|
||||||
|
|
||||||
// 🔗 Statistics (per-CPU, атомарные инкременты)
|
// 🔗 Statistics (per-CPU, атомарные инкременты)
|
||||||
#define STAT_TOTAL 0
|
#define STAT_TOTAL 0
|
||||||
#define STAT_TCP 1
|
#define STAT_TCP 1
|
||||||
|
|
@ -55,6 +79,9 @@ struct {
|
||||||
#define STAT_DROP 6
|
#define STAT_DROP 6
|
||||||
#define STAT_UDP 7
|
#define STAT_UDP 7
|
||||||
#define STAT_RATE_LIMIT 8
|
#define STAT_RATE_LIMIT 8
|
||||||
|
#define STAT_CHALLENGE_SENT 9
|
||||||
|
#define STAT_CHALLENGE_VERIFIED 10
|
||||||
|
#define STAT_CHALLENGE_FAILED 11
|
||||||
|
|
||||||
struct {
|
struct {
|
||||||
__uint(type, BPF_MAP_TYPE_PERCPU_ARRAY);
|
__uint(type, BPF_MAP_TYPE_PERCPU_ARRAY);
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,9 @@ static __always_inline void inc_whitelist(void) { inc_stat(STAT_WHITELIST); }
|
||||||
static __always_inline void inc_blacklist(void) { inc_stat(STAT_BLACKLIST); }
|
static __always_inline void inc_blacklist(void) { inc_stat(STAT_BLACKLIST); }
|
||||||
static __always_inline void inc_syn_throttle(void) { inc_stat(STAT_SYN_THROTTLE); }
|
static __always_inline void inc_syn_throttle(void) { inc_stat(STAT_SYN_THROTTLE); }
|
||||||
static __always_inline void inc_rate_limit(void) { inc_stat(STAT_RATE_LIMIT); }
|
static __always_inline void inc_rate_limit(void) { inc_stat(STAT_RATE_LIMIT); }
|
||||||
|
static __always_inline void inc_chal_sent(void) { inc_stat(STAT_CHALLENGE_SENT); }
|
||||||
|
static __always_inline void inc_chal_verified(void) { inc_stat(STAT_CHALLENGE_VERIFIED); }
|
||||||
|
static __always_inline void inc_chal_failed(void) { inc_stat(STAT_CHALLENGE_FAILED); }
|
||||||
static __always_inline void inc_pass(void) { inc_stat(STAT_PASS); }
|
static __always_inline void inc_pass(void) { inc_stat(STAT_PASS); }
|
||||||
static __always_inline void inc_drop(void) { inc_stat(STAT_DROP); }
|
static __always_inline void inc_drop(void) { inc_stat(STAT_DROP); }
|
||||||
|
|
||||||
|
|
|
||||||
221
xdp/core/syn_challenge.h
Normal file
221
xdp/core/syn_challenge.h
Normal file
|
|
@ -0,0 +1,221 @@
|
||||||
|
#ifndef RAMPART_SYN_CHALLENGE_H
|
||||||
|
#define RAMPART_SYN_CHALLENGE_H
|
||||||
|
|
||||||
|
// ── Rampart RST-challenge (Oubliette-style liveness proof) ──
|
||||||
|
//
|
||||||
|
// Protocol-agnostic source verification, compatible with any TCP client:
|
||||||
|
// 1. SYN from a source that is neither whitelisted nor verified:
|
||||||
|
// kernel reflects a SYN-ACK with a DELIBERATELY WRONG ack number
|
||||||
|
// (deterministic from the 4-tuple + secret) and drops the original SYN.
|
||||||
|
// 2. Spoofed source never reacts — it does not own the address.
|
||||||
|
// 3. Live client's TCP stack rejects the unacceptable SYN-ACK
|
||||||
|
// (RFC 9293 SYN-SENT: SEG.ACK outside [ISS+1, SND.NXT] → RST)
|
||||||
|
// echoing secret-derived values. Seeing that RST marks the source
|
||||||
|
// verified; the app reconnects and its next SYN reaches the backend.
|
||||||
|
//
|
||||||
|
// Pending challenges expire after G_CHALLENGE_TIMEOUT_MS (spoof = silence).
|
||||||
|
|
||||||
|
#include "common.h"
|
||||||
|
#include "config.h"
|
||||||
|
#include "maps.h"
|
||||||
|
#include "stats.h"
|
||||||
|
|
||||||
|
#define CHAL_CONTINUE (-1) // fall through to the normal filter path
|
||||||
|
|
||||||
|
// ── Secret-dependent 64-bit mixing (splitmix-style finalizer) ──
|
||||||
|
static __always_inline __u64 chal_mix64(__u64 h, __u64 v)
|
||||||
|
{
|
||||||
|
h ^= v;
|
||||||
|
h *= 0x9E3779B97F4A7C15ULL;
|
||||||
|
h ^= h >> 29;
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-flow challenge values; infeasible to forge without G_CHALLENGE_SECRET
|
||||||
|
static __always_inline __u64 chal_derive(const struct flow_key *k)
|
||||||
|
{
|
||||||
|
__u64 h = (__u64)G_CHALLENGE_SECRET ^ 0x5253544348414C53ULL;
|
||||||
|
h = chal_mix64(h, ((__u64)k->src_ip << 32) | k->dst_ip);
|
||||||
|
h = chal_mix64(h, ((__u64)k->src_port << 48) | ((__u64)k->dst_port << 32));
|
||||||
|
h *= 0xFF51AFD7ED558CCDULL;
|
||||||
|
h ^= h >> 32;
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Ones-complement helpers (both IPv4/TCP headers are 20 bytes = 10 words) ──
|
||||||
|
static __always_inline __u32 chal_sum10(const __u16 *w)
|
||||||
|
{
|
||||||
|
__u32 s = 0;
|
||||||
|
s += w[0]; s += w[1]; s += w[2]; s += w[3]; s += w[4];
|
||||||
|
s += w[5]; s += w[6]; s += w[7]; s += w[8]; s += w[9];
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
static __always_inline __u16 chal_csum_fold(__u32 sum)
|
||||||
|
{
|
||||||
|
sum = (sum >> 16) + (sum & 0xFFFF);
|
||||||
|
sum += sum >> 16;
|
||||||
|
return (__u16)~sum;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rewrite the packet in place into a SYN-ACK: MAC/IP/ports swapped,
|
||||||
|
// seq = marker, ack = bad_ack (guaranteed unacceptable for the client),
|
||||||
|
// clean 20-byte TCP header, IP checksum recomputed, TCP checksum rebuilt.
|
||||||
|
// All reads happen BEFORE any packet write (writes invalidate verifier
|
||||||
|
// bounds), the tail is trimmed so the peer sees no leftover option bytes.
|
||||||
|
static __always_inline void chal_build_synack(struct xdp_md *ctx,
|
||||||
|
struct ethhdr *eth, void *l3,
|
||||||
|
__u8 is_ipv6, struct tcphdr *tcp,
|
||||||
|
__u32 marker, __u32 bad_ack)
|
||||||
|
{
|
||||||
|
// ── Snapshot phase (packet reads) ──
|
||||||
|
__u16 ipw[10];
|
||||||
|
__u16 tcpw[10];
|
||||||
|
__u8 ms[ETH_ALEN], md[ETH_ALEN];
|
||||||
|
__u32 saddr[4] = {0}, daddr[4] = {0};
|
||||||
|
|
||||||
|
__builtin_memcpy(ms, eth->h_source, ETH_ALEN);
|
||||||
|
__builtin_memcpy(md, eth->h_dest, ETH_ALEN);
|
||||||
|
__builtin_memcpy(ipw, l3, 20);
|
||||||
|
__builtin_memcpy(tcpw, tcp, 20);
|
||||||
|
|
||||||
|
if (is_ipv6) {
|
||||||
|
__builtin_memcpy(saddr, &((struct ipv6hdr *)l3)->daddr, 16); // swapped
|
||||||
|
__builtin_memcpy(daddr, &((struct ipv6hdr *)l3)->saddr, 16);
|
||||||
|
} else {
|
||||||
|
saddr[0] = ((struct iphdr *)l3)->daddr;
|
||||||
|
daddr[0] = ((struct iphdr *)l3)->saddr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Compute images in registers ──
|
||||||
|
// IPv4 header: swap addresses, fresh TTL, recompute checksum
|
||||||
|
if (!is_ipv6) {
|
||||||
|
__u16 s0 = ipw[6], s1 = ipw[7];
|
||||||
|
ipw[6] = ipw[8];
|
||||||
|
ipw[7] = ipw[9];
|
||||||
|
ipw[8] = s0;
|
||||||
|
ipw[9] = s1;
|
||||||
|
((__u8 *)ipw)[8] = 64; // TTL
|
||||||
|
ipw[5] = 0; // checksum placeholder
|
||||||
|
ipw[5] = chal_csum_fold(chal_sum10(ipw));
|
||||||
|
}
|
||||||
|
|
||||||
|
// TCP header: swap ports, inject secret-derived seq/ack, SYN|ACK
|
||||||
|
__u16 nsport = tcpw[1]; // new source = old dest
|
||||||
|
__u16 ndport = tcpw[0]; // new dest = old source
|
||||||
|
tcpw[0] = nsport;
|
||||||
|
tcpw[1] = ndport;
|
||||||
|
tcpw[2] = (__u16)(marker >> 16);
|
||||||
|
tcpw[3] = (__u16)marker;
|
||||||
|
tcpw[4] = (__u16)(bad_ack >> 16);
|
||||||
|
tcpw[5] = (__u16)bad_ack;
|
||||||
|
tcpw[6] = bpf_htons(0x5012); // doff=5, flags SYN|ACK
|
||||||
|
tcpw[7] = bpf_htons(0xFFFF); // window
|
||||||
|
tcpw[8] = 0; // checksum placeholder
|
||||||
|
tcpw[9] = 0; // urg_ptr
|
||||||
|
|
||||||
|
// TCP checksum over pseudo-header + 20-byte header
|
||||||
|
__u32 sum = 0;
|
||||||
|
if (is_ipv6) {
|
||||||
|
__u16 *w = (__u16 *)saddr;
|
||||||
|
sum += w[0] + w[1] + w[2] + w[3] + w[4] + w[5] + w[6] + w[7];
|
||||||
|
w = (__u16 *)daddr;
|
||||||
|
sum += w[0] + w[1] + w[2] + w[3] + w[4] + w[5] + w[6] + w[7];
|
||||||
|
} else {
|
||||||
|
sum += ipw[6] + ipw[7] + ipw[8] + ipw[9]; // new IPv4 saddr/daddr
|
||||||
|
}
|
||||||
|
sum += bpf_htons(IPPROTO_TCP);
|
||||||
|
sum += bpf_htons((__u16)sizeof(struct tcphdr));
|
||||||
|
sum += chal_sum10(tcpw);
|
||||||
|
tcpw[8] = chal_csum_fold(sum);
|
||||||
|
|
||||||
|
// ── Write phase ──
|
||||||
|
__builtin_memcpy(eth->h_dest, ms, ETH_ALEN);
|
||||||
|
__builtin_memcpy(eth->h_source, md, ETH_ALEN);
|
||||||
|
if (is_ipv6) {
|
||||||
|
struct ipv6hdr *ip6 = l3;
|
||||||
|
__builtin_memcpy(&ip6->saddr, daddr, 16);
|
||||||
|
__builtin_memcpy(&ip6->daddr, saddr, 16);
|
||||||
|
ip6->hop_limit = 64;
|
||||||
|
} else {
|
||||||
|
__builtin_memcpy(l3, ipw, 20);
|
||||||
|
}
|
||||||
|
__builtin_memcpy(tcp, tcpw, 20);
|
||||||
|
|
||||||
|
// Trim to the bare header (drop SYN options/payload remnants)
|
||||||
|
long trim = (long)((void *)(long)ctx->data_end -
|
||||||
|
((void *)tcp + sizeof(struct tcphdr)));
|
||||||
|
if (trim > 0)
|
||||||
|
bpf_xdp_adjust_tail(ctx, -trim);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pure SYN from an unknown source: reflect the wrong SYN-ACK challenge ──
|
||||||
|
// Returns CHAL_CONTINUE (verified source — process normally), XDP_TX
|
||||||
|
// (challenge sent) or XDP_DROP.
|
||||||
|
static __always_inline int chal_on_syn(struct xdp_md *ctx, struct ethhdr *eth,
|
||||||
|
void *l3, __u8 is_ipv6,
|
||||||
|
struct tcphdr *tcp,
|
||||||
|
const struct flow_key *flow, __u64 now)
|
||||||
|
{
|
||||||
|
__u64 *v = bpf_map_lookup_elem(&challenge_verified, &flow->src_ip);
|
||||||
|
if (v) {
|
||||||
|
if (now - *v <= G_CHALLENGE_VERIFIED_TTL_NS) {
|
||||||
|
*v = now; // sliding TTL refresh
|
||||||
|
return CHAL_CONTINUE;
|
||||||
|
}
|
||||||
|
bpf_map_delete_elem(&challenge_verified, &flow->src_ip); // expired
|
||||||
|
}
|
||||||
|
|
||||||
|
__u64 h = chal_derive(flow);
|
||||||
|
__u32 marker = (__u32)(h >> 32); // our ISN
|
||||||
|
__u32 bad_ack = (__u32)h; // bogus ack the client must reject
|
||||||
|
|
||||||
|
__u32 expect = bpf_ntohl(tcp->seq) + 1;
|
||||||
|
if (bad_ack == expect) // ~2^-32 collision with a valid handshake — kill it
|
||||||
|
bad_ack = ~bad_ack;
|
||||||
|
|
||||||
|
struct challenge_pending p = {
|
||||||
|
.sent_at = now,
|
||||||
|
.marker = marker,
|
||||||
|
.bad_ack = bad_ack,
|
||||||
|
};
|
||||||
|
if (bpf_map_update_elem(&challenge_pending, flow, &p, BPF_ANY)) {
|
||||||
|
inc_drop(); // cannot track the challenge — fail closed
|
||||||
|
return XDP_DROP;
|
||||||
|
}
|
||||||
|
|
||||||
|
inc_chal_sent();
|
||||||
|
chal_build_synack(ctx, eth, l3, is_ipv6, tcp, marker, bad_ack);
|
||||||
|
return XDP_TX;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── RST answering a pending challenge: proof of life ──
|
||||||
|
// Accept only if the RST echoes a secret-derived value (our bad ack as its
|
||||||
|
// seq, or our marker+1 as its ack). Anything else fails the challenge.
|
||||||
|
static __always_inline int chal_on_rst(struct tcphdr *tcp,
|
||||||
|
const struct flow_key *flow, __u64 now)
|
||||||
|
{
|
||||||
|
struct challenge_pending *p = bpf_map_lookup_elem(&challenge_pending, flow);
|
||||||
|
if (!p)
|
||||||
|
return CHAL_CONTINUE;
|
||||||
|
|
||||||
|
__u64 timeout_ns = (__u64)G_CHALLENGE_TIMEOUT_MS * 1000000ULL;
|
||||||
|
__u32 seq = bpf_ntohl(tcp->seq);
|
||||||
|
__u32 ack = bpf_ntohl(tcp->ack_seq);
|
||||||
|
__u8 ok = (seq == p->bad_ack) ||
|
||||||
|
(tcp->ack && ack == p->marker + 1);
|
||||||
|
|
||||||
|
bpf_map_delete_elem(&challenge_pending, flow);
|
||||||
|
|
||||||
|
if (!ok || now - p->sent_at > timeout_ns) {
|
||||||
|
inc_chal_failed();
|
||||||
|
return XDP_DROP;
|
||||||
|
}
|
||||||
|
|
||||||
|
__u64 seen = now;
|
||||||
|
bpf_map_update_elem(&challenge_verified, &flow->src_ip, &seen, BPF_ANY);
|
||||||
|
inc_chal_verified();
|
||||||
|
return XDP_DROP; // client app will retry connect; next SYN passes
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif /* RAMPART_SYN_CHALLENGE_H */
|
||||||
|
|
@ -27,6 +27,7 @@
|
||||||
#include "config.h"
|
#include "config.h"
|
||||||
#include "stats.h"
|
#include "stats.h"
|
||||||
#include "prefix_stats.h"
|
#include "prefix_stats.h"
|
||||||
|
#include "syn_challenge.h"
|
||||||
#include "../hooks/hook_api.h"
|
#include "../hooks/hook_api.h"
|
||||||
|
|
||||||
char __license[] SEC("license") = "GPL";
|
char __license[] SEC("license") = "GPL";
|
||||||
|
|
@ -299,6 +300,15 @@ int rampart_universal_filter(struct xdp_md *ctx)
|
||||||
return XDP_DROP;
|
return XDP_DROP;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── RST-challenge: unverified source must prove liveness first ──
|
||||||
|
// (after throttle/blacklist, mirroring prefix_stats ordering)
|
||||||
|
if (G_SYN_CHALLENGE_ENABLED) {
|
||||||
|
int cv = chal_on_syn(ctx, eth, (void *)(eth + 1), is_ipv6,
|
||||||
|
tcp, &flow, now);
|
||||||
|
if (cv != CHAL_CONTINUE)
|
||||||
|
return cv;
|
||||||
|
}
|
||||||
|
|
||||||
// Count SYN only after throttle passed (don't count throttled SYNs)
|
// Count SYN only after throttle passed (don't count throttled SYNs)
|
||||||
update_prefix_stats(&pkey, 1, now);
|
update_prefix_stats(&pkey, 1, now);
|
||||||
|
|
||||||
|
|
@ -324,6 +334,13 @@ int rampart_universal_filter(struct xdp_md *ctx)
|
||||||
// pkt_count: all non-SYN TCP that survived blacklist/bypass/throttle
|
// pkt_count: all non-SYN TCP that survived blacklist/bypass/throttle
|
||||||
update_prefix_stats(&pkey, 0, now);
|
update_prefix_stats(&pkey, 0, now);
|
||||||
|
|
||||||
|
// ── RST-challenge proof: match a pending challenge before conntrack ──
|
||||||
|
if (G_SYN_CHALLENGE_ENABLED && tcp->rst) {
|
||||||
|
int cv = chal_on_rst(tcp, &flow, now);
|
||||||
|
if (cv != CHAL_CONTINUE)
|
||||||
|
return cv;
|
||||||
|
}
|
||||||
|
|
||||||
struct conntrack_entry *conn = bpf_map_lookup_elem(&conntrack_map, &flow);
|
struct conntrack_entry *conn = bpf_map_lookup_elem(&conntrack_map, &flow);
|
||||||
if (!conn) {
|
if (!conn) {
|
||||||
// Unknown connection — drop
|
// Unknown connection — drop
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue