feat!: universal redesign — drop Minecraft stack, single-crate architecture
- remove Java plugins (velocity/paper), dashboard, all MC-specific code
(handshake, death_code, varint, hostname-HMAC); available in history pre-v0.2
- merge crates/* into one package with src/bin/{rampart,rampart-manager,rampart-cli}
- ProtocolHandler trait + registry (no implementations yet), universal PoW kept
- XDP: universal L3/L4 filter (xdp/core/) + pluggable hook API (xdp/hooks/),
fix IPv6 saddr bug; clang build verified
- docs: bilingual knowledge base (docs/kb/: attacks x4, defense-levels,
practice x3), rewrite README/architecture for universal concept
- TODO.md v4.0: <=300-line module limit, competitor benchmark section (ref/)
- deploy/CI/docs cleanup: no MC references, new binary names
cargo build/clippy(-D warnings)/test green (55 tests)
This commit is contained in:
parent
0b53ed720b
commit
15f474486a
179 changed files with 5044 additions and 11519 deletions
69
src/cli/commands/blacklist.rs
Normal file
69
src/cli/commands/blacklist.rs
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AddResponse {
|
||||
status: String,
|
||||
target: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct BlacklistItem {
|
||||
target: String,
|
||||
reason: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct BlacklistResponse {
|
||||
items: Vec<BlacklistItem>,
|
||||
total: usize,
|
||||
}
|
||||
|
||||
pub async fn add(target: String, reason: Option<String>) -> anyhow::Result<()> {
|
||||
let manager_url = std::env::var("RAMPART_MANAGER").unwrap_or_else(|_| "http://localhost:8080".to_string());
|
||||
|
||||
let body = serde_json::json!({
|
||||
"target": target,
|
||||
"type": "ip",
|
||||
"reason": reason.unwrap_or_else(|| "manual".to_string()),
|
||||
});
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
match client
|
||||
.post(format!("{manager_url}/api/v1/blacklist"))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => {
|
||||
if let Ok(add_resp) = resp.json::<AddResponse>().await {
|
||||
println!("[OK] {}: {}", add_resp.status, add_resp.target);
|
||||
}
|
||||
},
|
||||
Err(e) => println!("[FAIL] {e}"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn remove(target: String) -> anyhow::Result<()> {
|
||||
println!("Removing {target} from blacklist...");
|
||||
println!("(not implemented in v0.1)");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn list() -> anyhow::Result<()> {
|
||||
let manager_url = std::env::var("RAMPART_MANAGER").unwrap_or_else(|_| "http://localhost:8080".to_string());
|
||||
|
||||
match reqwest::get(format!("{manager_url}/api/v1/blacklist")).await {
|
||||
Ok(resp) => {
|
||||
if let Ok(list) = resp.json::<BlacklistResponse>().await {
|
||||
println!("Blacklist ({} entries)", list.total);
|
||||
println!("----------------------");
|
||||
for item in &list.items {
|
||||
println!(" {} ({})", item.target, item.reason);
|
||||
}
|
||||
}
|
||||
},
|
||||
Err(e) => println!("[FAIL] {e}"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
24
src/cli/commands/config.rs
Normal file
24
src/cli/commands/config.rs
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
pub async fn run(key: Option<String>, value: Option<String>) -> anyhow::Result<()> {
|
||||
match (key, value) {
|
||||
(Some(k), Some(v)) => {
|
||||
println!("Setting {k} = {v}");
|
||||
Ok(())
|
||||
},
|
||||
(Some(k), None) => {
|
||||
println!("Reading config key: {k}");
|
||||
println!("(not implemented in v0.1)");
|
||||
Ok(())
|
||||
},
|
||||
(None, Some(_)) | (None, None) => {
|
||||
println!("Configuration");
|
||||
println!("=============\n");
|
||||
println!("Use: rampart config <key> [value]");
|
||||
println!();
|
||||
println!("Example keys:");
|
||||
println!(" workers.count");
|
||||
println!(" limits.rate_limit_login_pps");
|
||||
println!(" limits.max_connections_per_ip");
|
||||
Ok(())
|
||||
},
|
||||
}
|
||||
}
|
||||
45
src/cli/commands/doctor.rs
Normal file
45
src/cli/commands/doctor.rs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
pub async fn run() -> anyhow::Result<()> {
|
||||
println!("Rampart Diagnostics");
|
||||
println!("=====================\n");
|
||||
|
||||
let mut all_ok = true;
|
||||
|
||||
let manager_url = std::env::var("RAMPART_MANAGER").unwrap_or_else(|_| "http://localhost:8080".to_string());
|
||||
|
||||
match reqwest::get(format!("{manager_url}/api/v1/health")).await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
println!("[OK] Manager API");
|
||||
},
|
||||
_ => {
|
||||
println!("[FAIL] Manager API");
|
||||
all_ok = false;
|
||||
},
|
||||
}
|
||||
|
||||
match reqwest::get(format!("{manager_url}/api/v1/blacklist")).await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
println!("[OK] Blacklist API");
|
||||
},
|
||||
_ => {
|
||||
println!("[WARN] Blacklist API unavailable");
|
||||
},
|
||||
}
|
||||
|
||||
match reqwest::get(format!("{manager_url}/api/v1/servers")).await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
println!("[OK] Servers API");
|
||||
},
|
||||
_ => {
|
||||
println!("[WARN] Servers API unavailable");
|
||||
},
|
||||
}
|
||||
|
||||
println!();
|
||||
if all_ok {
|
||||
println!("All checks passed.");
|
||||
} else {
|
||||
println!("Some checks failed. Run with --verbose for details.");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
7
src/cli/commands/drain.rs
Normal file
7
src/cli/commands/drain.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
pub async fn run(node: &str) -> anyhow::Result<()> {
|
||||
println!("Draining node: {node}");
|
||||
println!("Waiting for active connections to drain...");
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
println!("Node {node} drained successfully.");
|
||||
Ok(())
|
||||
}
|
||||
11
src/cli/commands/emergency.rs
Normal file
11
src/cli/commands/emergency.rs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
pub async fn enable() -> anyhow::Result<()> {
|
||||
println!("Emergency mode ENABLED");
|
||||
println!("Only whitelisted IPs will be allowed through.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn disable() -> anyhow::Result<()> {
|
||||
println!("Emergency mode DISABLED");
|
||||
println!("Normal filtering resumed.");
|
||||
Ok(())
|
||||
}
|
||||
6
src/cli/commands/mod.rs
Normal file
6
src/cli/commands/mod.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
pub mod blacklist;
|
||||
pub mod config;
|
||||
pub mod doctor;
|
||||
pub mod drain;
|
||||
pub mod emergency;
|
||||
pub mod status;
|
||||
23
src/cli/commands/status.rs
Normal file
23
src/cli/commands/status.rs
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
pub async fn run() -> anyhow::Result<()> {
|
||||
println!("Rampart Status");
|
||||
println!("================\n");
|
||||
|
||||
let manager_url = std::env::var("RAMPART_MANAGER").unwrap_or_else(|_| "http://localhost:8080".to_string());
|
||||
|
||||
match reqwest::get(format!("{manager_url}/api/v1/health")).await {
|
||||
Ok(resp) => {
|
||||
if let Ok(body) = resp.json::<serde_json::Value>().await {
|
||||
println!(
|
||||
"Manager: {} (v{})",
|
||||
body["status"].as_str().unwrap_or("unknown"),
|
||||
body["version"].as_str().unwrap_or("?")
|
||||
);
|
||||
}
|
||||
},
|
||||
Err(e) => println!("Manager: unreachable ({e})"),
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("To check individual components, run: rampart doctor");
|
||||
Ok(())
|
||||
}
|
||||
3
src/cli/mod.rs
Normal file
3
src/cli/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
//! CLI-команды для bin/rampart-cli.
|
||||
#[allow(clippy::print_stdout, clippy::print_stderr)]
|
||||
pub mod commands;
|
||||
143
src/config/mod.rs
Normal file
143
src/config/mod.rs
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
//! Единый конфиг платформы Rampart.
|
||||
mod sections;
|
||||
|
||||
pub use sections::{
|
||||
BackendConfig, BanConfig, BindConfig, LimitsConfig, LoggingConfig, MetricsConfig, PowConfig, StoreConfig,
|
||||
WorkerConfig, XdpConfig,
|
||||
};
|
||||
|
||||
use serde::Deserialize;
|
||||
use std::fs;
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub struct Config {
|
||||
#[serde(default)]
|
||||
pub bind: BindConfig,
|
||||
#[serde(default)]
|
||||
pub backend: BackendConfig,
|
||||
#[serde(default)]
|
||||
pub workers: WorkerConfig,
|
||||
#[serde(default)]
|
||||
pub limits: LimitsConfig,
|
||||
#[serde(default)]
|
||||
pub ban: BanConfig,
|
||||
#[serde(default)]
|
||||
pub store: StoreConfig,
|
||||
#[serde(default)]
|
||||
pub xdp: XdpConfig,
|
||||
#[serde(default)]
|
||||
pub logging: LoggingConfig,
|
||||
#[serde(default)]
|
||||
pub metrics: MetricsConfig,
|
||||
#[serde(default)]
|
||||
pub pow: PowConfig,
|
||||
#[serde(default)]
|
||||
pub whitelist: Vec<String>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Парсит конфиг из TOML-строки и валидирует семантику.
|
||||
///
|
||||
/// # Errors
|
||||
/// Возвращает ошибку при невалидном TOML или нарушении инвариантов
|
||||
/// (пустой список upstream-бэкендов, некорректные адреса).
|
||||
pub fn parse_str(contents: &str) -> anyhow::Result<Self> {
|
||||
let config: Config = toml::from_str(contents)?;
|
||||
config.validate()?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Читает и парсит конфиг из файла.
|
||||
///
|
||||
/// # Errors
|
||||
/// Возвращает ошибку при невозможности прочитать файл или невалидном содержимом.
|
||||
pub fn from_file(path: &str) -> anyhow::Result<Self> {
|
||||
let contents = fs::read_to_string(path)?;
|
||||
Self::parse_str(&contents)
|
||||
}
|
||||
|
||||
fn validate(&self) -> anyhow::Result<()> {
|
||||
if self.backend.upstreams.is_empty() {
|
||||
anyhow::bail!("backend.upstreams must not be empty");
|
||||
}
|
||||
for upstream in &self.backend.upstreams {
|
||||
if upstream.parse::<std::net::SocketAddr>().is_err() {
|
||||
anyhow::bail!("invalid upstream address: {upstream}");
|
||||
}
|
||||
}
|
||||
for entry in &self.whitelist {
|
||||
if entry.parse::<std::net::IpAddr>().is_err() {
|
||||
anyhow::bail!("invalid whitelist entry: {entry}");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empty_config_uses_defaults() {
|
||||
let config = Config::parse_str("").expect("empty config should parse");
|
||||
assert_eq!(config.bind.address, "0.0.0.0");
|
||||
assert_eq!(config.bind.port, 25565);
|
||||
assert_eq!(config.backend.upstreams, vec!["127.0.0.1:25566"]);
|
||||
assert!(!config.pow.enabled);
|
||||
assert!(!config.xdp.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_config_parses() {
|
||||
let config = Config::parse_str(
|
||||
r#"
|
||||
[bind]
|
||||
address = "10.0.0.1"
|
||||
port = 443
|
||||
|
||||
[backend]
|
||||
upstreams = ["192.168.1.10:8080", "192.168.1.11:8080"]
|
||||
|
||||
[pow]
|
||||
enabled = true
|
||||
difficulty = 6
|
||||
|
||||
whitelist = ["203.0.113.7"]
|
||||
"#,
|
||||
)
|
||||
.expect("config should parse");
|
||||
assert_eq!(config.bind.port, 443);
|
||||
assert_eq!(config.backend.upstreams.len(), 2);
|
||||
assert!(config.pow.enabled);
|
||||
assert_eq!(config.pow.difficulty, 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_upstreams_rejected() {
|
||||
let result = Config::parse_str(
|
||||
r#"
|
||||
[backend]
|
||||
upstreams = []
|
||||
"#,
|
||||
);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_upstream_rejected() {
|
||||
let result = Config::parse_str(
|
||||
r#"
|
||||
[backend]
|
||||
upstreams = ["not-an-addr"]
|
||||
"#,
|
||||
);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_whitelist_entry_rejected() {
|
||||
let result = Config::parse_str("whitelist = [\"999.999.1.1\"]");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
228
src/config/sections.rs
Normal file
228
src/config/sections.rs
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
//! Секции единого конфига платформы.
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct BindConfig {
|
||||
#[serde(default = "default_bind_address")]
|
||||
pub address: String,
|
||||
#[serde(default = "default_bind_port")]
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
impl Default for BindConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
address: default_bind_address(),
|
||||
port: default_bind_port(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_bind_address() -> String {
|
||||
"0.0.0.0".to_string()
|
||||
}
|
||||
fn default_bind_port() -> u16 {
|
||||
25565
|
||||
}
|
||||
|
||||
/// Generic upstream-бэкенды (список addr:port) за edge-нодой.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct BackendConfig {
|
||||
#[serde(default = "default_upstreams")]
|
||||
pub upstreams: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for BackendConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
upstreams: default_upstreams(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_upstreams() -> Vec<String> {
|
||||
vec!["127.0.0.1:25566".to_string()]
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct WorkerConfig {
|
||||
#[serde(default = "default_worker_count")]
|
||||
pub count: usize,
|
||||
}
|
||||
|
||||
impl Default for WorkerConfig {
|
||||
fn default() -> Self {
|
||||
Self { count: 4 }
|
||||
}
|
||||
}
|
||||
|
||||
fn default_worker_count() -> usize {
|
||||
4
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct LimitsConfig {
|
||||
#[serde(default = "default_handshake_timeout")]
|
||||
pub handshake_timeout_secs: u64,
|
||||
#[serde(default = "default_max_connections_per_ip")]
|
||||
pub max_connections_per_ip: u32,
|
||||
#[serde(default = "default_rate_limit_pps")]
|
||||
pub rate_limit_pps: f64,
|
||||
#[serde(default = "default_rate_limit_burst")]
|
||||
pub rate_limit_burst: f64,
|
||||
}
|
||||
|
||||
impl Default for LimitsConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
handshake_timeout_secs: default_handshake_timeout(),
|
||||
max_connections_per_ip: default_max_connections_per_ip(),
|
||||
rate_limit_pps: default_rate_limit_pps(),
|
||||
rate_limit_burst: default_rate_limit_burst(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_handshake_timeout() -> u64 {
|
||||
5
|
||||
}
|
||||
fn default_max_connections_per_ip() -> u32 {
|
||||
10
|
||||
}
|
||||
fn default_rate_limit_pps() -> f64 {
|
||||
5.0
|
||||
}
|
||||
fn default_rate_limit_burst() -> f64 {
|
||||
10.0
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct BanConfig {
|
||||
#[serde(default = "default_ban_duration")]
|
||||
pub ban_duration_secs: u64,
|
||||
}
|
||||
|
||||
impl Default for BanConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
ban_duration_secs: default_ban_duration(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_ban_duration() -> u64 {
|
||||
3600
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct StoreConfig {
|
||||
pub redis_url: Option<String>,
|
||||
#[serde(default = "default_blacklist_cache_ttl")]
|
||||
pub blacklist_cache_ttl_secs: u64,
|
||||
pub clickhouse_url: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for StoreConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
redis_url: None,
|
||||
blacklist_cache_ttl_secs: default_blacklist_cache_ttl(),
|
||||
clickhouse_url: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_blacklist_cache_ttl() -> u64 {
|
||||
300
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct XdpConfig {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_xdp_interface")]
|
||||
pub interface: String,
|
||||
}
|
||||
|
||||
impl Default for XdpConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
interface: default_xdp_interface(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_xdp_interface() -> String {
|
||||
"eth0".to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct LoggingConfig {
|
||||
#[serde(default = "default_log_level")]
|
||||
pub level: String,
|
||||
#[serde(default = "default_log_format")]
|
||||
pub format: String,
|
||||
}
|
||||
|
||||
impl Default for LoggingConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
level: default_log_level(),
|
||||
format: default_log_format(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_log_level() -> String {
|
||||
"info".to_string()
|
||||
}
|
||||
fn default_log_format() -> String {
|
||||
"text".to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct MetricsConfig {
|
||||
#[serde(default = "default_metrics_enabled")]
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_metrics_port")]
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
impl Default for MetricsConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: default_metrics_enabled(),
|
||||
port: default_metrics_port(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_metrics_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
fn default_metrics_port() -> u16 {
|
||||
9090
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct PowConfig {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_pow_difficulty")]
|
||||
pub difficulty: u8,
|
||||
}
|
||||
|
||||
impl Default for PowConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
difficulty: default_pow_difficulty(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_pow_difficulty() -> u8 {
|
||||
4
|
||||
}
|
||||
260
src/engine/challenge.rs
Normal file
260
src/engine/challenge.rs
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
//! Универсальный SHA-256 hashcash: генерация challenge, решатель,
|
||||
//! верификатор и адаптивная сложность.
|
||||
|
||||
use crate::metrics;
|
||||
use rand::RngCore;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::VecDeque;
|
||||
use std::net::IpAddr;
|
||||
use std::time::{Duration, Instant};
|
||||
use subtle::ConstantTimeEq;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpStream;
|
||||
|
||||
const CHALLENGE_TTL_SECS: u64 = 30;
|
||||
const MAX_NONCE_LEN: usize = 64;
|
||||
const ALLOWED_HEX_PREFIX: [u8; 4] = *b"0123";
|
||||
|
||||
/// Активная PoW-задача, выданная одному соединению.
|
||||
pub struct Challenge {
|
||||
token: [u8; 32],
|
||||
created_at: Instant,
|
||||
difficulty: u8,
|
||||
used: bool,
|
||||
}
|
||||
|
||||
impl Challenge {
|
||||
#[must_use]
|
||||
pub fn generate(difficulty: u8) -> Self {
|
||||
let mut token = [0u8; 32];
|
||||
rand::thread_rng().fill_bytes(&mut token);
|
||||
Self {
|
||||
token,
|
||||
created_at: Instant::now(),
|
||||
difficulty,
|
||||
used: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_expired(&self) -> bool {
|
||||
self.created_at.elapsed().as_secs() >= CHALLENGE_TTL_SECS
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn challenge_string(&self) -> String {
|
||||
hex::encode(self.token)
|
||||
}
|
||||
|
||||
/// Проверяет nonce и помечает challenge использованным при успехе.
|
||||
#[must_use]
|
||||
pub fn verify(&mut self, nonce: &str) -> bool {
|
||||
if self.used || self.is_expired() || nonce.len() > MAX_NONCE_LEN {
|
||||
return false;
|
||||
}
|
||||
|
||||
let input = format!("{}{nonce}", self.challenge_string());
|
||||
let hash = Sha256::digest(input.as_bytes());
|
||||
let hex_hash = hex::encode(hash);
|
||||
let d = self.difficulty as usize;
|
||||
let ok = hex_hash.as_bytes().iter().take(d).all(|c| {
|
||||
let r = c.ct_eq(&ALLOWED_HEX_PREFIX[0])
|
||||
| c.ct_eq(&ALLOWED_HEX_PREFIX[1])
|
||||
| c.ct_eq(&ALLOWED_HEX_PREFIX[2])
|
||||
| c.ct_eq(&ALLOWED_HEX_PREFIX[3]);
|
||||
r.unwrap_u8() == 1
|
||||
});
|
||||
if ok {
|
||||
self.used = true;
|
||||
}
|
||||
ok
|
||||
}
|
||||
}
|
||||
|
||||
/// Переборный решатель hashcash (для клиентов и тестов).
|
||||
#[must_use]
|
||||
pub fn solve(challenge: &str, difficulty: u8) -> Option<String> {
|
||||
let d = difficulty as usize;
|
||||
for nonce in 0..u64::MAX {
|
||||
let nonce_str = nonce.to_string();
|
||||
let input = format!("{challenge}{nonce_str}");
|
||||
let hash = Sha256::digest(input.as_bytes());
|
||||
let hex_hash = hex::encode(hash);
|
||||
if hex_hash
|
||||
.as_bytes()
|
||||
.iter()
|
||||
.take(d)
|
||||
.all(|c| ALLOWED_HEX_PREFIX.contains(c))
|
||||
{
|
||||
return Some(nonce_str);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Адаптирует сложность PoW к текущему темпу подключений.
|
||||
pub struct DifficultyAdjuster {
|
||||
window: VecDeque<Instant>,
|
||||
min: u8,
|
||||
max: u8,
|
||||
current: u8,
|
||||
}
|
||||
|
||||
impl Default for DifficultyAdjuster {
|
||||
fn default() -> Self {
|
||||
Self::new(4, 10)
|
||||
}
|
||||
}
|
||||
|
||||
impl DifficultyAdjuster {
|
||||
#[must_use]
|
||||
pub fn new(min: u8, max: u8) -> Self {
|
||||
Self {
|
||||
window: VecDeque::new(),
|
||||
min: min.max(4),
|
||||
max: max.min(10),
|
||||
current: min.max(4),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_connection(&mut self) {
|
||||
let now = Instant::now();
|
||||
self.window.push_back(now);
|
||||
while let Some(&t) = self.window.front() {
|
||||
if now.duration_since(t).as_secs() >= 1 {
|
||||
self.window.pop_front();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let new_diff = self.compute_difficulty();
|
||||
if self.current != new_diff {
|
||||
tracing::info!(
|
||||
old = self.current,
|
||||
new = new_diff,
|
||||
window = self.window.len(),
|
||||
"pow: difficulty adjusted"
|
||||
);
|
||||
self.current = new_diff;
|
||||
metrics::POW_CURRENT_DIFFICULTY.set(self.current as i64);
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn current_difficulty(&self) -> u8 {
|
||||
metrics::POW_CURRENT_DIFFICULTY.set(self.current as i64);
|
||||
self.current
|
||||
}
|
||||
|
||||
fn compute_difficulty(&self) -> u8 {
|
||||
match self.window.len() {
|
||||
cps if cps > 500 => self.max.max(self.min),
|
||||
cps if cps > 200 => 8,
|
||||
cps if cps > 50 => 6,
|
||||
_ => self.min,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Проводит текстовый PoW-gate в потоке: выдаёт challenge и проверяет ответ.
|
||||
///
|
||||
/// # Errors
|
||||
/// Возвращает ошибку только при сбое ввода-вывода; неверное решение — `Ok(false)`.
|
||||
pub async fn enforce(stream: &mut TcpStream, peer_ip: IpAddr, difficulty: u8) -> anyhow::Result<bool> {
|
||||
if difficulty == 0 {
|
||||
tracing::debug!("pow: difficulty 0, skipping for {peer_ip}");
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let mut challenge = Challenge::generate(difficulty);
|
||||
let line = format!("{}\n", challenge.challenge_string());
|
||||
stream.write_all(line.as_bytes()).await?;
|
||||
|
||||
let mut buf = [0u8; MAX_NONCE_LEN + 1];
|
||||
let n = tokio::time::timeout(Duration::from_secs(10), stream.read(&mut buf)).await??;
|
||||
if n == 0 {
|
||||
tracing::debug!("pow: no response from {peer_ip}");
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let nonce = std::str::from_utf8(&buf[..n.min(MAX_NONCE_LEN)]).unwrap_or("").trim();
|
||||
if nonce.is_empty() || nonce.len() > MAX_NONCE_LEN {
|
||||
tracing::debug!("pow: invalid nonce from {peer_ip}");
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let valid = challenge.verify(nonce);
|
||||
tracing::debug!(
|
||||
"pow: verification {} for {peer_ip}",
|
||||
if valid { "passed" } else { "failed" }
|
||||
);
|
||||
Ok(valid)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn solve_then_verify_roundtrip() {
|
||||
let mut challenge = Challenge::generate(2);
|
||||
let nonce = solve(&challenge.challenge_string(), 2).expect("solver should find a nonce");
|
||||
assert!(challenge.verify(&nonce), "verifier must accept solver output");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_rejects_wrong_nonce() {
|
||||
let mut challenge = Challenge::generate(4);
|
||||
let challenge_str = challenge.challenge_string();
|
||||
|
||||
// Подбираем nonce, который независимо рассчитанный SHA-256
|
||||
// заведомо отклоняет — тест детерминирован.
|
||||
let mut wrong_nonce = None;
|
||||
for i in 0..10_000u32 {
|
||||
let candidate = format!("bad-{i}");
|
||||
let hex_hash = hex::encode(Sha256::digest(format!("{challenge_str}{candidate}").as_bytes()));
|
||||
if !hex_hash
|
||||
.as_bytes()
|
||||
.iter()
|
||||
.take(4)
|
||||
.all(|c| ALLOWED_HEX_PREFIX.contains(c))
|
||||
{
|
||||
wrong_nonce = Some(candidate);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let wrong_nonce = wrong_nonce.expect("a failing nonce must exist among candidates");
|
||||
assert!(!challenge.verify(&wrong_nonce));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_rejects_replay() {
|
||||
let mut challenge = Challenge::generate(2);
|
||||
let nonce = solve(&challenge.challenge_string(), 2).expect("solved");
|
||||
assert!(challenge.verify(&nonce));
|
||||
assert!(!challenge.verify(&nonce), "challenge must be single-use");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_rejects_oversized_nonce() {
|
||||
let mut challenge = Challenge::generate(2);
|
||||
let big_nonce = "0".repeat(MAX_NONCE_LEN + 1);
|
||||
assert!(!challenge.verify(&big_nonce));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjuster_raises_difficulty_under_load() {
|
||||
let mut adjuster = DifficultyAdjuster::new(4, 10);
|
||||
for _ in 0..600 {
|
||||
adjuster.record_connection();
|
||||
}
|
||||
assert_eq!(adjuster.current_difficulty(), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjuster_stays_minimal_when_idle() {
|
||||
let mut adjuster = DifficultyAdjuster::new(4, 10);
|
||||
adjuster.record_connection();
|
||||
assert_eq!(adjuster.current_difficulty(), 4);
|
||||
}
|
||||
}
|
||||
73
src/engine/listener.rs
Normal file
73
src/engine/listener.rs
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
//! Multi-worker TCP accept-loop с SO_REUSEPORT и graceful shutdown.
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::engine::tunnel::Gateway;
|
||||
use socket2::{Domain, Socket, Type};
|
||||
use std::sync::Arc;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::watch;
|
||||
|
||||
/// Запускает `workers.count` accept-loop'ов на одном адресе.
|
||||
///
|
||||
/// # Errors
|
||||
/// Возвращает ошибку при невозможности забиндить адрес или падении одного
|
||||
/// из accept-loop'ов.
|
||||
pub async fn run(config: Arc<Config>, gateway: Arc<Gateway>, shutdown: watch::Receiver<bool>) -> anyhow::Result<()> {
|
||||
let _ = &config;
|
||||
let addr: std::net::SocketAddr = format!("{}:{}", config.bind.address, config.bind.port).parse()?;
|
||||
let workers = config.workers.count.max(1);
|
||||
|
||||
let mut handles = Vec::with_capacity(workers);
|
||||
for _ in 0..workers {
|
||||
let listener = build_listener(addr)?;
|
||||
handles.push(tokio::spawn(accept_loop(
|
||||
listener,
|
||||
Arc::clone(&gateway),
|
||||
shutdown.clone(),
|
||||
)));
|
||||
}
|
||||
|
||||
for h in handles {
|
||||
h.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_listener(addr: std::net::SocketAddr) -> anyhow::Result<TcpListener> {
|
||||
let socket = Socket::new(Domain::IPV4, Type::STREAM, None)?;
|
||||
socket.set_reuse_port(true)?;
|
||||
socket.set_reuse_address(true)?;
|
||||
socket.set_nonblocking(true)?;
|
||||
socket.bind(&addr.into())?;
|
||||
socket.listen(65535)?;
|
||||
Ok(TcpListener::from_std(socket.into())?)
|
||||
}
|
||||
|
||||
async fn accept_loop(listener: TcpListener, gateway: Arc<Gateway>, mut shutdown: watch::Receiver<bool>) {
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = shutdown.changed() => {
|
||||
if *shutdown.borrow() {
|
||||
tracing::info!("shutdown signal received, stopping accept loop");
|
||||
return;
|
||||
}
|
||||
}
|
||||
result = listener.accept() => {
|
||||
let (stream, peer_addr) = match result {
|
||||
Ok(conn) => conn,
|
||||
Err(e) => {
|
||||
tracing::error!("accept error: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let gateway = Arc::clone(&gateway);
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = gateway.handle(stream, peer_addr).await {
|
||||
tracing::debug!("connection from {peer_addr}: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
5
src/engine/mod.rs
Normal file
5
src/engine/mod.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
//! Edge-движок: listener, generic TCP tunnel, PoW gate.
|
||||
|
||||
pub mod challenge;
|
||||
pub mod listener;
|
||||
pub mod tunnel;
|
||||
192
src/engine/tunnel.rs
Normal file
192
src/engine/tunnel.rs
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
//! Generic TCP proxy: фильтры входа → protocol handshake → upstream.
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::engine::challenge::{DifficultyAdjuster, enforce as enforce_pow};
|
||||
use crate::filter::blacklist::Blacklist;
|
||||
use crate::filter::rate_limit::RateLimiter;
|
||||
use crate::metrics;
|
||||
use crate::protocol::ProtocolRegistry;
|
||||
use crate::store::clickhouse::{ClickHouseEvent, ClickHouseWriter};
|
||||
use crate::traffic::reputation::IpReputation;
|
||||
use crate::xdp::XdpFilter;
|
||||
use std::collections::HashSet;
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
||||
use std::time::Duration;
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::sync::Mutex as TokioMutex;
|
||||
|
||||
/// Общее состояние edge-движка, разделяемое между accept-loop'ами.
|
||||
pub struct Gateway {
|
||||
pub config: Arc<Config>,
|
||||
pub rate_limiter: Arc<RateLimiter>,
|
||||
pub blacklist: Arc<Blacklist>,
|
||||
pub adjuster: Arc<Mutex<DifficultyAdjuster>>,
|
||||
pub whitelist: Arc<HashSet<IpAddr>>,
|
||||
pub reputation: Arc<IpReputation>,
|
||||
pub xdp: Option<Arc<Mutex<XdpFilter>>>,
|
||||
pub clickhouse: Option<Arc<TokioMutex<ClickHouseWriter>>>,
|
||||
pub allowed_1s: Arc<AtomicU64>,
|
||||
pub registry: Arc<ProtocolRegistry>,
|
||||
upstream_cursor: AtomicUsize,
|
||||
}
|
||||
|
||||
impl Gateway {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
config: Arc<Config>,
|
||||
rate_limiter: Arc<RateLimiter>,
|
||||
blacklist: Arc<Blacklist>,
|
||||
adjuster: Arc<Mutex<DifficultyAdjuster>>,
|
||||
whitelist: Arc<HashSet<IpAddr>>,
|
||||
reputation: Arc<IpReputation>,
|
||||
xdp: Option<Arc<Mutex<XdpFilter>>>,
|
||||
clickhouse: Option<Arc<TokioMutex<ClickHouseWriter>>>,
|
||||
allowed_1s: Arc<AtomicU64>,
|
||||
registry: Arc<ProtocolRegistry>,
|
||||
) -> Self {
|
||||
Self {
|
||||
config,
|
||||
rate_limiter,
|
||||
blacklist,
|
||||
adjuster,
|
||||
whitelist,
|
||||
reputation,
|
||||
xdp,
|
||||
clickhouse,
|
||||
allowed_1s,
|
||||
registry,
|
||||
upstream_cursor: AtomicUsize::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Полный конвейер обработки входящего соединения.
|
||||
///
|
||||
/// # Errors
|
||||
/// Возвращает ошибку только при сбое ввода-вывода апстрима; блокировки
|
||||
/// клиента ошибкой не считаются.
|
||||
pub async fn handle(&self, mut client: TcpStream, peer_addr: std::net::SocketAddr) -> anyhow::Result<()> {
|
||||
let peer_ip = peer_addr.ip();
|
||||
|
||||
if self.blacklist.is_blocked(peer_ip) {
|
||||
metrics::CONNECTIONS_TOTAL.with_label_values(&["blocked"]).inc();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if !self.pass_pow_gate(&mut client, peer_ip).await? {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if !self.rate_limiter.check(peer_ip) {
|
||||
self.block_rate_limit(peer_ip).await;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let handler = self.registry.primary()?;
|
||||
let upstream = match handler.handshake(&mut client).await {
|
||||
Ok(upstream) => upstream,
|
||||
Err(e) => {
|
||||
tracing::debug!("handshake rejected from {peer_addr}: {e}");
|
||||
metrics::CONNECTIONS_TOTAL.with_label_values(&["blocked"]).inc();
|
||||
return Ok(());
|
||||
},
|
||||
};
|
||||
|
||||
if !self.rate_limiter.check(peer_ip) {
|
||||
self.block_rate_limit(peer_ip).await;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
metrics::CONNECTIONS_TOTAL.with_label_values(&["allowed"]).inc();
|
||||
self.allowed_1s.fetch_add(1, Ordering::Relaxed);
|
||||
self.reputation.record_good(peer_ip);
|
||||
|
||||
let mut backend = TcpStream::connect(self.next_upstream(&upstream.addr)).await?;
|
||||
tokio::io::copy_bidirectional(&mut client, &mut backend).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn pass_pow_gate(&self, client: &mut TcpStream, peer_ip: IpAddr) -> anyhow::Result<bool> {
|
||||
let pow_config = &self.config.pow;
|
||||
if !(pow_config.enabled && pow_config.difficulty > 0) {
|
||||
return Ok(true);
|
||||
}
|
||||
if self.whitelist.contains(&peer_ip) {
|
||||
metrics::POW_CHALLENGES_TOTAL.with_label_values(&["skipped"]).inc();
|
||||
metrics::POW_CURRENT_DIFFICULTY.set(pow_config.difficulty as i64);
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let diff = {
|
||||
let mut adjuster = self.adjuster.lock().expect("adjuster lock poisoned");
|
||||
adjuster.record_connection();
|
||||
adjuster.current_difficulty()
|
||||
};
|
||||
let result = enforce_pow(client, peer_ip, diff).await?;
|
||||
if !result {
|
||||
metrics::POW_CHALLENGES_TOTAL.with_label_values(&["failed"]).inc();
|
||||
tracing::debug!("pow: failed for {peer_ip}, dropping connection");
|
||||
return Ok(false);
|
||||
}
|
||||
metrics::POW_CHALLENGES_TOTAL.with_label_values(&["passed"]).inc();
|
||||
metrics::POW_CURRENT_DIFFICULTY.set(diff as i64);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn next_upstream<'a>(&'a self, negotiated: &'a str) -> &'a str {
|
||||
let upstreams = &self.config.backend.upstreams;
|
||||
if upstreams.len() > 1 {
|
||||
let idx = self.upstream_cursor.fetch_add(1, Ordering::Relaxed) % upstreams.len();
|
||||
return upstreams[idx].as_str();
|
||||
}
|
||||
if let Some(first) = upstreams.first() {
|
||||
return first.as_str();
|
||||
}
|
||||
negotiated
|
||||
}
|
||||
|
||||
async fn block_rate_limit(&self, ip: IpAddr) {
|
||||
metrics::RATE_LIMIT_HITS.with_label_values(&["hit"]).inc();
|
||||
metrics::CONNECTIONS_TOTAL.with_label_values(&["blocked"]).inc();
|
||||
self.reputation.record_bad(ip);
|
||||
if self.reputation.score(ip) < -40 {
|
||||
let duration_secs = Duration::from_secs(self.config.ban.ban_duration_secs);
|
||||
self.blacklist.add(ip, duration_secs, "low_reputation");
|
||||
self.xdp_ban(ip, duration_secs.as_secs());
|
||||
self.push_event("block", ip, "low_reputation").await;
|
||||
tracing::info!("low reputation ban {ip}: rate-limit abuse");
|
||||
}
|
||||
}
|
||||
|
||||
fn xdp_ban(&self, ip: IpAddr, duration_secs: u64) {
|
||||
let Some(xdp) = &self.xdp else {
|
||||
return;
|
||||
};
|
||||
let IpAddr::V4(ip_v4) = ip else {
|
||||
return;
|
||||
};
|
||||
match xdp.lock().expect("xdp lock poisoned").ban_ip(ip_v4, duration_secs) {
|
||||
Ok(()) => tracing::debug!("xdp ban {ip_v4} for {duration_secs}s"),
|
||||
Err(e) => tracing::warn!("xdp ban failed for {ip_v4}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn push_event(&self, event_type: &str, ip: IpAddr, data_string: &str) {
|
||||
let Some(writer) = &self.clickhouse else {
|
||||
return;
|
||||
};
|
||||
let event = ClickHouseEvent {
|
||||
timestamp: chrono::Utc::now(),
|
||||
event_type: event_type.to_string(),
|
||||
ip: ip.to_string(),
|
||||
data_float: 0.0,
|
||||
data_int: 0,
|
||||
data_string: data_string.to_string(),
|
||||
};
|
||||
if let Err(e) = writer.lock().await.push(event).await {
|
||||
tracing::debug!("clickhouse push error: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
104
src/filter/blacklist.rs
Normal file
104
src/filter/blacklist.rs
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
use dashmap::DashMap;
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
struct BanEntry {
|
||||
expires: Instant,
|
||||
_reason: String,
|
||||
}
|
||||
|
||||
pub struct Blacklist {
|
||||
entries: Arc<DashMap<IpAddr, BanEntry>>,
|
||||
}
|
||||
|
||||
impl Default for Blacklist {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Blacklist {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
entries: Arc::new(DashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_blocked(&self, ip: IpAddr) -> bool {
|
||||
if let Some(entry) = self.entries.get(&ip) {
|
||||
if entry.expires > Instant::now() {
|
||||
return true;
|
||||
}
|
||||
drop(entry);
|
||||
self.entries.remove(&ip);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn add(&self, ip: IpAddr, duration: Duration, reason: &str) {
|
||||
self.entries.insert(
|
||||
ip,
|
||||
BanEntry {
|
||||
expires: Instant::now() + duration,
|
||||
_reason: reason.to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub fn remove(&self, ip: IpAddr) {
|
||||
self.entries.remove(&ip);
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
|
||||
pub fn clear_expired(&self) {
|
||||
self.entries.retain(|_, entry| entry.expires > Instant::now());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
|
||||
fn ip(octets: [u8; 4]) -> IpAddr {
|
||||
IpAddr::V4(Ipv4Addr::from(octets))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_blacklist_block() {
|
||||
let bl = Blacklist::new();
|
||||
bl.add(ip([1, 2, 3, 4]), Duration::from_secs(60), "test");
|
||||
assert!(bl.is_blocked(ip([1, 2, 3, 4])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_blacklist_not_blocked() {
|
||||
let bl = Blacklist::new();
|
||||
bl.add(ip([1, 2, 3, 4]), Duration::from_secs(60), "test");
|
||||
assert!(!bl.is_blocked(ip([5, 6, 7, 8])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_blacklist_expired() {
|
||||
let bl = Blacklist::new();
|
||||
bl.add(ip([1, 2, 3, 4]), Duration::from_millis(1), "test");
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
assert!(!bl.is_blocked(ip([1, 2, 3, 4])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_blacklist_remove() {
|
||||
let bl = Blacklist::new();
|
||||
bl.add(ip([1, 2, 3, 4]), Duration::from_secs(60), "test");
|
||||
bl.remove(ip([1, 2, 3, 4]));
|
||||
assert!(!bl.is_blocked(ip([1, 2, 3, 4])));
|
||||
}
|
||||
}
|
||||
35
src/filter/geo.rs
Normal file
35
src/filter/geo.rs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
#[cfg(feature = "geoip")]
|
||||
pub struct GeoIp {
|
||||
#[allow(dead_code)]
|
||||
reader: maxminddb::Reader<Vec<u8>>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "geoip")]
|
||||
impl GeoIp {
|
||||
pub fn new(db_path: &str) -> anyhow::Result<Self> {
|
||||
let reader = maxminddb::Reader::open_readfile(db_path)?;
|
||||
Ok(Self { reader })
|
||||
}
|
||||
}
|
||||
|
||||
pub enum IpCategory {
|
||||
Residential,
|
||||
Datacenter,
|
||||
Mobile,
|
||||
Vpn,
|
||||
Tor,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for IpCategory {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Residential => write!(f, "residential"),
|
||||
Self::Datacenter => write!(f, "datacenter"),
|
||||
Self::Mobile => write!(f, "mobile"),
|
||||
Self::Vpn => write!(f, "vpn"),
|
||||
Self::Tor => write!(f, "tor"),
|
||||
Self::Unknown => write!(f, "unknown"),
|
||||
}
|
||||
}
|
||||
}
|
||||
5
src/filter/mod.rs
Normal file
5
src/filter/mod.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
//! Фильтры входящего трафика.
|
||||
|
||||
pub mod blacklist;
|
||||
pub mod geo;
|
||||
pub mod rate_limit;
|
||||
152
src/filter/rate_limit.rs
Normal file
152
src/filter/rate_limit.rs
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
use dashmap::DashMap;
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const MAX_BUCKETS: usize = 1_000_000;
|
||||
const EVICTION_IDLE: Duration = Duration::from_secs(600);
|
||||
const SWEEP_INTERVAL: Duration = Duration::from_secs(60);
|
||||
|
||||
struct Bucket {
|
||||
tokens: f64,
|
||||
last_refill: Instant,
|
||||
last_access: Instant,
|
||||
}
|
||||
|
||||
pub struct RateLimiter {
|
||||
buckets: Arc<DashMap<IpAddr, Bucket>>,
|
||||
max_tokens: f64,
|
||||
refill_rate: f64,
|
||||
_refill_interval: Duration,
|
||||
epoch: Instant,
|
||||
last_sweep_elapsed: AtomicU64,
|
||||
eviction_idle: Duration,
|
||||
}
|
||||
|
||||
impl RateLimiter {
|
||||
pub fn new(rate_per_sec: f64, burst: f64) -> Self {
|
||||
Self::with_eviction(rate_per_sec, burst, EVICTION_IDLE)
|
||||
}
|
||||
|
||||
fn with_eviction(rate_per_sec: f64, burst: f64, eviction_idle: Duration) -> Self {
|
||||
Self {
|
||||
buckets: Arc::new(DashMap::new()),
|
||||
max_tokens: burst,
|
||||
refill_rate: rate_per_sec,
|
||||
_refill_interval: Duration::from_secs(1),
|
||||
epoch: Instant::now(),
|
||||
last_sweep_elapsed: AtomicU64::new(0),
|
||||
eviction_idle,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn check(&self, ip: IpAddr) -> bool {
|
||||
let now = Instant::now();
|
||||
let mut entry = self.buckets.entry(ip).or_insert_with(|| Bucket {
|
||||
tokens: self.max_tokens,
|
||||
last_refill: now,
|
||||
last_access: now,
|
||||
});
|
||||
|
||||
let elapsed = now.duration_since(entry.last_refill);
|
||||
let refill = elapsed.as_secs_f64() * self.refill_rate;
|
||||
entry.tokens = (entry.tokens + refill).min(self.max_tokens);
|
||||
entry.last_refill = now;
|
||||
entry.last_access = now;
|
||||
|
||||
if entry.tokens >= 1.0 {
|
||||
entry.tokens -= 1.0;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.buckets.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.buckets.is_empty()
|
||||
}
|
||||
|
||||
/// Эвиктит простаивающие бакеты. Запускается, когда бакетов больше
|
||||
/// MAX_BUCKETS либо по расписанию (раз в SWEEP_INTERVAL).
|
||||
pub fn sweep(&self) {
|
||||
let now = Instant::now();
|
||||
let elapsed_secs = now.duration_since(self.epoch).as_secs();
|
||||
let last = self.last_sweep_elapsed.load(Ordering::Relaxed);
|
||||
let due = last == 0 || elapsed_secs.saturating_sub(last) >= SWEEP_INTERVAL.as_secs();
|
||||
let over_cap = self.buckets.len() > MAX_BUCKETS;
|
||||
if !over_cap && !due {
|
||||
return;
|
||||
}
|
||||
self.buckets
|
||||
.retain(|_, b| now.duration_since(b.last_access) < self.eviction_idle);
|
||||
self.last_sweep_elapsed.store(elapsed_secs, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
|
||||
fn test_ip(octet: u8) -> IpAddr {
|
||||
IpAddr::V4(Ipv4Addr::new(10, 0, 0, octet))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rate_limit_under() {
|
||||
let limiter = RateLimiter::new(10.0, 10.0);
|
||||
assert!(limiter.check(test_ip(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rate_limit_over() {
|
||||
let limiter = RateLimiter::new(1.0, 1.0);
|
||||
assert!(limiter.check(test_ip(1)));
|
||||
assert!(!limiter.check(test_ip(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rate_limit_burst() {
|
||||
let limiter = RateLimiter::new(1.0, 5.0);
|
||||
for _ in 0..5 {
|
||||
assert!(limiter.check(test_ip(2)));
|
||||
}
|
||||
assert!(!limiter.check(test_ip(2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rate_limit_refill() {
|
||||
let limiter = RateLimiter::new(100.0, 1.0);
|
||||
assert!(limiter.check(test_ip(3)));
|
||||
assert!(!limiter.check(test_ip(3)));
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
assert!(limiter.check(test_ip(3)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sweep_removes_idle_keeps_active() {
|
||||
let limiter = RateLimiter::with_eviction(1.0, 10.0, Duration::from_millis(20));
|
||||
limiter.check(test_ip(1));
|
||||
limiter.check(test_ip(2));
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
limiter.check(test_ip(2));
|
||||
limiter.sweep();
|
||||
assert_eq!(limiter.len(), 1);
|
||||
assert!(!limiter.buckets.contains_key(&test_ip(1)));
|
||||
assert!(limiter.buckets.contains_key(&test_ip(2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sweep_does_not_remove_active() {
|
||||
let limiter = RateLimiter::with_eviction(1.0, 10.0, Duration::from_millis(50));
|
||||
limiter.check(test_ip(1));
|
||||
limiter.sweep();
|
||||
assert_eq!(limiter.len(), 1);
|
||||
assert!(limiter.buckets.contains_key(&test_ip(1)));
|
||||
}
|
||||
}
|
||||
11
src/lib.rs
Normal file
11
src/lib.rs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
//! Rampart — универсальная платформа сетевой защиты (L3/L4/L7).
|
||||
pub mod cli;
|
||||
pub mod config;
|
||||
pub mod engine;
|
||||
pub mod filter;
|
||||
pub mod manager;
|
||||
pub mod metrics;
|
||||
pub mod protocol;
|
||||
pub mod store;
|
||||
pub mod traffic;
|
||||
pub mod xdp;
|
||||
104
src/manager/api/auth.rs
Normal file
104
src/manager/api/auth.rs
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
use crate::manager::AppState;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{ConnectInfo, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use dashmap::DashMap;
|
||||
use serde::Deserialize;
|
||||
use std::{
|
||||
net::{IpAddr, SocketAddr},
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use subtle::ConstantTimeEq;
|
||||
|
||||
const LOGIN_WINDOW: Duration = Duration::from_secs(60);
|
||||
const LOGIN_MAX_ATTEMPTS: u32 = 5;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct LoginRequest {
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
pub async fn login(
|
||||
State(state): State<Arc<AppState>>,
|
||||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||
Json(req): Json<LoginRequest>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
|
||||
if !allow_login_attempt(&state.login_limiter, addr.ip()) {
|
||||
return Err((
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
Json(serde_json::json!({"error": "too many requests"})),
|
||||
));
|
||||
}
|
||||
|
||||
if !verify_password(&req.password, &state.api_password) {
|
||||
return Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(serde_json::json!({"error": "invalid password"})),
|
||||
));
|
||||
}
|
||||
|
||||
match crate::manager::auth::create_token(&state.jwt_secret, state.jwt_expiration, &state.jwt_audience) {
|
||||
Ok(token) => Ok(Json(serde_json::json!({"token": token}))),
|
||||
Err(_) => Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": "token creation failed"})),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn verify_password(provided: &str, expected: &str) -> bool {
|
||||
let provided = provided.as_bytes();
|
||||
let expected = expected.as_bytes();
|
||||
let len_match = (provided.len() as u64).ct_eq(&(expected.len() as u64));
|
||||
let min_len = provided.len().min(expected.len());
|
||||
let bytes_match = provided[..min_len].ct_eq(&expected[..min_len]);
|
||||
bool::from(len_match & bytes_match)
|
||||
}
|
||||
|
||||
fn allow_login_attempt(limiter: &DashMap<IpAddr, (Instant, u32)>, ip: IpAddr) -> bool {
|
||||
let now = Instant::now();
|
||||
let mut slot = limiter.entry(ip).or_insert((now, 0));
|
||||
let (last_reset, attempts) = &mut *slot;
|
||||
if now.duration_since(*last_reset) >= LOGIN_WINDOW {
|
||||
*last_reset = now;
|
||||
*attempts = 1;
|
||||
} else if *attempts >= LOGIN_MAX_ATTEMPTS {
|
||||
return false;
|
||||
} else {
|
||||
*attempts += 1;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn verify_password_matches() {
|
||||
assert!(verify_password("s3cret-pass", "s3cret-pass"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_password_rejects_wrong() {
|
||||
assert!(!verify_password("wrong-pass", "s3cret-pass"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_password_rejects_different_length() {
|
||||
assert!(!verify_password("short", "a-longer-password"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allow_login_attempt_respects_limit() {
|
||||
let limiter = DashMap::new();
|
||||
let ip = IpAddr::from([127, 0, 0, 1]);
|
||||
for _ in 0..LOGIN_MAX_ATTEMPTS {
|
||||
assert!(allow_login_attempt(&limiter, ip));
|
||||
}
|
||||
assert!(!allow_login_attempt(&limiter, ip));
|
||||
}
|
||||
}
|
||||
94
src/manager/api/blacklist.rs
Normal file
94
src/manager/api/blacklist.rs
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
use crate::manager::AppState;
|
||||
use axum::{Json, extract::State};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct BlacklistEntry {
|
||||
pub target: String,
|
||||
#[serde(rename = "type")]
|
||||
pub entry_type: String,
|
||||
pub reason: String,
|
||||
pub created_at: String,
|
||||
pub expires_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct BlacklistResponse {
|
||||
pub items: Vec<BlacklistEntry>,
|
||||
pub total: usize,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct AddBlacklistRequest {
|
||||
pub target: String,
|
||||
#[serde(rename = "type")]
|
||||
pub entry_type: String,
|
||||
pub reason: String,
|
||||
pub duration_secs: Option<u64>,
|
||||
}
|
||||
|
||||
pub async fn list_blacklist(State(state): State<Arc<AppState>>) -> Json<BlacklistResponse> {
|
||||
let mut conn = match state.redis_client.get_multiplexed_async_connection().await {
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
return Json(BlacklistResponse {
|
||||
items: vec![],
|
||||
total: 0,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
let members: Vec<String> = match redis::cmd("SMEMBERS")
|
||||
.arg("rampart:blacklist")
|
||||
.query_async(&mut conn)
|
||||
.await
|
||||
{
|
||||
Ok(m) => m,
|
||||
Err(_) => {
|
||||
return Json(BlacklistResponse {
|
||||
items: vec![],
|
||||
total: 0,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
let items: Vec<BlacklistEntry> = members
|
||||
.into_iter()
|
||||
.map(|target| BlacklistEntry {
|
||||
target,
|
||||
entry_type: "ip".to_string(),
|
||||
reason: "manual".to_string(),
|
||||
created_at: chrono::Utc::now().to_rfc3339(),
|
||||
expires_at: None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let total = items.len();
|
||||
Json(BlacklistResponse { items, total })
|
||||
}
|
||||
|
||||
pub async fn add_blacklist(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<AddBlacklistRequest>,
|
||||
) -> Json<serde_json::Value> {
|
||||
let mut conn = match state.redis_client.get_multiplexed_async_connection().await {
|
||||
Ok(c) => c,
|
||||
Err(_) => return Json(serde_json::json!({"error": "redis unavailable"})),
|
||||
};
|
||||
|
||||
let _: () = redis::cmd("SADD")
|
||||
.arg("rampart:blacklist")
|
||||
.arg(&req.target)
|
||||
.query_async(&mut conn)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
tracing::info!("Added to blacklist: {} ({})", req.target, req.reason);
|
||||
|
||||
Json(serde_json::json!({
|
||||
"status": "added",
|
||||
"target": req.target,
|
||||
"reason": req.reason
|
||||
}))
|
||||
}
|
||||
18
src/manager/api/health.rs
Normal file
18
src/manager/api/health.rs
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
use crate::manager::AppState;
|
||||
use axum::Json;
|
||||
use axum::extract::State;
|
||||
use serde::Serialize;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct HealthResponse {
|
||||
pub status: String,
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
pub async fn health_check(State(_state): State<Arc<AppState>>) -> Json<HealthResponse> {
|
||||
Json(HealthResponse {
|
||||
status: "healthy".to_string(),
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
})
|
||||
}
|
||||
5
src/manager/api/mod.rs
Normal file
5
src/manager/api/mod.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
pub mod auth;
|
||||
pub mod blacklist;
|
||||
pub mod health;
|
||||
pub mod nodes;
|
||||
pub mod servers;
|
||||
43
src/manager/api/nodes.rs
Normal file
43
src/manager/api/nodes.rs
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
use crate::manager::AppState;
|
||||
use axum::{Json, extract::State};
|
||||
use redis::AsyncCommands;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct NodeInfo {
|
||||
pub id: String,
|
||||
pub role: String,
|
||||
pub ip: String,
|
||||
pub status: String,
|
||||
pub last_heartbeat: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct NodesResponse {
|
||||
pub nodes: Vec<NodeInfo>,
|
||||
}
|
||||
|
||||
pub async fn list_nodes(State(state): State<Arc<AppState>>) -> Json<NodesResponse> {
|
||||
let mut conn = match state.redis_client.get_multiplexed_async_connection().await {
|
||||
Ok(c) => c,
|
||||
Err(_) => return Json(NodesResponse { nodes: vec![] }),
|
||||
};
|
||||
|
||||
let keys: Vec<String> = match redis::cmd("KEYS").arg("rampart:nodes:*").query_async(&mut conn).await {
|
||||
Ok(k) => k,
|
||||
Err(_) => return Json(NodesResponse { nodes: vec![] }),
|
||||
};
|
||||
|
||||
let mut nodes = Vec::with_capacity(keys.len());
|
||||
for key in &keys {
|
||||
let raw: Option<String> = conn.get(key).await.unwrap_or(None);
|
||||
if let Some(json) = raw
|
||||
&& let Ok(node) = serde_json::from_str::<NodeInfo>(&json)
|
||||
{
|
||||
nodes.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
Json(NodesResponse { nodes })
|
||||
}
|
||||
44
src/manager/api/servers.rs
Normal file
44
src/manager/api/servers.rs
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
use crate::manager::AppState;
|
||||
use axum::{Json, extract::State};
|
||||
use redis::AsyncCommands;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ServerEntry {
|
||||
pub name: String,
|
||||
#[serde(rename = "type")]
|
||||
pub server_type: String,
|
||||
pub ip: String,
|
||||
pub port: u16,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ServersResponse {
|
||||
pub servers: Vec<ServerEntry>,
|
||||
}
|
||||
|
||||
pub async fn list_servers(State(state): State<Arc<AppState>>) -> Json<ServersResponse> {
|
||||
let mut conn = match state.redis_client.get_multiplexed_async_connection().await {
|
||||
Ok(c) => c,
|
||||
Err(_) => return Json(ServersResponse { servers: vec![] }),
|
||||
};
|
||||
|
||||
let keys: Vec<String> = match redis::cmd("KEYS").arg("rampart:servers:*").query_async(&mut conn).await {
|
||||
Ok(k) => k,
|
||||
Err(_) => return Json(ServersResponse { servers: vec![] }),
|
||||
};
|
||||
|
||||
let mut servers = Vec::with_capacity(keys.len());
|
||||
for key in &keys {
|
||||
let raw: Option<String> = conn.get(key).await.unwrap_or(None);
|
||||
if let Some(json) = raw
|
||||
&& let Ok(server) = serde_json::from_str::<ServerEntry>(&json)
|
||||
{
|
||||
servers.push(server);
|
||||
}
|
||||
}
|
||||
|
||||
Json(ServersResponse { servers })
|
||||
}
|
||||
111
src/manager/auth.rs
Normal file
111
src/manager/auth.rs
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
use axum::Json;
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use axum::middleware::Next;
|
||||
use axum::response::Response;
|
||||
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Claims {
|
||||
pub sub: String,
|
||||
pub aud: String,
|
||||
pub role: String,
|
||||
pub exp: usize,
|
||||
pub iat: usize,
|
||||
}
|
||||
|
||||
pub fn create_token(secret: &str, expiration: u64, audience: &str) -> Result<String, jsonwebtoken::errors::Error> {
|
||||
let now = chrono::Utc::now().timestamp() as usize;
|
||||
let claims = Claims {
|
||||
sub: "rampart-admin".to_string(),
|
||||
aud: audience.to_string(),
|
||||
role: "admin".to_string(),
|
||||
exp: now + expiration as usize,
|
||||
iat: now,
|
||||
};
|
||||
encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_ref()))
|
||||
}
|
||||
|
||||
pub fn verify_token(token: &str, secret: &str, audience: &str) -> Result<Claims, jsonwebtoken::errors::Error> {
|
||||
let mut validation = Validation::default();
|
||||
validation.set_audience(&[audience]);
|
||||
let token_data = decode::<Claims>(token, &DecodingKey::from_secret(secret.as_ref()), &validation)?;
|
||||
Ok(token_data.claims)
|
||||
}
|
||||
|
||||
pub async fn auth_middleware(
|
||||
request: Request<Body>,
|
||||
next: Next,
|
||||
) -> Result<Response, (StatusCode, Json<serde_json::Value>)> {
|
||||
let auth_header = request
|
||||
.headers()
|
||||
.get(axum::http::header::AUTHORIZATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.strip_prefix("Bearer "));
|
||||
|
||||
let token = match auth_header {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
return Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(serde_json::json!({"error": "unauthorized"})),
|
||||
));
|
||||
},
|
||||
};
|
||||
|
||||
let state = match request.extensions().get::<Arc<crate::manager::AppState>>() {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": "internal error"})),
|
||||
));
|
||||
},
|
||||
};
|
||||
|
||||
if verify_token(token, &state.jwt_secret, &state.jwt_audience).is_err() {
|
||||
return Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(serde_json::json!({"error": "unauthorized"})),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(next.run(request).await)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn valid_secret() -> String {
|
||||
"this-is-a-test-secret-32-bytes-long!".to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_verify_roundtrip_passes() {
|
||||
let secret = valid_secret();
|
||||
let token = create_token(&secret, 3600, "rampart").expect("token creation should succeed");
|
||||
let claims = verify_token(&token, &secret, "rampart").expect("verification should succeed");
|
||||
assert_eq!(claims.sub, "rampart-admin");
|
||||
assert_eq!(claims.aud, "rampart");
|
||||
assert_eq!(claims.role, "admin");
|
||||
assert!(claims.exp > claims.iat);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_rejects_wrong_audience() {
|
||||
let secret = valid_secret();
|
||||
let token = create_token(&secret, 3600, "rampart").expect("token creation should succeed");
|
||||
assert!(verify_token(&token, &secret, "other").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_rejects_wrong_secret() {
|
||||
let secret = valid_secret();
|
||||
let other_secret = "another-test-secret-also-32-bytes-long!".to_string();
|
||||
let token = create_token(&secret, 3600, "rampart").expect("token creation should succeed");
|
||||
assert!(verify_token(&token, &other_secret, "rampart").is_err());
|
||||
}
|
||||
}
|
||||
21
src/manager/mod.rs
Normal file
21
src/manager/mod.rs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
//! Management-плоскость Rampart: REST API, аутентификация, синхронизация узлов.
|
||||
pub mod api;
|
||||
pub mod auth;
|
||||
pub mod sync;
|
||||
|
||||
use dashmap::DashMap;
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
/// Общее состояние management-API.
|
||||
pub struct AppState {
|
||||
pub redis_client: redis::Client,
|
||||
pub jwt_secret: String,
|
||||
pub jwt_audience: String,
|
||||
pub jwt_expiration: u64,
|
||||
pub api_password: String,
|
||||
pub login_limiter: DashMap<IpAddr, (Instant, u32)>,
|
||||
}
|
||||
|
||||
pub type SharedState = Arc<AppState>;
|
||||
44
src/manager/sync/heartbeat.rs
Normal file
44
src/manager/sync/heartbeat.rs
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
use crate::manager::AppState;
|
||||
use redis::AsyncCommands;
|
||||
use std::sync::Arc;
|
||||
use tokio::time::{Duration, interval};
|
||||
|
||||
pub async fn start_heartbeat_check(state: Arc<AppState>) {
|
||||
let mut ticker = interval(Duration::from_secs(30));
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
if let Err(e) = check_nodes(&state).await {
|
||||
tracing::warn!("heartbeat check failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn check_nodes(state: &AppState) -> anyhow::Result<()> {
|
||||
let mut conn = state.redis_client.get_multiplexed_async_connection().await?;
|
||||
let keys: Vec<String> = redis::cmd("KEYS").arg("rampart:nodes:*").query_async(&mut conn).await?;
|
||||
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
|
||||
for key in &keys {
|
||||
let raw: Option<String> = conn.get(key).await?;
|
||||
if let Some(json) = raw
|
||||
&& let Ok(mut node) = serde_json::from_str::<serde_json::Value>(&json)
|
||||
{
|
||||
let hb = node["last_heartbeat"]
|
||||
.as_str()
|
||||
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
|
||||
.map(|t| t.timestamp())
|
||||
.unwrap_or(0);
|
||||
if now - hb > 60 {
|
||||
if let Some(obj) = node.as_object_mut() {
|
||||
obj.insert("status".to_string(), serde_json::Value::String("offline".to_string()));
|
||||
if let Ok(updated) = serde_json::to_string(&node) {
|
||||
let _: () = conn.set(key.as_str(), updated).await.unwrap_or_default();
|
||||
}
|
||||
}
|
||||
tracing::warn!("Node {key} is offline (heartbeat expired)");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
1
src/manager/sync/mod.rs
Normal file
1
src/manager/sync/mod.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
pub mod heartbeat;
|
||||
69
src/metrics.rs
Normal file
69
src/metrics.rs
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
use prometheus::{Encoder, IntCounterVec, IntGauge, register_int_counter_vec, register_int_gauge};
|
||||
use std::sync::LazyLock;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
pub static CONNECTIONS_TOTAL: LazyLock<IntCounterVec> = LazyLock::new(|| {
|
||||
register_int_counter_vec!("rampart_connections_total", "Total connections handled", &["result"])
|
||||
.expect("CONNECTIONS_TOTAL")
|
||||
});
|
||||
|
||||
pub static RATE_LIMIT_HITS: LazyLock<IntCounterVec> = LazyLock::new(|| {
|
||||
register_int_counter_vec!("rampart_rate_limit_hits", "Rate limit hits", &["action"]).expect("RATE_LIMIT_HITS")
|
||||
});
|
||||
|
||||
pub static POW_CHALLENGES_TOTAL: LazyLock<IntCounterVec> = LazyLock::new(|| {
|
||||
register_int_counter_vec!("rampart_pow_challenges_total", "PoW challenges issued", &["result"])
|
||||
.expect("POW_CHALLENGES_TOTAL")
|
||||
});
|
||||
|
||||
pub static POW_CURRENT_DIFFICULTY: LazyLock<IntGauge> = LazyLock::new(|| {
|
||||
register_int_gauge!("rampart_pow_current_difficulty", "Current PoW difficulty").expect("POW_CURRENT_DIFFICULTY")
|
||||
});
|
||||
|
||||
pub static ATTACK_STATUS: LazyLock<IntGauge> = LazyLock::new(|| {
|
||||
register_int_gauge!(
|
||||
"rampart_attack_status",
|
||||
"Attack detector status (0=normal, 1=suspicious, 2=under attack)"
|
||||
)
|
||||
.expect("ATTACK_STATUS")
|
||||
});
|
||||
|
||||
/// Отдаёт Prometheus-метрики по голому HTTP/0.9-совместимому ответу.
|
||||
pub async fn run_metrics_server(addr: &str) {
|
||||
let listener = match TcpListener::bind(addr).await {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to bind metrics server: {e}");
|
||||
return;
|
||||
},
|
||||
};
|
||||
loop {
|
||||
let (mut stream, _) = match listener.accept().await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
tracing::error!("Metrics accept error: {e}");
|
||||
continue;
|
||||
},
|
||||
};
|
||||
tokio::spawn(async move {
|
||||
let mut buf = [0u8; 1024];
|
||||
if stream.read(&mut buf).await.is_err() {
|
||||
return;
|
||||
}
|
||||
let metric_families = prometheus::gather();
|
||||
let encoder = prometheus::TextEncoder::new();
|
||||
let mut payload = Vec::new();
|
||||
if encoder.encode(&metric_families, &mut payload).is_err() {
|
||||
return;
|
||||
}
|
||||
let header = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: text/plain; version=0.0.4\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
payload.len()
|
||||
);
|
||||
let mut response = header.into_bytes();
|
||||
response.extend_from_slice(&payload);
|
||||
let _ = stream.write_all(&response).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
130
src/protocol/mod.rs
Normal file
130
src/protocol/mod.rs
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use tokio::net::TcpStream;
|
||||
|
||||
/// Результат успешного protocol-handshake: адрес upstream-бэкенда,
|
||||
/// на который нужно проксировать соединение.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Upstream {
|
||||
pub addr: String,
|
||||
}
|
||||
|
||||
impl Upstream {
|
||||
#[must_use]
|
||||
pub fn new(addr: impl Into<String>) -> Self {
|
||||
Self { addr: addr.into() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Точка расширения платформы: реализация для конкретного L7-протокола.
|
||||
///
|
||||
/// Контракт минимальный: обработчик проводит handshake с клиентом
|
||||
/// (чтение/запись в поток до момента, когда можно проксировать) и
|
||||
/// возвращает целевой [`Upstream`]. Реализации поставляются фичами
|
||||
/// (`--features protocol-http`, внешние plugin-крейты) — в ядре их нет.
|
||||
///
|
||||
/// Метод асинхронен через boxed future, чтобы трейт оставался
|
||||
/// dyn-совместимым (`ProtocolRegistry` хранит `Box<dyn ProtocolHandler>`).
|
||||
pub trait ProtocolHandler: Send + Sync {
|
||||
/// Имя протокола (для метрик, логов и реестра).
|
||||
fn name(&self) -> &'static str;
|
||||
|
||||
/// Проводит handshake с клиентом и определяет upstream.
|
||||
///
|
||||
/// # Errors
|
||||
/// Возвращает ошибку при невалидном клиентском потоке или отказе в доступе.
|
||||
fn handshake<'a>(
|
||||
&'a self,
|
||||
stream: &'a mut TcpStream,
|
||||
) -> Pin<Box<dyn Future<Output = anyhow::Result<Upstream>> + Send + 'a>>;
|
||||
}
|
||||
|
||||
/// Реестр скомпилированных протокольных обработчиков.
|
||||
#[derive(Default)]
|
||||
pub struct ProtocolRegistry {
|
||||
handlers: Vec<Box<dyn ProtocolHandler>>,
|
||||
}
|
||||
|
||||
impl ProtocolRegistry {
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn register(&mut self, handler: Box<dyn ProtocolHandler>) {
|
||||
self.handlers.push(handler);
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.handlers.is_empty()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.handlers.len()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn names(&self) -> Vec<&'static str> {
|
||||
self.handlers.iter().map(|h| h.name()).collect()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn get(&self, name: &str) -> Option<&dyn ProtocolHandler> {
|
||||
self.handlers.iter().find(|h| h.name() == name).map(|h| h.as_ref())
|
||||
}
|
||||
|
||||
/// Возвращает основной обработчик.
|
||||
///
|
||||
/// # Errors
|
||||
/// Возвращает ошибку, если ни один протокол не скомпилирован —
|
||||
/// edge-нода обязана fail-fast на старте в этом случае.
|
||||
pub fn primary(&self) -> anyhow::Result<&dyn ProtocolHandler> {
|
||||
let Some(handler) = self.handlers.first() else {
|
||||
anyhow::bail!(
|
||||
"no protocol plugins compiled; build with --features protocol-http \
|
||||
or link an external ProtocolHandler implementation"
|
||||
);
|
||||
};
|
||||
Ok(handler.as_ref())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
struct DummyHandler;
|
||||
|
||||
impl ProtocolHandler for DummyHandler {
|
||||
fn name(&self) -> &'static str {
|
||||
"dummy"
|
||||
}
|
||||
|
||||
fn handshake<'a>(
|
||||
&'a self,
|
||||
_stream: &'a mut TcpStream,
|
||||
) -> Pin<Box<dyn Future<Output = anyhow::Result<Upstream>> + Send + 'a>> {
|
||||
Box::pin(async { Ok(Upstream::new("127.0.0.1:9000")) })
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_registry_fails_fast() {
|
||||
let registry = ProtocolRegistry::new();
|
||||
assert!(registry.is_empty());
|
||||
assert!(registry.primary().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registered_handler_is_resolvable() {
|
||||
let mut registry = ProtocolRegistry::new();
|
||||
registry.register(Box::new(DummyHandler));
|
||||
assert_eq!(registry.len(), 1);
|
||||
assert_eq!(registry.names(), vec!["dummy"]);
|
||||
assert!(registry.primary().is_ok());
|
||||
assert!(registry.get("dummy").is_some());
|
||||
assert!(registry.get("missing").is_none());
|
||||
}
|
||||
}
|
||||
89
src/store/clickhouse.rs
Normal file
89
src/store/clickhouse.rs
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
use chrono::{DateTime, Utc};
|
||||
use serde::Serialize;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::watch;
|
||||
|
||||
const BATCH_SIZE: usize = 1000;
|
||||
const FLUSH_INTERVAL: Duration = Duration::from_secs(1);
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ClickHouseEvent {
|
||||
pub timestamp: DateTime<Utc>,
|
||||
pub event_type: String,
|
||||
pub ip: String,
|
||||
pub data_float: f64,
|
||||
pub data_int: i64,
|
||||
pub data_string: String,
|
||||
}
|
||||
|
||||
pub struct ClickHouseWriter {
|
||||
url: String,
|
||||
client: reqwest::Client,
|
||||
buffer: Vec<ClickHouseEvent>,
|
||||
}
|
||||
|
||||
impl ClickHouseWriter {
|
||||
pub fn new(url: &str) -> Self {
|
||||
Self {
|
||||
url: url.to_string(),
|
||||
client: reqwest::Client::new(),
|
||||
buffer: Vec::with_capacity(BATCH_SIZE),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn push(&mut self, event: ClickHouseEvent) -> anyhow::Result<()> {
|
||||
self.buffer.push(event);
|
||||
if self.buffer.len() >= BATCH_SIZE {
|
||||
self.flush().await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn flush(&mut self) -> anyhow::Result<()> {
|
||||
if self.buffer.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let events = std::mem::take(&mut self.buffer);
|
||||
let json = serde_json::to_string(&events)?;
|
||||
let response = self
|
||||
.client
|
||||
.post(&self.url)
|
||||
.query(&[("query", "INSERT INTO rampart_events FORMAT JSONEachRow")])
|
||||
.header("Content-Type", "application/json")
|
||||
.body(json)
|
||||
.send()
|
||||
.await?;
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let text = response.text().await?;
|
||||
anyhow::bail!("clickhouse insert failed ({}): {}", status, text);
|
||||
}
|
||||
tracing::debug!("flushed {} events to clickhouse", events.len());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start_flush_task(writer: Arc<Mutex<ClickHouseWriter>>, mut shutdown: watch::Receiver<bool>) {
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(FLUSH_INTERVAL) => {
|
||||
if let Err(e) = writer.lock().await.flush().await {
|
||||
tracing::error!("clickhouse flush error: {e}");
|
||||
}
|
||||
}
|
||||
_ = shutdown.changed() => {
|
||||
if *shutdown.borrow() {
|
||||
tracing::info!("flushing clickhouse on shutdown");
|
||||
if let Err(e) = writer.lock().await.flush().await {
|
||||
tracing::error!("clickhouse final flush error: {e}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
26
src/store/mod.rs
Normal file
26
src/store/mod.rs
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
#[cfg(feature = "store-redis")]
|
||||
pub mod redis;
|
||||
#[cfg(feature = "store-redis")]
|
||||
pub use redis::start_blacklist_sync;
|
||||
|
||||
pub mod clickhouse;
|
||||
|
||||
/// Общий контракт внешнего хранилища состояния.
|
||||
#[allow(async_fn_in_trait)]
|
||||
pub trait StateStore: Send + Sync {
|
||||
/// # Errors
|
||||
/// Возвращает ошибку при недоступности хранилища.
|
||||
async fn get(&self, key: &str) -> anyhow::Result<Option<String>>;
|
||||
|
||||
/// # Errors
|
||||
/// Возвращает ошибку при недоступности хранилища.
|
||||
async fn set(&self, key: &str, value: &str) -> anyhow::Result<()>;
|
||||
|
||||
/// # Errors
|
||||
/// Возвращает ошибку при недоступности хранилища.
|
||||
async fn del(&self, key: &str) -> anyhow::Result<()>;
|
||||
|
||||
/// # Errors
|
||||
/// Возвращает ошибку при недоступности хранилища.
|
||||
async fn publish(&self, channel: &str, message: &str) -> anyhow::Result<()>;
|
||||
}
|
||||
128
src/store/redis.rs
Normal file
128
src/store/redis.rs
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
use crate::filter::blacklist::Blacklist;
|
||||
use crate::store::StateStore;
|
||||
use futures::StreamExt;
|
||||
use redis::AsyncCommands;
|
||||
use redis::Msg;
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::watch;
|
||||
|
||||
pub struct RedisStore {
|
||||
client: redis::Client,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct BlacklistEvent {
|
||||
ip: String,
|
||||
action: String,
|
||||
#[serde(default = "default_duration")]
|
||||
duration_secs: u64,
|
||||
}
|
||||
|
||||
fn default_duration() -> u64 {
|
||||
300
|
||||
}
|
||||
|
||||
impl RedisStore {
|
||||
pub fn new(url: &str) -> anyhow::Result<Self> {
|
||||
let client = redis::Client::open(url)?;
|
||||
Ok(Self { client })
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn start_blacklist_sync(
|
||||
client: &redis::Client,
|
||||
blacklist: Arc<Blacklist>,
|
||||
mut shutdown: watch::Receiver<bool>,
|
||||
) {
|
||||
#[allow(deprecated)]
|
||||
let conn = match client.get_async_connection().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::error!("failed to connect to Redis for blacklist sync: {e}");
|
||||
return;
|
||||
},
|
||||
};
|
||||
|
||||
let mut pubsub = conn.into_pubsub();
|
||||
if let Err(e) = pubsub.subscribe("rampart:blacklist:events").await {
|
||||
tracing::error!("failed to subscribe to blacklist events: {e}");
|
||||
return;
|
||||
}
|
||||
tracing::info!("subscribed to rampart:blacklist:events");
|
||||
|
||||
loop {
|
||||
let mut stream = pubsub.on_message();
|
||||
let msg_fut = stream.next();
|
||||
tokio::pin!(msg_fut);
|
||||
|
||||
tokio::select! {
|
||||
_ = shutdown.changed() => {
|
||||
if *shutdown.borrow() {
|
||||
tracing::info!("shutting down blacklist subscriber");
|
||||
return;
|
||||
}
|
||||
}
|
||||
result = &mut msg_fut => {
|
||||
match result {
|
||||
Some(msg) => {
|
||||
if let Err(e) = handle_event(&msg, &blacklist) {
|
||||
tracing::error!("blacklist event error: {e}");
|
||||
}
|
||||
}
|
||||
None => {
|
||||
tracing::error!("pubsub stream ended");
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_event(msg: &Msg, blacklist: &Blacklist) -> anyhow::Result<()> {
|
||||
let payload: String = msg.get_payload()?;
|
||||
let event: BlacklistEvent = serde_json::from_str(&payload)?;
|
||||
|
||||
let ip: std::net::IpAddr = event.ip.parse()?;
|
||||
|
||||
match event.action.as_str() {
|
||||
"ban" => {
|
||||
blacklist.add(ip, Duration::from_secs(event.duration_secs), "redis");
|
||||
tracing::info!("blacklist add via Redis: {}", event.ip);
|
||||
},
|
||||
"unban" => {
|
||||
blacklist.remove(ip);
|
||||
tracing::info!("blacklist remove via Redis: {}", event.ip);
|
||||
},
|
||||
a => anyhow::bail!("unknown action: {a}"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl StateStore for RedisStore {
|
||||
async fn get(&self, key: &str) -> anyhow::Result<Option<String>> {
|
||||
let mut conn = self.client.get_multiplexed_async_connection().await?;
|
||||
Ok(conn.get(key).await?)
|
||||
}
|
||||
|
||||
async fn set(&self, key: &str, value: &str) -> anyhow::Result<()> {
|
||||
let mut conn = self.client.get_multiplexed_async_connection().await?;
|
||||
let _: () = conn.set(key, value).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn del(&self, key: &str) -> anyhow::Result<()> {
|
||||
let mut conn = self.client.get_multiplexed_async_connection().await?;
|
||||
let _: () = conn.del(key).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn publish(&self, channel: &str, message: &str) -> anyhow::Result<()> {
|
||||
let mut conn = self.client.get_multiplexed_async_connection().await?;
|
||||
let _: () = conn.publish(channel, message).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
80
src/traffic/alert.rs
Normal file
80
src/traffic/alert.rs
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
use std::fmt;
|
||||
use std::net::Ipv4Addr;
|
||||
use std::time::Instant;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AlertLevel {
|
||||
Info,
|
||||
Warning,
|
||||
Critical,
|
||||
}
|
||||
|
||||
impl fmt::Display for AlertLevel {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
AlertLevel::Info => write!(f, "INFO"),
|
||||
AlertLevel::Warning => write!(f, "WARNING"),
|
||||
AlertLevel::Critical => write!(f, "CRITICAL"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Alert {
|
||||
pub level: AlertLevel,
|
||||
pub message: String,
|
||||
pub ip: Option<Ipv4Addr>,
|
||||
pub pps: f64,
|
||||
pub timestamp: Instant,
|
||||
}
|
||||
|
||||
impl Alert {
|
||||
pub fn new(level: AlertLevel, message: String, ip: Option<Ipv4Addr>, pps: f64) -> Self {
|
||||
Self {
|
||||
level,
|
||||
message,
|
||||
ip,
|
||||
pps,
|
||||
timestamp: Instant::now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Alert {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match &self.ip {
|
||||
Some(ip) => write!(
|
||||
f,
|
||||
"[{}] {} | IP: {} | PPS: {:.2}",
|
||||
self.level, self.message, ip, self.pps
|
||||
),
|
||||
None => write!(f, "[{}] {} | PPS: {:.2}", self.level, self.message, self.pps),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_alert_display_with_ip() {
|
||||
let alert = Alert::new(
|
||||
AlertLevel::Critical,
|
||||
"possible attack".into(),
|
||||
Some(Ipv4Addr::new(192, 168, 1, 1)),
|
||||
100500.0,
|
||||
);
|
||||
let s = alert.to_string();
|
||||
assert!(s.contains("CRITICAL"));
|
||||
assert!(s.contains("192.168.1.1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_alert_display_without_ip() {
|
||||
let alert = Alert::new(AlertLevel::Info, "traffic spike".into(), None, 5000.0);
|
||||
let s = alert.to_string();
|
||||
assert!(s.contains("INFO"));
|
||||
assert!(s.contains("traffic spike"));
|
||||
}
|
||||
}
|
||||
93
src/traffic/detector.rs
Normal file
93
src/traffic/detector.rs
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
use crate::traffic::profiler::TrafficProfiler;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AttackStatus {
|
||||
Normal,
|
||||
Suspicious,
|
||||
UnderAttack,
|
||||
}
|
||||
|
||||
pub struct AttackDetector {
|
||||
profiler: TrafficProfiler,
|
||||
consecutive_anomalies: u32,
|
||||
}
|
||||
|
||||
impl Default for AttackDetector {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl AttackDetector {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
profiler: TrafficProfiler::new(),
|
||||
consecutive_anomalies: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn analyze(&mut self, pps: f64) -> AttackStatus {
|
||||
self.profiler.record(pps);
|
||||
let score = self.profiler.anomaly_score(pps);
|
||||
|
||||
if score > 3.0 {
|
||||
self.consecutive_anomalies += 1;
|
||||
} else {
|
||||
self.consecutive_anomalies = 0;
|
||||
}
|
||||
|
||||
if self.consecutive_anomalies >= 3 {
|
||||
AttackStatus::UnderAttack
|
||||
} else if score > 2.0 {
|
||||
AttackStatus::Suspicious
|
||||
} else {
|
||||
AttackStatus::Normal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_attack_status_normal() {
|
||||
let mut d = AttackDetector::new();
|
||||
for _ in 0..10 {
|
||||
d.analyze(100.0);
|
||||
}
|
||||
assert_eq!(d.analyze(100.0), AttackStatus::Normal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_attack_status_suspicious() {
|
||||
let mut d = AttackDetector::new();
|
||||
for _ in 0..10 {
|
||||
d.analyze(1.0);
|
||||
}
|
||||
assert_eq!(d.analyze(5.0), AttackStatus::Suspicious);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_attack_status_under_attack() {
|
||||
let mut d = AttackDetector::new();
|
||||
for _ in 0..10 {
|
||||
d.analyze(1.0);
|
||||
}
|
||||
d.analyze(10_000.0);
|
||||
d.analyze(10_000.0);
|
||||
assert_eq!(d.analyze(10_000.0), AttackStatus::UnderAttack);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_consecutive_resets_on_normal() {
|
||||
let mut d = AttackDetector::new();
|
||||
for _ in 0..10 {
|
||||
d.analyze(1.0);
|
||||
}
|
||||
d.analyze(100.0);
|
||||
d.analyze(100.0);
|
||||
d.analyze(1.0);
|
||||
assert_eq!(d.analyze(1.0), AttackStatus::Normal);
|
||||
}
|
||||
}
|
||||
57
src/traffic/ewma.rs
Normal file
57
src/traffic/ewma.rs
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
use std::time::Instant;
|
||||
|
||||
pub struct Ewma {
|
||||
value: f64,
|
||||
last: Instant,
|
||||
alpha: f64,
|
||||
}
|
||||
|
||||
impl Ewma {
|
||||
pub fn new(alpha: f64) -> Self {
|
||||
Self {
|
||||
value: 0.0,
|
||||
last: Instant::now(),
|
||||
alpha,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update(&mut self, sample: f64) {
|
||||
let now = Instant::now();
|
||||
let elapsed = now.duration_since(self.last).as_secs_f64();
|
||||
let steps = elapsed.max(1.0);
|
||||
let weight = (1.0 - self.alpha).powf(steps);
|
||||
self.value = self.value * weight + sample * (1.0 - weight);
|
||||
self.last = now;
|
||||
}
|
||||
|
||||
pub fn value(&self) -> f64 {
|
||||
self.value
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_ewma_initial() {
|
||||
let ewma = Ewma::new(0.125);
|
||||
assert_eq!(ewma.value(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ewma_update() {
|
||||
let mut ewma = Ewma::new(1.0);
|
||||
ewma.update(100.0);
|
||||
assert!((ewma.value() - 100.0).abs() < 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ewma_convergence() {
|
||||
let mut ewma = Ewma::new(0.5);
|
||||
for _ in 0..10 {
|
||||
ewma.update(50.0);
|
||||
}
|
||||
assert!((ewma.value() - 50.0).abs() < 1.0);
|
||||
}
|
||||
}
|
||||
7
src/traffic/mod.rs
Normal file
7
src/traffic/mod.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
//! Профилирование трафика, детектор атак и репутация IP.
|
||||
|
||||
pub mod alert;
|
||||
pub mod detector;
|
||||
pub mod ewma;
|
||||
pub mod profiler;
|
||||
pub mod reputation;
|
||||
85
src/traffic/profiler.rs
Normal file
85
src/traffic/profiler.rs
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
use crate::traffic::ewma::Ewma;
|
||||
use std::time::Instant;
|
||||
|
||||
pub struct TrafficProfiler {
|
||||
slots: [Ewma; 168],
|
||||
current_slot: usize,
|
||||
epoch: Instant,
|
||||
}
|
||||
|
||||
impl Default for TrafficProfiler {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl TrafficProfiler {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
slots: std::array::from_fn(|_| Ewma::new(0.125)),
|
||||
current_slot: 0,
|
||||
epoch: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
fn slot_index(&self) -> usize {
|
||||
(self.epoch.elapsed().as_secs() / 3600) as usize % 168
|
||||
}
|
||||
|
||||
pub fn record(&mut self, pps: f64) {
|
||||
self.current_slot = self.slot_index();
|
||||
self.slots[self.current_slot].update(pps);
|
||||
}
|
||||
|
||||
pub fn baseline(&self) -> f64 {
|
||||
self.slots[self.slot_index()].value()
|
||||
}
|
||||
|
||||
pub fn anomaly_score(&self, pps: f64) -> f64 {
|
||||
let base = self.baseline();
|
||||
if base <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
(pps / base).min(10.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_profiler_record_and_baseline() {
|
||||
let mut p = TrafficProfiler::new();
|
||||
p.record(1000.0);
|
||||
p.record(1100.0);
|
||||
let base = p.baseline();
|
||||
assert!(base > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anomaly_score_normal() {
|
||||
let mut p = TrafficProfiler::new();
|
||||
for _ in 0..10 {
|
||||
p.record(100.0);
|
||||
}
|
||||
let score = p.anomaly_score(100.0);
|
||||
assert!(score < 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anomaly_score_capped() {
|
||||
let mut p = TrafficProfiler::new();
|
||||
for _ in 0..10 {
|
||||
p.record(1.0);
|
||||
}
|
||||
let score = p.anomaly_score(1_000_000.0);
|
||||
assert!((score - 10.0).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anomaly_score_zero_baseline() {
|
||||
let p = TrafficProfiler::new();
|
||||
assert_eq!(p.anomaly_score(100.0), 0.0);
|
||||
}
|
||||
}
|
||||
110
src/traffic/reputation.rs
Normal file
110
src/traffic/reputation.rs
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
use dashmap::DashMap;
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct IpReputation {
|
||||
scores: Arc<DashMap<IpAddr, i32>>,
|
||||
}
|
||||
|
||||
impl Default for IpReputation {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl IpReputation {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
scores: Arc::new(DashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_good(&self, ip: IpAddr) {
|
||||
let mut entry = self.scores.entry(ip).or_insert(0);
|
||||
*entry = (*entry + 1).min(100);
|
||||
}
|
||||
|
||||
pub fn record_bad(&self, ip: IpAddr) {
|
||||
let mut entry = self.scores.entry(ip).or_insert(0);
|
||||
*entry = (*entry - 10).max(-100);
|
||||
}
|
||||
|
||||
pub fn score(&self, ip: IpAddr) -> i32 {
|
||||
self.scores.get(&ip).map(|v| *v).unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn is_trusted(&self, ip: IpAddr) -> bool {
|
||||
self.score(ip) > 50
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||
|
||||
fn v4(octets: [u8; 4]) -> IpAddr {
|
||||
IpAddr::V4(Ipv4Addr::from(octets))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reputation_initial_score() {
|
||||
let rep = IpReputation::new();
|
||||
assert_eq!(rep.score(v4([192, 168, 1, 1])), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reputation_good() {
|
||||
let rep = IpReputation::new();
|
||||
let ip = v4([10, 0, 0, 1]);
|
||||
rep.record_good(ip);
|
||||
assert_eq!(rep.score(ip), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reputation_bad() {
|
||||
let rep = IpReputation::new();
|
||||
let ip = v4([10, 0, 0, 2]);
|
||||
rep.record_bad(ip);
|
||||
assert_eq!(rep.score(ip), -10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reputation_cap_positive() {
|
||||
let rep = IpReputation::new();
|
||||
let ip = v4([10, 0, 0, 3]);
|
||||
for _ in 0..200 {
|
||||
rep.record_good(ip);
|
||||
}
|
||||
assert_eq!(rep.score(ip), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reputation_cap_negative() {
|
||||
let rep = IpReputation::new();
|
||||
let ip = v4([10, 0, 0, 4]);
|
||||
for _ in 0..20 {
|
||||
rep.record_bad(ip);
|
||||
}
|
||||
assert_eq!(rep.score(ip), -100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_trusted() {
|
||||
let rep = IpReputation::new();
|
||||
let ip = v4([10, 0, 0, 5]);
|
||||
assert!(!rep.is_trusted(ip));
|
||||
for _ in 0..51 {
|
||||
rep.record_good(ip);
|
||||
}
|
||||
assert!(rep.is_trusted(ip));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reputation_v6() {
|
||||
let rep = IpReputation::new();
|
||||
let ip = IpAddr::V6(Ipv6Addr::LOCALHOST);
|
||||
rep.record_bad(ip);
|
||||
assert_eq!(rep.score(ip), -10);
|
||||
}
|
||||
}
|
||||
156
src/xdp/filter.rs
Normal file
156
src/xdp/filter.rs
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
use anyhow::{Context, Result, bail};
|
||||
use libbpf_rs::{MapCore, MapFlags, Object, ObjectBuilder, RingBuffer, RingBufferBuilder, Xdp, XdpFlags};
|
||||
use std::net::Ipv4Addr;
|
||||
use std::os::unix::io::AsFd;
|
||||
|
||||
use super::XdpStats;
|
||||
|
||||
pub struct XdpFilter {
|
||||
obj: Option<Object>,
|
||||
ringbuf: Option<RingBuffer<'static>>,
|
||||
ifindex: i32,
|
||||
interface: String,
|
||||
}
|
||||
|
||||
impl XdpFilter {
|
||||
pub fn new(interface: &str) -> Self {
|
||||
Self {
|
||||
obj: None,
|
||||
ringbuf: None,
|
||||
ifindex: 0,
|
||||
interface: interface.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load(&mut self) -> Result<()> {
|
||||
let bpf_obj = include_bytes!(concat!(env!("OUT_DIR"), "/universal_filter.o"));
|
||||
let obj = ObjectBuilder::default()
|
||||
.open_memory(bpf_obj)
|
||||
.context("Failed to open XDP object")?
|
||||
.load()
|
||||
.context("Failed to load XDP object (verifier error?)")?;
|
||||
|
||||
let ifindex = unsafe { libc::if_nametoindex(self.interface.as_ptr() as *const libc::c_char) };
|
||||
if ifindex == 0 {
|
||||
bail!("interface '{}' not found", self.interface);
|
||||
}
|
||||
|
||||
let prog = obj
|
||||
.progs()
|
||||
.find(|p| p.name() == "rampart_universal_filter")
|
||||
.context("XDP program 'rampart_universal_filter' not found")?;
|
||||
Xdp::new(prog.as_fd()).attach(ifindex as i32, XdpFlags::NONE)?;
|
||||
|
||||
let rbuf = build_ringbuf(&obj)?;
|
||||
|
||||
self.obj = Some(obj);
|
||||
self.ringbuf = Some(rbuf);
|
||||
self.ifindex = ifindex as i32;
|
||||
tracing::info!("XDP filter attached to {}", self.interface);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn unload(&mut self) -> Result<()> {
|
||||
if self.ifindex != 0 {
|
||||
let fd = unsafe { std::os::unix::io::BorrowedFd::borrow_raw(std::os::unix::io::RawFd::from(-1)) };
|
||||
let _ = Xdp::new(fd).detach(self.ifindex, XdpFlags::NONE);
|
||||
}
|
||||
self.ringbuf = None;
|
||||
self.obj = None;
|
||||
self.ifindex = 0;
|
||||
tracing::info!("XDP filter detached from {}", self.interface);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn drain_events(&self) {
|
||||
if let Some(rb) = &self.ringbuf {
|
||||
let _ = rb.consume();
|
||||
}
|
||||
}
|
||||
|
||||
fn find_map<'a>(&'a self, name: &str) -> Result<impl MapCore + 'a> {
|
||||
self.obj
|
||||
.as_ref()
|
||||
.context("XDP not loaded")?
|
||||
.maps()
|
||||
.find(|m| m.name() == name)
|
||||
.with_context(|| format!("map '{}' not found", name))
|
||||
}
|
||||
|
||||
pub fn ban_ip(&self, ip: Ipv4Addr, duration_secs: u64) -> Result<()> {
|
||||
let map = self.find_map("blacklist_map")?;
|
||||
let mut key = [0u8; 8];
|
||||
key[0] = 32;
|
||||
key[4..8].copy_from_slice(&ip.octets());
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos() as u64;
|
||||
map.update(
|
||||
&key,
|
||||
&(now + duration_secs * 1_000_000_000).to_le_bytes(),
|
||||
MapFlags::ANY,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn unban_ip(&self, ip: Ipv4Addr) -> Result<()> {
|
||||
let map = self.find_map("blacklist_map")?;
|
||||
let mut key = [0u8; 8];
|
||||
key[0] = 32;
|
||||
key[4..8].copy_from_slice(&ip.octets());
|
||||
map.delete(&key)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_stats(&self) -> Result<XdpStats> {
|
||||
let map = self.find_map("stats_map")?;
|
||||
let sum = |idx: u32| -> u64 {
|
||||
let key = idx.to_le_bytes();
|
||||
match map.lookup(&key, MapFlags::ANY) {
|
||||
Ok(Some(v)) => v
|
||||
.chunks_exact(8)
|
||||
.map(|c| u64::from_le_bytes(c.try_into().expect("chunk size 8")))
|
||||
.sum(),
|
||||
_ => 0,
|
||||
}
|
||||
};
|
||||
Ok(XdpStats {
|
||||
total: sum(0),
|
||||
tcp: sum(1),
|
||||
whitelist: sum(2),
|
||||
blacklist: sum(3),
|
||||
syn_throttle: sum(4),
|
||||
passed: sum(5),
|
||||
dropped: sum(6),
|
||||
udp: sum(7),
|
||||
rate_limit: sum(8),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
unsafe impl Send for XdpFilter {}
|
||||
|
||||
impl Drop for XdpFilter {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.unload();
|
||||
}
|
||||
}
|
||||
|
||||
fn build_ringbuf(obj: &Object) -> Result<RingBuffer<'static>> {
|
||||
let map = obj
|
||||
.maps()
|
||||
.find(|m| m.name() == "events_map")
|
||||
.context("events_map not found")?;
|
||||
let mut builder = RingBufferBuilder::new();
|
||||
builder.add(&map, |data: &[u8]| {
|
||||
if data.len() >= 16 {
|
||||
let ty = u32::from_ne_bytes(data[0..4].try_into().expect("4 bytes for type"));
|
||||
let ip4 = u32::from_ne_bytes(data[4..8].try_into().expect("4 bytes for ip"));
|
||||
let val = u64::from_ne_bytes(data[8..16].try_into().expect("8 bytes for val"));
|
||||
tracing::debug!(event = ty, src_ip = ip4, data = val, "xdp event");
|
||||
}
|
||||
0
|
||||
})?;
|
||||
Ok(builder.build()?)
|
||||
}
|
||||
53
src/xdp/metrics.rs
Normal file
53
src/xdp/metrics.rs
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
use anyhow::{Context, Result};
|
||||
use prometheus::{IntGauge, register};
|
||||
|
||||
use super::XdpStats;
|
||||
|
||||
pub struct XdpMetrics {
|
||||
total: IntGauge,
|
||||
tcp: IntGauge,
|
||||
whitelist: IntGauge,
|
||||
blacklist: IntGauge,
|
||||
syn_throttle: IntGauge,
|
||||
passed: IntGauge,
|
||||
dropped: IntGauge,
|
||||
udp: IntGauge,
|
||||
rate_limit: IntGauge,
|
||||
}
|
||||
|
||||
impl XdpMetrics {
|
||||
fn new_gauge(name: &str, help: &str) -> Result<IntGauge> {
|
||||
let gauge = IntGauge::new(name, help)?;
|
||||
register(Box::new(gauge.clone())).context(format!("failed to register {name}"))?;
|
||||
Ok(gauge)
|
||||
}
|
||||
|
||||
pub fn register() -> Result<Self> {
|
||||
let m = Self {
|
||||
total: Self::new_gauge("rampart_xdp_total", "Total XDP packets processed")?,
|
||||
tcp: Self::new_gauge("rampart_xdp_tcp", "TCP packets processed")?,
|
||||
whitelist: Self::new_gauge("rampart_xdp_whitelist", "Whitelisted packets")?,
|
||||
blacklist: Self::new_gauge("rampart_xdp_blacklist", "Blacklisted packets")?,
|
||||
syn_throttle: Self::new_gauge("rampart_xdp_syn_throttle", "SYN packets rate-limited")?,
|
||||
passed: Self::new_gauge("rampart_xdp_passed", "Packets passed to upper layers")?,
|
||||
dropped: Self::new_gauge("rampart_xdp_dropped", "Packets dropped by filter")?,
|
||||
udp: Self::new_gauge("rampart_xdp_udp", "UDP packets processed")?,
|
||||
rate_limit: Self::new_gauge("rampart_xdp_rate_limit", "Packets rate-limited by window counter")?,
|
||||
};
|
||||
tracing::info!("XDP Prometheus metrics registered");
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
pub fn update(&self, stats: &XdpStats) {
|
||||
self.total.set(stats.total as i64);
|
||||
self.tcp.set(stats.tcp as i64);
|
||||
self.whitelist.set(stats.whitelist as i64);
|
||||
self.blacklist.set(stats.blacklist as i64);
|
||||
self.syn_throttle.set(stats.syn_throttle as i64);
|
||||
self.passed.set(stats.passed as i64);
|
||||
self.dropped.set(stats.dropped as i64);
|
||||
self.udp.set(stats.udp as i64);
|
||||
self.rate_limit.set(stats.rate_limit as i64);
|
||||
tracing::debug!("XDP metrics updated");
|
||||
}
|
||||
}
|
||||
18
src/xdp/mod.rs
Normal file
18
src/xdp/mod.rs
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
//! Загрузчик XDP-программы (L3/L4-уровень защиты).
|
||||
mod stats;
|
||||
pub use stats::XdpStats;
|
||||
|
||||
#[cfg(feature = "xdp")]
|
||||
mod filter;
|
||||
#[cfg(feature = "xdp")]
|
||||
pub use filter::XdpFilter;
|
||||
|
||||
#[cfg(feature = "xdp")]
|
||||
mod metrics;
|
||||
#[cfg(feature = "xdp")]
|
||||
pub use metrics::XdpMetrics;
|
||||
|
||||
#[cfg(not(feature = "xdp"))]
|
||||
mod noop;
|
||||
#[cfg(not(feature = "xdp"))]
|
||||
pub use noop::XdpFilter;
|
||||
26
src/xdp/noop.rs
Normal file
26
src/xdp/noop.rs
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
use anyhow::Result;
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
pub struct XdpFilter;
|
||||
|
||||
impl XdpFilter {
|
||||
pub fn new(_interface: &str) -> Self {
|
||||
Self
|
||||
}
|
||||
pub fn load(&mut self) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
pub fn unload(&mut self) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
pub fn drain_events(&self) {}
|
||||
pub fn ban_ip(&self, _ip: Ipv4Addr, _duration_secs: u64) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
pub fn unban_ip(&self, _ip: Ipv4Addr) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
pub fn get_stats(&self) -> Result<super::XdpStats> {
|
||||
Ok(super::XdpStats::default())
|
||||
}
|
||||
}
|
||||
13
src/xdp/stats.rs
Normal file
13
src/xdp/stats.rs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
/// Счётчики XDP-программы; индексы соответствуют STAT_* из xdp/core/maps.h.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct XdpStats {
|
||||
pub total: u64,
|
||||
pub tcp: u64,
|
||||
pub whitelist: u64,
|
||||
pub blacklist: u64,
|
||||
pub syn_throttle: u64,
|
||||
pub passed: u64,
|
||||
pub dropped: u64,
|
||||
pub udp: u64,
|
||||
pub rate_limit: u64,
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue