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).
282 lines
8.8 KiB
Rust
282 lines
8.8 KiB
Rust
//! 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);
|
|
}
|
|
}
|