diff --git a/crates/chanora_protocol/src/flood_tracker.rs b/crates/chanora_protocol/src/flood_tracker.rs new file mode 100644 index 0000000..a85e8d9 --- /dev/null +++ b/crates/chanora_protocol/src/flood_tracker.rs @@ -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); + } +} diff --git a/crates/chanora_protocol/src/lib.rs b/crates/chanora_protocol/src/lib.rs index 9f7f0e1..b150b76 100644 --- a/crates/chanora_protocol/src/lib.rs +++ b/crates/chanora_protocol/src/lib.rs @@ -35,6 +35,7 @@ mod adapter; mod dto; +pub mod flood_tracker; pub mod poke_limiter; pub use adapter::{ConnectConfig, DisconnectReason, InboundVoice, ProtocolClient, SnapshotProbe};