Scaffold btest-server-pro: multi-user, quotas, LDAP

New binary `btest-server-pro` (build with --features pro):
  cargo build --release --features pro --bin btest-server-pro

Modules:
- server_pro/user_db.rs: SQLite user database with usage tracking
  - Users table (username, password_hash, quotas, enabled)
  - Usage table (daily bytes per user)
  - Sessions table (per-connection tracking)
- server_pro/quota.rs: bandwidth quota enforcement
  - Per-user daily/weekly limits
  - Per-IP connection limits
  - Max test duration
- server_pro/ldap_auth.rs: LDAP/AD authentication via ldap3
  - Simple bind authentication
  - Service account search for user DN

CLI flags: --users-db, --ldap-url, --ldap-base-dn, --ldap-bind-dn,
  --ldap-bind-pass, --daily-quota, --weekly-quota, --max-conn-per-ip,
  --max-duration

Binary sizes: btest=1.8MB, btest-server-pro=3.4MB (SQLite bundled)
Standard btest binary unchanged, 58 tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-04-01 14:33:36 +04:00
parent 8c853c3605
commit d2fdc9c6ae
6 changed files with 1692 additions and 1 deletions

1090
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -16,6 +16,15 @@ path = "src/lib.rs"
name = "btest"
path = "src/main.rs"
[[bin]]
name = "btest-server-pro"
path = "src/server_pro/main.rs"
required-features = ["pro"]
[features]
default = []
pro = ["dep:rusqlite", "dep:ldap3"]
[dependencies]
tokio = { version = "1", features = ["full"] }
clap = { version = "4", features = ["derive"] }
@@ -32,6 +41,8 @@ num-traits = "0.2.19"
num-integer = "0.1.46"
sha2 = "0.11.0"
hostname = "0.4.2"
rusqlite = { version = "0.39.0", features = ["bundled"], optional = true }
ldap3 = { version = "0.12.1", optional = true }
[profile.release]
opt-level = 3

View File

@@ -0,0 +1,74 @@
//! LDAP/Active Directory authentication for btest-server-pro.
//!
//! Authenticates users against an LDAP directory using simple bind.
use ldap3::{LdapConnAsync, Scope, SearchEntry};
pub struct LdapConfig {
pub url: String,
pub base_dn: String,
pub bind_dn: Option<String>,
pub bind_pass: Option<String>,
}
pub struct LdapAuth {
config: LdapConfig,
}
impl LdapAuth {
pub fn new(config: LdapConfig) -> Self {
Self { config }
}
/// Authenticate a user by attempting an LDAP bind.
/// Returns Ok(true) if authentication succeeds.
pub async fn authenticate(&self, username: &str, password: &str) -> anyhow::Result<bool> {
let (conn, mut ldap) = LdapConnAsync::new(&self.config.url).await?;
ldap3::drive!(conn);
// If service account configured, bind first to search for user DN
let user_dn = if let (Some(ref bind_dn), Some(ref bind_pass)) =
(&self.config.bind_dn, &self.config.bind_pass)
{
let result = ldap.simple_bind(bind_dn, bind_pass).await?;
if result.rc != 0 {
tracing::warn!("LDAP service bind failed: rc={}", result.rc);
return Ok(false);
}
// Search for the user
let filter = format!(
"(&(objectClass=person)(|(uid={})(sAMAccountName={})(cn={})))",
username, username, username
);
let (results, _) = ldap
.search(&self.config.base_dn, Scope::Subtree, &filter, vec!["dn"])
.await?
.success()?;
if results.is_empty() {
tracing::debug!("LDAP user not found: {}", username);
return Ok(false);
}
let entry = SearchEntry::construct(results.into_iter().next().unwrap());
entry.dn
} else {
// No service account — construct DN directly
format!("uid={},{}", username, self.config.base_dn)
};
// Attempt user bind
let result = ldap.simple_bind(&user_dn, password).await?;
let success = result.rc == 0;
if success {
tracing::info!("LDAP auth successful for {} (dn={})", username, user_dn);
} else {
tracing::warn!("LDAP auth failed for {} (dn={}): rc={}", username, user_dn, result.rc);
}
let _ = ldap.unbind().await;
Ok(success)
}
}

158
src/server_pro/main.rs Normal file
View File

@@ -0,0 +1,158 @@
//! btest-server-pro: MikroTik Bandwidth Test server with multi-user, quotas, and LDAP.
//!
//! This is a superset of the standard `btest` server with additional features:
//! - SQLite user database (--users-db)
//! - Per-user and per-IP bandwidth quotas (daily/weekly)
//! - LDAP/Active Directory authentication (--ldap-url)
//! - Rate limiting for public server deployment
//!
//! Build with: cargo build --release --features pro --bin btest-server-pro
mod user_db;
mod quota;
mod ldap_auth;
use clap::Parser;
use tracing_subscriber::EnvFilter;
#[derive(Parser, Debug)]
#[command(
name = "btest-server-pro",
about = "btest-rs Pro Server: multi-user, quotas, LDAP",
version,
)]
struct Cli {
/// Listen port
#[arg(short = 'P', long = "port", default_value_t = 2000)]
port: u16,
/// IPv4 listen address
#[arg(long = "listen", default_value = "0.0.0.0")]
listen_addr: String,
/// IPv6 listen address (optional)
#[arg(long = "listen6")]
listen6_addr: Option<String>,
/// SQLite user database path
#[arg(long = "users-db", default_value = "btest-users.db")]
users_db: String,
/// LDAP server URL (e.g., ldap://dc.example.com)
#[arg(long = "ldap-url")]
ldap_url: Option<String>,
/// LDAP base DN for user search
#[arg(long = "ldap-base-dn")]
ldap_base_dn: Option<String>,
/// LDAP bind DN (for service account)
#[arg(long = "ldap-bind-dn")]
ldap_bind_dn: Option<String>,
/// LDAP bind password
#[arg(long = "ldap-bind-pass")]
ldap_bind_pass: Option<String>,
/// Default daily quota per user in bytes (0 = unlimited)
#[arg(long = "daily-quota", default_value_t = 0)]
daily_quota: u64,
/// Default weekly quota per user in bytes (0 = unlimited)
#[arg(long = "weekly-quota", default_value_t = 0)]
weekly_quota: u64,
/// Maximum concurrent connections per IP (0 = unlimited)
#[arg(long = "max-conn-per-ip", default_value_t = 5)]
max_conn_per_ip: u32,
/// Maximum test duration in seconds (0 = unlimited)
#[arg(long = "max-duration", default_value_t = 300)]
max_duration: u64,
/// Use EC-SRP5 authentication
#[arg(long = "ecsrp5")]
ecsrp5: bool,
/// Syslog server address
#[arg(long = "syslog")]
syslog: Option<String>,
/// CSV output file
#[arg(long = "csv")]
csv: Option<String>,
/// Verbose logging
#[arg(short = 'v', long = "verbose", action = clap::ArgAction::Count)]
verbose: u8,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
let filter = match cli.verbose {
0 => "info",
1 => "debug",
_ => "trace",
};
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(filter)),
)
.with_target(false)
.init();
// Initialize subsystems
btest_rs::cpu::start_sampler();
if let Some(ref syslog_addr) = cli.syslog {
if let Err(e) = btest_rs::syslog_logger::init(syslog_addr) {
eprintln!("Warning: syslog init failed: {}", e);
}
}
if let Some(ref csv_path) = cli.csv {
if let Err(e) = btest_rs::csv_output::init(csv_path) {
eprintln!("Warning: CSV init failed: {}", e);
}
}
// Initialize user database
tracing::info!("Opening user database: {}", cli.users_db);
let db = user_db::UserDb::open(&cli.users_db)?;
db.ensure_tables()?;
tracing::info!("User database ready ({} users)", db.user_count()?);
// Initialize LDAP if configured
if let Some(ref url) = cli.ldap_url {
tracing::info!("LDAP configured: {}", url);
}
// Initialize quota manager
let quota_mgr = quota::QuotaManager::new(
db.clone(),
cli.daily_quota,
cli.weekly_quota,
cli.max_conn_per_ip,
cli.max_duration,
);
tracing::info!(
"Quotas: daily={}, weekly={}, max_conn_per_ip={}, max_duration={}s",
if cli.daily_quota == 0 { "unlimited".to_string() } else { format!("{}", cli.daily_quota) },
if cli.weekly_quota == 0 { "unlimited".to_string() } else { format!("{}", cli.weekly_quota) },
cli.max_conn_per_ip,
cli.max_duration,
);
tracing::info!("btest-server-pro starting on port {}", cli.port);
// TODO: Run the enhanced server loop with quota checks and multi-user auth
// For now, delegate to the standard server
let v4 = if cli.listen_addr.eq_ignore_ascii_case("none") { None } else { Some(cli.listen_addr) };
let v6 = cli.listen6_addr;
btest_rs::server::run_server(cli.port, None, None, cli.ecsrp5, v4, v6).await?;
Ok(())
}

140
src/server_pro/quota.rs Normal file
View File

@@ -0,0 +1,140 @@
//! Bandwidth quota management for btest-server-pro.
//!
//! Enforces per-user and per-IP bandwidth limits.
use std::collections::HashMap;
use std::net::IpAddr;
use std::sync::{Arc, Mutex};
use super::user_db::UserDb;
#[derive(Clone)]
pub struct QuotaManager {
db: UserDb,
default_daily: u64,
default_weekly: u64,
max_conn_per_ip: u32,
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 },
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, "Daily quota exceeded: {}/{} bytes", used, limit),
Self::WeeklyExceeded { used, limit } =>
write!(f, "Weekly 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 {
pub fn new(
db: UserDb,
default_daily: u64,
default_weekly: u64,
max_conn_per_ip: u32,
max_duration: u64,
) -> Self {
Self {
db,
default_daily,
default_weekly,
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);
}
// Check daily quota
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 });
}
}
// Check weekly quota
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 });
}
}
Ok(())
}
/// Check if an IP is allowed to connect.
pub fn check_ip(&self, ip: &IpAddr) -> Result<(), QuotaError> {
if self.max_conn_per_ip == 0 {
return Ok(());
}
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,
});
}
Ok(())
}
/// Register an active connection from an IP.
pub fn connect(&self, ip: &IpAddr) {
let mut conns = self.active_connections.lock().unwrap();
*conns.entry(*ip).or_insert(0) += 1;
}
/// Unregister a connection from an IP.
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.
pub fn record_usage(&self, username: &str, tx_bytes: u64, rx_bytes: u64) {
if let Err(e) = self.db.record_usage(username, tx_bytes, rx_bytes) {
tracing::error!("Failed to record usage for {}: {}", username, e);
}
}
/// Get the maximum test duration in seconds.
pub fn max_duration(&self) -> u64 {
self.max_duration
}
}

220
src/server_pro/user_db.rs Normal file
View File

@@ -0,0 +1,220 @@
//! SQLite-based user database for btest-server-pro.
//!
//! Stores users with credentials, quotas, and usage tracking.
use rusqlite::{Connection, params};
use std::sync::{Arc, Mutex};
#[derive(Clone)]
pub struct UserDb {
conn: Arc<Mutex<Connection>>,
}
#[derive(Debug, Clone)]
pub struct User {
pub id: i64,
pub username: String,
pub password_hash: String, // stored as hex of SHA256(username:password)
pub daily_quota: i64, // 0 = use default
pub weekly_quota: i64, // 0 = use default
pub enabled: bool,
}
#[derive(Debug)]
pub struct UsageRecord {
pub username: String,
pub date: String, // YYYY-MM-DD
pub tx_bytes: u64,
pub rx_bytes: u64,
pub test_count: u32,
}
impl UserDb {
pub fn open(path: &str) -> anyhow::Result<Self> {
let conn = Connection::open(path)?;
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;")?;
Ok(Self {
conn: Arc::new(Mutex::new(conn)),
})
}
pub fn ensure_tables(&self) -> anyhow::Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute_batch("
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
daily_quota INTEGER DEFAULT 0,
weekly_quota INTEGER DEFAULT 0,
enabled INTEGER DEFAULT 1,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS usage (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL,
date TEXT NOT NULL,
tx_bytes INTEGER DEFAULT 0,
rx_bytes INTEGER DEFAULT 0,
test_count INTEGER DEFAULT 0,
UNIQUE(username, date)
);
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL,
peer_ip TEXT NOT NULL,
started_at TEXT DEFAULT (datetime('now')),
ended_at TEXT,
tx_bytes INTEGER DEFAULT 0,
rx_bytes INTEGER DEFAULT 0,
protocol TEXT,
direction TEXT
);
CREATE INDEX IF NOT EXISTS idx_usage_user_date ON usage(username, date);
CREATE INDEX IF NOT EXISTS idx_sessions_peer ON sessions(peer_ip, started_at);
")?;
Ok(())
}
pub fn user_count(&self) -> anyhow::Result<u64> {
let conn = self.conn.lock().unwrap();
let count: i64 = conn.query_row("SELECT COUNT(*) FROM users", [], |r| r.get(0))?;
Ok(count as u64)
}
pub fn add_user(&self, username: &str, password: &str) -> anyhow::Result<()> {
let hash = hash_password(username, password);
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT OR REPLACE INTO users (username, password_hash) VALUES (?1, ?2)",
params![username, hash],
)?;
Ok(())
}
pub fn get_user(&self, username: &str) -> anyhow::Result<Option<User>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, username, password_hash, daily_quota, weekly_quota, enabled FROM users WHERE username = ?1"
)?;
let user = stmt.query_row(params![username], |row| {
Ok(User {
id: row.get(0)?,
username: row.get(1)?,
password_hash: row.get(2)?,
daily_quota: row.get(3)?,
weekly_quota: row.get(4)?,
enabled: row.get::<_, i32>(5)? != 0,
})
}).optional()?;
Ok(user)
}
pub fn verify_password(&self, username: &str, password: &str) -> anyhow::Result<bool> {
let expected = hash_password(username, password);
match self.get_user(username)? {
Some(user) => Ok(user.enabled && user.password_hash == expected),
None => Ok(false),
}
}
pub fn record_usage(&self, username: &str, tx_bytes: u64, rx_bytes: u64) -> anyhow::Result<()> {
let conn = self.conn.lock().unwrap();
let today = chrono_date_today();
conn.execute(
"INSERT INTO usage (username, date, tx_bytes, rx_bytes, test_count)
VALUES (?1, ?2, ?3, ?4, 1)
ON CONFLICT(username, date) DO UPDATE SET
tx_bytes = tx_bytes + ?3,
rx_bytes = rx_bytes + ?4,
test_count = test_count + 1",
params![username, today, tx_bytes as i64, rx_bytes as i64],
)?;
Ok(())
}
pub fn get_daily_usage(&self, username: &str) -> anyhow::Result<(u64, u64)> {
let conn = self.conn.lock().unwrap();
let today = chrono_date_today();
let result = conn.query_row(
"SELECT COALESCE(SUM(tx_bytes),0), COALESCE(SUM(rx_bytes),0) FROM usage WHERE username = ?1 AND date = ?2",
params![username, today],
|row| {
let a: i64 = row.get(0)?;
let b: i64 = row.get(1)?;
Ok((a as u64, b as u64))
},
)?;
Ok(result)
}
pub fn get_weekly_usage(&self, username: &str) -> anyhow::Result<(u64, u64)> {
let conn = self.conn.lock().unwrap();
let result = conn.query_row(
"SELECT COALESCE(SUM(tx_bytes),0), COALESCE(SUM(rx_bytes),0) FROM usage
WHERE username = ?1 AND date >= date('now', '-7 days')",
params![username],
|row| {
let a: i64 = row.get(0)?;
let b: i64 = row.get(1)?;
Ok((a as u64, b as u64))
},
)?;
Ok(result)
}
pub fn list_users(&self) -> anyhow::Result<Vec<User>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, username, password_hash, daily_quota, weekly_quota, enabled FROM users ORDER BY username"
)?;
let users = stmt.query_map([], |row| {
Ok(User {
id: row.get(0)?,
username: row.get(1)?,
password_hash: row.get(2)?,
daily_quota: row.get(3)?,
weekly_quota: row.get(4)?,
enabled: row.get::<_, i32>(5)? != 0,
})
})?.filter_map(|r| r.ok()).collect();
Ok(users)
}
}
fn hash_password(username: &str, password: &str) -> String {
use sha2::{Sha256, Digest};
let mut hasher = Sha256::new();
hasher.update(format!("{}:{}", username, password).as_bytes());
let result = hasher.finalize();
result.iter().map(|b| format!("{:02x}", b)).collect()
}
fn chrono_date_today() -> String {
// Simple date without chrono crate
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
let days = secs / 86400;
let mut y = 1970u64;
let mut remaining = days;
loop {
let leap = if y % 4 == 0 && (y % 100 != 0 || y % 400 == 0) { 366 } else { 365 };
if remaining < leap { break; }
remaining -= leap;
y += 1;
}
let leap = y % 4 == 0 && (y % 100 != 0 || y % 400 == 0);
let days_in_months = [31u64, if leap { 29 } else { 28 }, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
let mut m = 0usize;
for i in 0..12 {
if remaining < days_in_months[i] { m = i; break; }
remaining -= days_in_months[i];
}
format!("{:04}-{:02}-{:02}", y, m + 1, remaining + 1)
}
// Re-export for use by rusqlite
use rusqlite::OptionalExtension;