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:
Siavash Sameni
2026-04-01 16:30:18 +04:00
parent 9e3cd6d6d4
commit 2087e5a75f
10 changed files with 1992 additions and 47 deletions

View File

@@ -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 {