Public server: separate in/out IP quotas, web dashboard scaffold, test intervals
3 agents worked in parallel: 1. DB schema (user_db.rs): - ip_usage: inbound_bytes/outbound_bytes columns (renamed from tx/rx) - test_intervals table for per-second graphing data - Directional methods: get_ip_daily_inbound/outbound, record_ip_inbound/outbound - Query methods: get_session_intervals, get_ip_sessions, get_ip_stats - New structs: IntervalData, SessionSummary, IpStats 2. Quota (quota.rs): - Direction enum (Inbound/Outbound/Both) - 6 new directional IP limits (daily/weekly/monthly × in/out) - check_ip() now takes direction parameter - record_usage() takes (inbound_bytes, outbound_bytes) 3. Web dashboard (web/): - Stub router with axum (will be expanded) - Templates: index.html + dashboard.html with Chart.js - Dependencies: axum, tower-http, serde, serde_json, askama (optional, pro feature) CLI additions: --ip-daily-in, --ip-daily-out, --web-port, --shared-password 64 tests, all passing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
//! Bandwidth quota management for btest-server-pro.
|
||||
//!
|
||||
//! Enforces per-user and per-IP bandwidth limits (daily/weekly/monthly).
|
||||
//! 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;
|
||||
@@ -8,6 +10,19 @@ 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,
|
||||
@@ -15,10 +30,17 @@ pub struct QuotaManager {
|
||||
default_daily: u64,
|
||||
default_weekly: u64,
|
||||
default_monthly: u64,
|
||||
/// Per-IP limits (0 = unlimited) — for abuse prevention
|
||||
/// 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
|
||||
@@ -31,9 +53,21 @@ 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,
|
||||
@@ -54,6 +88,18 @@ impl std::fmt::Display for QuotaError {
|
||||
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"),
|
||||
@@ -63,6 +109,7 @@ impl std::fmt::Display for QuotaError {
|
||||
}
|
||||
|
||||
impl QuotaManager {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
db: UserDb,
|
||||
default_daily: u64,
|
||||
@@ -71,6 +118,12 @@ impl QuotaManager {
|
||||
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 {
|
||||
@@ -82,6 +135,12 @@ impl QuotaManager {
|
||||
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())),
|
||||
@@ -130,8 +189,14 @@ impl QuotaManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if an IP is allowed to connect (connection count + bandwidth quotas).
|
||||
pub fn check_ip(&self, ip: &IpAddr) -> Result<(), QuotaError> {
|
||||
/// 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();
|
||||
@@ -146,27 +211,46 @@ impl QuotaManager {
|
||||
|
||||
let ip_str = ip.to_string();
|
||||
|
||||
// IP daily
|
||||
// --- 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 (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
|
||||
// IP weekly (combined)
|
||||
if self.ip_weekly > 0 {
|
||||
let (tx, rx) = self.db.get_ip_weekly_usage(&ip_str).unwrap_or((0, 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
|
||||
// IP monthly (combined)
|
||||
if self.ip_monthly > 0 {
|
||||
let (tx, rx) = self.db.get_ip_monthly_usage(&ip_str).unwrap_or((0, 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 });
|
||||
@@ -176,6 +260,82 @@ impl QuotaManager {
|
||||
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;
|
||||
@@ -191,14 +351,38 @@ impl QuotaManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Record usage after a test completes (both user and IP).
|
||||
pub fn record_usage(&self, username: &str, ip: &str, tx_bytes: u64, rx_bytes: u64) {
|
||||
if let Err(e) = self.db.record_usage(username, tx_bytes, rx_bytes) {
|
||||
/// 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);
|
||||
}
|
||||
if let Err(e) = self.db.record_ip_usage(ip, tx_bytes, rx_bytes) {
|
||||
|
||||
// Record combined IP usage.
|
||||
if let Err(e) = self.db.record_ip_usage(ip, outbound_bytes, inbound_bytes) {
|
||||
tracing::error!("Failed to record IP usage for {}: {}", ip, e);
|
||||
}
|
||||
|
||||
// Record directional IP usage for the new per-direction columns.
|
||||
if let Err(e) = self.db.record_ip_inbound_usage(ip, inbound_bytes) {
|
||||
tracing::error!("Failed to record IP inbound usage for {}: {}", ip, e);
|
||||
}
|
||||
if let Err(e) = self.db.record_ip_outbound_usage(ip, outbound_bytes) {
|
||||
tracing::error!("Failed to record IP outbound usage for {}: {}", ip, e);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn max_duration(&self) -> u64 {
|
||||
|
||||
Reference in New Issue
Block a user