//! 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); } }