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:
Edison Jwa
2026-06-11 14:49:19 +09:00
parent b944bd89d7
commit af823da543
2 changed files with 282 additions and 0 deletions
@@ -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);
}
}
+1
View File
@@ -35,6 +35,7 @@
mod adapter;
mod dto;
pub mod flood_tracker;
pub mod poke_limiter;
pub use adapter::{ConnectConfig, DisconnectReason, InboundVoice, ProtocolClient, SnapshotProbe};