feat(protocol): add client-side anti-flood awareness (TODO-064)
Implement FloodTracker with server-configurable thresholds from YaTQA §5.1 model: 0.5s tick interval, point costs per operation, NearLimit/CommandBlocked/IpBlocked risk levels. Defaults from TeaSpeak (tick_reduce=25, command_block=150, ip_block=300).
This commit is contained in:
@@ -0,0 +1,281 @@
|
||||
//! Client-side anti-flood awareness per YaTQA §5.1.
|
||||
//!
|
||||
//! TS3 servers enforce a tick-based point system. Points accumulate
|
||||
//! per operation and decay over time. This tracker provides client-side
|
||||
//! awareness to avoid accidental server bans.
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
/// Point costs per operation (from YaTQA §5.2).
|
||||
///
|
||||
/// These are client-side estimates. Server may differ slightly.
|
||||
/// Zero-cost operations are listed for completeness.
|
||||
pub struct FloodCosts;
|
||||
|
||||
impl FloodCosts {
|
||||
// Zero-cost
|
||||
/// Client disconnect (0 points).
|
||||
pub const CLIENT_DISCONNECT: u32 = 0;
|
||||
/// Get client variables (0 points).
|
||||
pub const CLIENT_GET_VARIABLES: u32 = 0;
|
||||
/// Set whisper list (0 points).
|
||||
pub const SET_WHISPER_LIST: u32 = 0;
|
||||
/// File transfer get file list (0 points).
|
||||
pub const FT_GET_FILE_LIST: u32 = 0;
|
||||
/// File transfer init upload (0 points).
|
||||
pub const FT_INIT_UPLOAD: u32 = 0;
|
||||
/// File transfer init download (0 points).
|
||||
pub const FT_INIT_DOWNLOAD: u32 = 0;
|
||||
|
||||
// Low-cost (5)
|
||||
/// Add permission (5 points).
|
||||
pub const PERMISSION_ADD: u32 = 5;
|
||||
/// Remove permission (5 points).
|
||||
pub const PERMISSION_REMOVE: u32 = 5;
|
||||
/// Add server group (5 points).
|
||||
pub const SERVER_GROUP_ADD: u32 = 5;
|
||||
/// Delete server group (5 points).
|
||||
pub const SERVER_GROUP_DELETE: u32 = 5;
|
||||
|
||||
// Medium-cost (10-20)
|
||||
/// Move client to another channel (10 points).
|
||||
pub const CLIENT_MOVE: u32 = 10;
|
||||
/// Send text message (15 points).
|
||||
pub const TEXT_MESSAGE_SEND: u32 = 15;
|
||||
/// Subscribe to channel (158 points).
|
||||
pub const CHANNEL_SUBSCRIBE: u32 = 158;
|
||||
/// Set badges on connect (15 points).
|
||||
pub const SET_BADGES: u32 = 15;
|
||||
|
||||
// High-cost (25)
|
||||
/// Add ban (25 points).
|
||||
pub const BAN_ADD: u32 = 25;
|
||||
/// Ban client (25 points).
|
||||
pub const BAN_CLIENT: u32 = 25;
|
||||
/// Add complain (25 points).
|
||||
pub const COMPLAIN_ADD: u32 = 25;
|
||||
/// Delete all complains (25 points).
|
||||
pub const COMPLAIN_DEL_ALL: u32 = 25;
|
||||
/// Create channel (25 points).
|
||||
pub const CHANNEL_CREATE: u32 = 25;
|
||||
/// Delete channel (25 points).
|
||||
pub const CHANNEL_DELETE: u32 = 25;
|
||||
/// Move channel (25 points).
|
||||
pub const CHANNEL_MOVE: u32 = 25;
|
||||
/// Edit channel (25 points).
|
||||
pub const CHANNEL_EDIT: u32 = 25;
|
||||
/// Kick client (25 points).
|
||||
pub const CLIENT_KICK: u32 = 25;
|
||||
/// Poke client (25 points).
|
||||
pub const CLIENT_POKE: u32 = 25;
|
||||
/// Edit client (25 points).
|
||||
pub const CLIENT_EDIT: u32 = 25;
|
||||
/// Add client to server group (25 points).
|
||||
pub const SERVER_GROUP_ADD_CLIENT: u32 = 25;
|
||||
/// Remove client from server group (25 points).
|
||||
pub const SERVER_GROUP_DEL_CLIENT: u32 = 25;
|
||||
/// Set client channel group (25 points).
|
||||
pub const SET_CLIENT_CHANNEL_GROUP: u32 = 25;
|
||||
|
||||
// Very high-cost (50)
|
||||
/// Delete client from database (50 points).
|
||||
pub const CLIENT_DB_DELETE: u32 = 50;
|
||||
/// Edit client in database (50 points).
|
||||
pub const CLIENT_DB_EDIT: u32 = 50;
|
||||
/// Find client in database (50 points).
|
||||
pub const CLIENT_DB_FIND: u32 = 50;
|
||||
/// View server log (50 points).
|
||||
pub const LOG_VIEW: u32 = 50;
|
||||
}
|
||||
|
||||
/// Server-configured anti-flood parameters.
|
||||
///
|
||||
/// Obtained from `serverinfo` response. If unavailable,
|
||||
/// conservative defaults are used.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FloodConfig {
|
||||
/// Points deducted per 0.5-second tick.
|
||||
pub points_tick_reduce: u32,
|
||||
/// Points before command block (at equality).
|
||||
pub points_to_command_block: u32,
|
||||
/// Points before IP block.
|
||||
pub points_to_ip_block: u32,
|
||||
}
|
||||
|
||||
impl Default for FloodConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
points_tick_reduce: 25,
|
||||
points_to_command_block: 150,
|
||||
points_to_ip_block: 300,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Current flood risk level.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum FloodRisk {
|
||||
/// Well below thresholds.
|
||||
Safe,
|
||||
/// Approaching command block (>= 80% of threshold).
|
||||
NearLimit,
|
||||
/// At or above command block threshold. Commands will be dropped.
|
||||
CommandBlocked,
|
||||
/// At or above IP block threshold. Connection may be terminated.
|
||||
IpBlocked,
|
||||
}
|
||||
|
||||
/// Client-side flood tracker following YaTQA §5.1 model.
|
||||
///
|
||||
/// Tick interval: 0.5 seconds. Points decay by `config.points_tick_reduce`
|
||||
/// per tick. Thresholds are server-configurable.
|
||||
///
|
||||
/// # Usage
|
||||
///
|
||||
/// ```rust
|
||||
/// use chanora_protocol::flood_tracker::{FloodTracker, FloodCosts, FloodConfig, FloodRisk};
|
||||
///
|
||||
/// let mut tracker = FloodTracker::new(FloodConfig::default());
|
||||
/// let risk = tracker.record(FloodCosts::TEXT_MESSAGE_SEND);
|
||||
/// if risk >= FloodRisk::NearLimit {
|
||||
/// // Warn user or throttle operations
|
||||
/// }
|
||||
/// ```
|
||||
pub struct FloodTracker {
|
||||
points: u32,
|
||||
last_tick: Instant,
|
||||
config: FloodConfig,
|
||||
}
|
||||
|
||||
impl FloodTracker {
|
||||
/// Tick interval in milliseconds (0.5 seconds per YaTQA §5.1).
|
||||
const TICK_INTERVAL_MS: u128 = 500;
|
||||
|
||||
/// Create a new tracker with the given server configuration.
|
||||
pub fn new(config: FloodConfig) -> Self {
|
||||
Self {
|
||||
points: 0,
|
||||
last_tick: Instant::now(),
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
/// Record an operation and return the current flood risk.
|
||||
pub fn record(&mut self, cost: u32) -> FloodRisk {
|
||||
self.tick();
|
||||
self.points = self.points.saturating_add(cost);
|
||||
self.risk_level()
|
||||
}
|
||||
|
||||
/// Apply time-based decay (0.5-second ticks).
|
||||
fn tick(&mut self) {
|
||||
let elapsed = self.last_tick.elapsed();
|
||||
let ticks = (elapsed.as_millis() / Self::TICK_INTERVAL_MS) as u32;
|
||||
if ticks > 0 {
|
||||
let decay = ticks.saturating_mul(self.config.points_tick_reduce);
|
||||
self.points = self.points.saturating_sub(decay);
|
||||
self.last_tick = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
/// Current risk level based on accumulated points.
|
||||
pub fn risk_level(&self) -> FloodRisk {
|
||||
if self.points >= self.config.points_to_ip_block {
|
||||
FloodRisk::IpBlocked
|
||||
} else if self.points >= self.config.points_to_command_block {
|
||||
FloodRisk::CommandBlocked
|
||||
} else if self.points >= (self.config.points_to_command_block * 80 / 100) {
|
||||
FloodRisk::NearLimit
|
||||
} else {
|
||||
FloodRisk::Safe
|
||||
}
|
||||
}
|
||||
|
||||
/// Current accumulated points.
|
||||
pub fn points(&self) -> u32 {
|
||||
self.points
|
||||
}
|
||||
|
||||
/// Update configuration from serverinfo response.
|
||||
pub fn update_config(&mut self, config: FloodConfig) {
|
||||
self.config = config;
|
||||
}
|
||||
|
||||
/// Reset points (e.g., after successful reconnect).
|
||||
pub fn reset(&mut self) {
|
||||
self.points = 0;
|
||||
self.last_tick = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn new_tracker_is_safe() {
|
||||
let tracker = FloodTracker::new(FloodConfig::default());
|
||||
assert_eq!(tracker.risk_level(), FloodRisk::Safe);
|
||||
assert_eq!(tracker.points(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_accumulation() {
|
||||
let mut tracker = FloodTracker::new(FloodConfig::default());
|
||||
tracker.record(FloodCosts::TEXT_MESSAGE_SEND);
|
||||
assert_eq!(tracker.points(), 15);
|
||||
assert_eq!(tracker.risk_level(), FloodRisk::Safe);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn near_limit_detection() {
|
||||
let mut tracker = FloodTracker::new(FloodConfig::default());
|
||||
// 80% of 150 = 120
|
||||
for _ in 0..8 {
|
||||
tracker.record(FloodCosts::TEXT_MESSAGE_SEND); // 8 * 15 = 120
|
||||
}
|
||||
assert_eq!(tracker.risk_level(), FloodRisk::NearLimit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_blocked_detection() {
|
||||
let mut tracker = FloodTracker::new(FloodConfig::default());
|
||||
// 150 / 15 = 10 messages
|
||||
for _ in 0..10 {
|
||||
tracker.record(FloodCosts::TEXT_MESSAGE_SEND);
|
||||
}
|
||||
assert_eq!(tracker.risk_level(), FloodRisk::CommandBlocked);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extreme_cost_channel_subscribe() {
|
||||
let mut tracker = FloodTracker::new(FloodConfig::default());
|
||||
let risk = tracker.record(FloodCosts::CHANNEL_SUBSCRIBE);
|
||||
assert_eq!(tracker.points(), 158);
|
||||
assert_eq!(risk, FloodRisk::CommandBlocked);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_update() {
|
||||
let mut tracker = FloodTracker::new(FloodConfig::default());
|
||||
tracker.update_config(FloodConfig {
|
||||
points_tick_reduce: 10,
|
||||
points_to_command_block: 200,
|
||||
points_to_ip_block: 400,
|
||||
});
|
||||
// With higher threshold, same points should be safe
|
||||
for _ in 0..10 {
|
||||
tracker.record(FloodCosts::TEXT_MESSAGE_SEND); // 150
|
||||
}
|
||||
assert_eq!(tracker.risk_level(), FloodRisk::Safe);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_points() {
|
||||
let mut tracker = FloodTracker::new(FloodConfig::default());
|
||||
tracker.record(FloodCosts::CHANNEL_SUBSCRIBE);
|
||||
assert!(tracker.points() > 0);
|
||||
tracker.reset();
|
||||
assert_eq!(tracker.points(), 0);
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@
|
||||
|
||||
mod adapter;
|
||||
mod dto;
|
||||
pub mod flood_tracker;
|
||||
pub mod poke_limiter;
|
||||
|
||||
pub use adapter::{ConnectConfig, DisconnectReason, InboundVoice, ProtocolClient, SnapshotProbe};
|
||||
|
||||
Reference in New Issue
Block a user