Files
btest-rs/src/server_pro/quota.rs
Siavash Sameni 4cdcc4e6c4 Public btest server: byte budget, multi-conn, web dashboard, quotas
- Inline byte budget in BandwidthState prevents quota overshoot at any
  link speed (TX/RX loops check per-packet, not per-interval)
- TCP multi-connection support for server-pro (session tokens, secondary
  connection joins, delegates to standard multi-conn handler)
- MD5 password verification against stored raw passwords in user DB
- Web dashboard: quota progress bars (daily/weekly/monthly), JSON export
  endpoint (/api/ip/{ip}/export), quota API (/api/ip/{ip}/quota)
- Landing page with usage instructions, UDP NAT warning, credentials
- Fix IP usage double-counting bug in QuotaManager::record_usage
- UserDb now stores DB path and raw passwords for MD5 auth
- 10 enforcer tests (4 new: budget calc, budget stop, budget exhausted,
  unlimited passthrough)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 18:43:09 +04:00

471 lines
17 KiB
Rust

//! Bandwidth quota management for btest-server-pro.
//!
//! Enforces per-user and per-IP bandwidth limits (daily/weekly/monthly),
//! with separate tracking for inbound (client-to-server) and outbound
//! (server-to-client) directions.
use std::collections::HashMap;
use std::net::IpAddr;
use std::sync::{Arc, Mutex};
use super::user_db::UserDb;
/// Traffic direction for bandwidth tests.
///
/// From the **server's** perspective:
/// - `Inbound` = client sends data to us (client TX, server RX)
/// - `Outbound` = we send data to the client (server TX, client RX)
/// - `Both` = bidirectional test
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Direction {
Inbound,
Outbound,
Both,
}
#[derive(Clone)]
pub struct QuotaManager {
db: UserDb,
/// Per-user defaults (0 = unlimited)
default_daily: u64,
default_weekly: u64,
default_monthly: u64,
/// Per-IP combined (inbound + outbound) limits (0 = unlimited) — for abuse prevention
ip_daily: u64,
ip_weekly: u64,
ip_monthly: u64,
/// Per-IP directional limits (0 = unlimited)
ip_daily_inbound: u64,
ip_daily_outbound: u64,
ip_weekly_inbound: u64,
ip_weekly_outbound: u64,
ip_monthly_inbound: u64,
ip_monthly_outbound: u64,
/// Max simultaneous connections from one IP
max_conn_per_ip: u32,
/// Max test duration in seconds
max_duration: u64,
active_connections: Arc<Mutex<HashMap<IpAddr, u32>>>,
}
#[derive(Debug)]
pub enum QuotaError {
DailyExceeded { used: u64, limit: u64 },
WeeklyExceeded { used: u64, limit: u64 },
MonthlyExceeded { used: u64, limit: u64 },
/// Combined (inbound + outbound) IP daily limit exceeded.
IpDailyExceeded { used: u64, limit: u64 },
/// Combined (inbound + outbound) IP weekly limit exceeded.
IpWeeklyExceeded { used: u64, limit: u64 },
/// Combined (inbound + outbound) IP monthly limit exceeded.
IpMonthlyExceeded { used: u64, limit: u64 },
/// Per-direction IP daily limits.
IpInboundDailyExceeded { used: u64, limit: u64 },
IpOutboundDailyExceeded { used: u64, limit: u64 },
/// Per-direction IP weekly limits.
IpInboundWeeklyExceeded { used: u64, limit: u64 },
IpOutboundWeeklyExceeded { used: u64, limit: u64 },
/// Per-direction IP monthly limits.
IpInboundMonthlyExceeded { used: u64, limit: u64 },
IpOutboundMonthlyExceeded { used: u64, limit: u64 },
TooManyConnections { current: u32, limit: u32 },
UserDisabled,
UserNotFound,
}
impl std::fmt::Display for QuotaError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::DailyExceeded { used, limit } =>
write!(f, "User daily quota exceeded: {}/{} bytes", used, limit),
Self::WeeklyExceeded { used, limit } =>
write!(f, "User weekly quota exceeded: {}/{} bytes", used, limit),
Self::MonthlyExceeded { used, limit } =>
write!(f, "User monthly quota exceeded: {}/{} bytes", used, limit),
Self::IpDailyExceeded { used, limit } =>
write!(f, "IP daily quota exceeded: {}/{} bytes", used, limit),
Self::IpWeeklyExceeded { used, limit } =>
write!(f, "IP weekly quota exceeded: {}/{} bytes", used, limit),
Self::IpMonthlyExceeded { used, limit } =>
write!(f, "IP monthly quota exceeded: {}/{} bytes", used, limit),
Self::IpInboundDailyExceeded { used, limit } =>
write!(f, "IP inbound daily quota exceeded: {}/{} bytes", used, limit),
Self::IpOutboundDailyExceeded { used, limit } =>
write!(f, "IP outbound daily quota exceeded: {}/{} bytes", used, limit),
Self::IpInboundWeeklyExceeded { used, limit } =>
write!(f, "IP inbound weekly quota exceeded: {}/{} bytes", used, limit),
Self::IpOutboundWeeklyExceeded { used, limit } =>
write!(f, "IP outbound weekly quota exceeded: {}/{} bytes", used, limit),
Self::IpInboundMonthlyExceeded { used, limit } =>
write!(f, "IP inbound monthly quota exceeded: {}/{} bytes", used, limit),
Self::IpOutboundMonthlyExceeded { used, limit } =>
write!(f, "IP outbound monthly quota exceeded: {}/{} bytes", used, limit),
Self::TooManyConnections { current, limit } =>
write!(f, "Too many connections from this IP: {}/{}", current, limit),
Self::UserDisabled => write!(f, "User account is disabled"),
Self::UserNotFound => write!(f, "User not found"),
}
}
}
impl QuotaManager {
#[allow(clippy::too_many_arguments)]
pub fn new(
db: UserDb,
default_daily: u64,
default_weekly: u64,
default_monthly: u64,
ip_daily: u64,
ip_weekly: u64,
ip_monthly: u64,
ip_daily_inbound: u64,
ip_daily_outbound: u64,
ip_weekly_inbound: u64,
ip_weekly_outbound: u64,
ip_monthly_inbound: u64,
ip_monthly_outbound: u64,
max_conn_per_ip: u32,
max_duration: u64,
) -> Self {
Self {
db,
default_daily,
default_weekly,
default_monthly,
ip_daily,
ip_weekly,
ip_monthly,
ip_daily_inbound,
ip_daily_outbound,
ip_weekly_inbound,
ip_weekly_outbound,
ip_monthly_inbound,
ip_monthly_outbound,
max_conn_per_ip,
max_duration,
active_connections: Arc::new(Mutex::new(HashMap::new())),
}
}
/// Check if a user is allowed to start a test.
pub fn check_user(&self, username: &str) -> Result<(), QuotaError> {
let user = self.db.get_user(username)
.map_err(|_| QuotaError::UserNotFound)?
.ok_or(QuotaError::UserNotFound)?;
if !user.enabled {
return Err(QuotaError::UserDisabled);
}
// Daily
let daily_limit = if user.daily_quota > 0 { user.daily_quota as u64 } else { self.default_daily };
if daily_limit > 0 {
let (tx, rx) = self.db.get_daily_usage(username).unwrap_or((0, 0));
let used = tx + rx;
if used >= daily_limit {
return Err(QuotaError::DailyExceeded { used, limit: daily_limit });
}
}
// Weekly
let weekly_limit = if user.weekly_quota > 0 { user.weekly_quota as u64 } else { self.default_weekly };
if weekly_limit > 0 {
let (tx, rx) = self.db.get_weekly_usage(username).unwrap_or((0, 0));
let used = tx + rx;
if used >= weekly_limit {
return Err(QuotaError::WeeklyExceeded { used, limit: weekly_limit });
}
}
// Monthly
if self.default_monthly > 0 {
let (tx, rx) = self.db.get_monthly_usage(username).unwrap_or((0, 0));
let used = tx + rx;
if used >= self.default_monthly {
return Err(QuotaError::MonthlyExceeded { used, limit: self.default_monthly });
}
}
Ok(())
}
/// Check if an IP is allowed to connect, considering both combined and
/// directional bandwidth quotas.
///
/// The `direction` parameter indicates which direction the test will use.
/// For `Direction::Both`, both inbound and outbound directional limits are
/// checked. Combined (total) limits are always checked regardless of
/// direction.
pub fn check_ip(&self, ip: &IpAddr, direction: Direction) -> Result<(), QuotaError> {
// Connection limit
if self.max_conn_per_ip > 0 {
let conns = self.active_connections.lock().unwrap();
let current = conns.get(ip).copied().unwrap_or(0);
if current >= self.max_conn_per_ip {
return Err(QuotaError::TooManyConnections {
current,
limit: self.max_conn_per_ip,
});
}
}
let ip_str = ip.to_string();
// --- Combined (inbound + outbound) limits ---
self.check_ip_combined(&ip_str)?;
// --- Directional limits ---
let check_inbound = matches!(direction, Direction::Inbound | Direction::Both);
let check_outbound = matches!(direction, Direction::Outbound | Direction::Both);
if check_inbound {
self.check_ip_inbound(&ip_str)?;
}
if check_outbound {
self.check_ip_outbound(&ip_str)?;
}
Ok(())
}
/// Check combined (total inbound + outbound) IP limits.
fn check_ip_combined(&self, ip_str: &str) -> Result<(), QuotaError> {
// IP daily (combined)
if self.ip_daily > 0 {
let (tx, rx) = self.db.get_ip_daily_usage(ip_str).unwrap_or((0, 0));
let used = tx + rx;
if used >= self.ip_daily {
return Err(QuotaError::IpDailyExceeded { used, limit: self.ip_daily });
}
}
// IP weekly (combined)
if self.ip_weekly > 0 {
let (tx, rx) = self.db.get_ip_weekly_usage(ip_str).unwrap_or((0, 0));
let used = tx + rx;
if used >= self.ip_weekly {
return Err(QuotaError::IpWeeklyExceeded { used, limit: self.ip_weekly });
}
}
// IP monthly (combined)
if self.ip_monthly > 0 {
let (tx, rx) = self.db.get_ip_monthly_usage(ip_str).unwrap_or((0, 0));
let used = tx + rx;
if used >= self.ip_monthly {
return Err(QuotaError::IpMonthlyExceeded { used, limit: self.ip_monthly });
}
}
Ok(())
}
/// Check inbound-only (client sends to us) IP limits.
fn check_ip_inbound(&self, ip_str: &str) -> Result<(), QuotaError> {
// Daily inbound
if self.ip_daily_inbound > 0 {
let used = self.db.get_ip_daily_inbound(ip_str).unwrap_or(0);
if used >= self.ip_daily_inbound {
return Err(QuotaError::IpInboundDailyExceeded {
used,
limit: self.ip_daily_inbound,
});
}
}
// Weekly inbound
if self.ip_weekly_inbound > 0 {
let used = self.db.get_ip_weekly_inbound(ip_str).unwrap_or(0);
if used >= self.ip_weekly_inbound {
return Err(QuotaError::IpInboundWeeklyExceeded {
used,
limit: self.ip_weekly_inbound,
});
}
}
// Monthly inbound
if self.ip_monthly_inbound > 0 {
let used = self.db.get_ip_monthly_inbound(ip_str).unwrap_or(0);
if used >= self.ip_monthly_inbound {
return Err(QuotaError::IpInboundMonthlyExceeded {
used,
limit: self.ip_monthly_inbound,
});
}
}
Ok(())
}
/// Check outbound-only (we send to client) IP limits.
fn check_ip_outbound(&self, ip_str: &str) -> Result<(), QuotaError> {
// Daily outbound
if self.ip_daily_outbound > 0 {
let used = self.db.get_ip_daily_outbound(ip_str).unwrap_or(0);
if used >= self.ip_daily_outbound {
return Err(QuotaError::IpOutboundDailyExceeded {
used,
limit: self.ip_daily_outbound,
});
}
}
// Weekly outbound
if self.ip_weekly_outbound > 0 {
let used = self.db.get_ip_weekly_outbound(ip_str).unwrap_or(0);
if used >= self.ip_weekly_outbound {
return Err(QuotaError::IpOutboundWeeklyExceeded {
used,
limit: self.ip_weekly_outbound,
});
}
}
// Monthly outbound
if self.ip_monthly_outbound > 0 {
let used = self.db.get_ip_monthly_outbound(ip_str).unwrap_or(0);
if used >= self.ip_monthly_outbound {
return Err(QuotaError::IpOutboundMonthlyExceeded {
used,
limit: self.ip_monthly_outbound,
});
}
}
Ok(())
}
pub fn connect(&self, ip: &IpAddr) {
let mut conns = self.active_connections.lock().unwrap();
*conns.entry(*ip).or_insert(0) += 1;
}
pub fn disconnect(&self, ip: &IpAddr) {
let mut conns = self.active_connections.lock().unwrap();
if let Some(count) = conns.get_mut(ip) {
*count = count.saturating_sub(1);
if *count == 0 {
conns.remove(ip);
}
}
}
/// Record usage after a test completes (both user and IP), with separate
/// inbound and outbound byte counts.
///
/// - `inbound_bytes`: bytes the client sent to us (server RX).
/// - `outbound_bytes`: bytes we sent to the client (server TX).
///
/// Both the combined user/IP usage and directional IP usage are recorded.
pub fn record_usage(
&self,
username: &str,
ip: &str,
inbound_bytes: u64,
outbound_bytes: u64,
) {
// Record combined user usage (tx/rx from the server's perspective:
// tx = outbound, rx = inbound).
if let Err(e) = self.db.record_usage(username, outbound_bytes, inbound_bytes) {
tracing::error!("Failed to record user usage for {}: {}", username, e);
}
// Record IP usage — record_ip_usage already writes both the
// inbound_bytes and outbound_bytes columns in one operation.
// Do NOT also call record_ip_inbound_usage/record_ip_outbound_usage
// as they update the same columns and would double-count.
if let Err(e) = self.db.record_ip_usage(ip, outbound_bytes, inbound_bytes) {
tracing::error!("Failed to record IP usage for {}: {}", ip, e);
}
}
/// Calculate the remaining byte budget for a user+IP combination.
/// Returns the minimum remaining quota across all applicable limits.
/// Used to set `BandwidthState::byte_budget` before a test starts,
/// preventing overshoot beyond quota boundaries.
pub fn remaining_budget(&self, username: &str, ip: &IpAddr) -> u64 {
let mut budget = u64::MAX;
let ip_str = ip.to_string();
// Helper: min that ignores 0 (unlimited)
let cap = |budget: &mut u64, limit: u64, used: u64| {
if limit > 0 {
let remaining = limit.saturating_sub(used);
*budget = (*budget).min(remaining);
}
};
// User quotas (combined tx+rx)
if let Ok(Some(user)) = self.db.get_user(username) {
let daily_limit = if user.daily_quota > 0 { user.daily_quota as u64 } else { self.default_daily };
if daily_limit > 0 {
let (tx, rx) = self.db.get_daily_usage(username).unwrap_or((0, 0));
cap(&mut budget, daily_limit, tx + rx);
}
let weekly_limit = if user.weekly_quota > 0 { user.weekly_quota as u64 } else { self.default_weekly };
if weekly_limit > 0 {
let (tx, rx) = self.db.get_weekly_usage(username).unwrap_or((0, 0));
cap(&mut budget, weekly_limit, tx + rx);
}
if self.default_monthly > 0 {
let (tx, rx) = self.db.get_monthly_usage(username).unwrap_or((0, 0));
cap(&mut budget, self.default_monthly, tx + rx);
}
}
// IP combined quotas
if self.ip_daily > 0 {
let (tx, rx) = self.db.get_ip_daily_usage(&ip_str).unwrap_or((0, 0));
cap(&mut budget, self.ip_daily, tx + rx);
}
if self.ip_weekly > 0 {
let (tx, rx) = self.db.get_ip_weekly_usage(&ip_str).unwrap_or((0, 0));
cap(&mut budget, self.ip_weekly, tx + rx);
}
if self.ip_monthly > 0 {
let (tx, rx) = self.db.get_ip_monthly_usage(&ip_str).unwrap_or((0, 0));
cap(&mut budget, self.ip_monthly, tx + rx);
}
// IP directional quotas — use inbound + outbound as combined ceiling
if self.ip_daily_inbound > 0 {
let used = self.db.get_ip_daily_inbound(&ip_str).unwrap_or(0);
cap(&mut budget, self.ip_daily_inbound, used);
}
if self.ip_daily_outbound > 0 {
let used = self.db.get_ip_daily_outbound(&ip_str).unwrap_or(0);
cap(&mut budget, self.ip_daily_outbound, used);
}
if self.ip_weekly_inbound > 0 {
let used = self.db.get_ip_weekly_inbound(&ip_str).unwrap_or(0);
cap(&mut budget, self.ip_weekly_inbound, used);
}
if self.ip_weekly_outbound > 0 {
let used = self.db.get_ip_weekly_outbound(&ip_str).unwrap_or(0);
cap(&mut budget, self.ip_weekly_outbound, used);
}
if self.ip_monthly_inbound > 0 {
let used = self.db.get_ip_monthly_inbound(&ip_str).unwrap_or(0);
cap(&mut budget, self.ip_monthly_inbound, used);
}
if self.ip_monthly_outbound > 0 {
let used = self.db.get_ip_monthly_outbound(&ip_str).unwrap_or(0);
cap(&mut budget, self.ip_monthly_outbound, used);
}
budget
}
pub fn max_duration(&self) -> u64 {
self.max_duration
}
pub fn active_connections_count(&self, ip: &IpAddr) -> u32 {
let conns = self.active_connections.lock().unwrap();
conns.get(ip).copied().unwrap_or(0)
}
}