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

@@ -29,6 +29,39 @@ pub struct UsageRecord {
pub test_count: u32,
}
/// Per-second bandwidth interval data for graphing.
#[derive(Debug, Clone)]
pub struct IntervalData {
pub interval_num: i32,
pub tx_mbps: f64,
pub rx_mbps: f64,
pub local_cpu: i32,
pub remote_cpu: i32,
pub lost: i64,
}
/// Summary of a single test session.
#[derive(Debug, Clone)]
pub struct SessionSummary {
pub id: i64,
pub started_at: String,
pub ended_at: Option<String>,
pub protocol: String,
pub direction: String,
pub tx_bytes: u64,
pub rx_bytes: u64,
}
/// Aggregate statistics for an IP address.
#[derive(Debug, Clone)]
pub struct IpStats {
pub total_tests: u64,
pub total_inbound: u64,
pub total_outbound: u64,
pub avg_tx_mbps: f64,
pub avg_rx_mbps: f64,
}
impl UserDb {
pub fn open(path: &str) -> anyhow::Result<Self> {
let conn = Connection::open(path)?;
@@ -65,8 +98,8 @@ impl UserDb {
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip TEXT NOT NULL,
date TEXT NOT NULL,
tx_bytes INTEGER DEFAULT 0,
rx_bytes INTEGER DEFAULT 0,
inbound_bytes INTEGER DEFAULT 0,
outbound_bytes INTEGER DEFAULT 0,
test_count INTEGER DEFAULT 0,
UNIQUE(ip, date)
);
@@ -83,9 +116,24 @@ impl UserDb {
direction TEXT
);
CREATE TABLE IF NOT EXISTS test_intervals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL,
interval_num INTEGER NOT NULL,
tx_bytes INTEGER DEFAULT 0,
rx_bytes INTEGER DEFAULT 0,
tx_mbps REAL DEFAULT 0,
rx_mbps REAL DEFAULT 0,
local_cpu INTEGER DEFAULT 0,
remote_cpu INTEGER DEFAULT 0,
lost_packets INTEGER DEFAULT 0,
FOREIGN KEY(session_id) REFERENCES sessions(id)
);
CREATE INDEX IF NOT EXISTS idx_usage_user_date ON usage(username, date);
CREATE INDEX IF NOT EXISTS idx_ip_usage_date ON ip_usage(ip, date);
CREATE INDEX IF NOT EXISTS idx_sessions_peer ON sessions(peer_ip, started_at);
CREATE INDEX IF NOT EXISTS idx_intervals_session ON test_intervals(session_id);
")?;
Ok(())
}
@@ -197,14 +245,18 @@ impl UserDb {
pub fn record_ip_usage(&self, ip: &str, tx_bytes: u64, rx_bytes: u64) -> anyhow::Result<()> {
let conn = self.conn.lock().unwrap();
let today = chrono_date_today();
// From the server's perspective: inbound = data coming FROM the client (rx),
// outbound = data going TO the client (tx).
let inbound = rx_bytes;
let outbound = tx_bytes;
conn.execute(
"INSERT INTO ip_usage (ip, date, tx_bytes, rx_bytes, test_count)
"INSERT INTO ip_usage (ip, date, inbound_bytes, outbound_bytes, test_count)
VALUES (?1, ?2, ?3, ?4, 1)
ON CONFLICT(ip, date) DO UPDATE SET
tx_bytes = tx_bytes + ?3,
rx_bytes = rx_bytes + ?4,
inbound_bytes = inbound_bytes + ?3,
outbound_bytes = outbound_bytes + ?4,
test_count = test_count + 1",
params![ip, today, tx_bytes as i64, rx_bytes as i64],
params![ip, today, inbound as i64, outbound as i64],
)?;
Ok(())
}
@@ -213,12 +265,12 @@ impl UserDb {
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 ip_usage WHERE ip = ?1 AND date = ?2",
"SELECT COALESCE(SUM(inbound_bytes),0), COALESCE(SUM(outbound_bytes),0) FROM ip_usage WHERE ip = ?1 AND date = ?2",
params![ip, today],
|row| {
let a: i64 = row.get(0)?;
let b: i64 = row.get(1)?;
Ok((a as u64, b as u64))
let inbound: i64 = row.get(0)?;
let outbound: i64 = row.get(1)?;
Ok((inbound as u64, outbound as u64))
},
)?;
Ok(result)
@@ -227,13 +279,13 @@ impl UserDb {
pub fn get_ip_weekly_usage(&self, ip: &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 ip_usage
"SELECT COALESCE(SUM(inbound_bytes),0), COALESCE(SUM(outbound_bytes),0) FROM ip_usage
WHERE ip = ?1 AND date >= date('now', '-7 days')",
params![ip],
|row| {
let a: i64 = row.get(0)?;
let b: i64 = row.get(1)?;
Ok((a as u64, b as u64))
let inbound: i64 = row.get(0)?;
let outbound: i64 = row.get(1)?;
Ok((inbound as u64, outbound as u64))
},
)?;
Ok(result)
@@ -242,18 +294,116 @@ impl UserDb {
pub fn get_ip_monthly_usage(&self, ip: &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 ip_usage
"SELECT COALESCE(SUM(inbound_bytes),0), COALESCE(SUM(outbound_bytes),0) FROM ip_usage
WHERE ip = ?1 AND date >= date('now', '-30 days')",
params![ip],
|row| {
let a: i64 = row.get(0)?;
let b: i64 = row.get(1)?;
Ok((a as u64, b as u64))
let inbound: i64 = row.get(0)?;
let outbound: i64 = row.get(1)?;
Ok((inbound as u64, outbound as u64))
},
)?;
Ok(result)
}
// --- Per-IP directional usage (single-column queries) ---
/// Record inbound-only IP usage (data coming FROM the client).
pub fn record_ip_inbound_usage(&self, ip: &str, bytes: u64) -> anyhow::Result<()> {
let conn = self.conn.lock().unwrap();
let today = chrono_date_today();
conn.execute(
"INSERT INTO ip_usage (ip, date, inbound_bytes, test_count)
VALUES (?1, ?2, ?3, 0)
ON CONFLICT(ip, date) DO UPDATE SET
inbound_bytes = inbound_bytes + ?3",
params![ip, today, bytes as i64],
)?;
Ok(())
}
/// Record outbound-only IP usage (data going TO the client).
pub fn record_ip_outbound_usage(&self, ip: &str, bytes: u64) -> anyhow::Result<()> {
let conn = self.conn.lock().unwrap();
let today = chrono_date_today();
conn.execute(
"INSERT INTO ip_usage (ip, date, outbound_bytes, test_count)
VALUES (?1, ?2, ?3, 0)
ON CONFLICT(ip, date) DO UPDATE SET
outbound_bytes = outbound_bytes + ?3",
params![ip, today, bytes as i64],
)?;
Ok(())
}
/// Get daily inbound bytes for an IP.
pub fn get_ip_daily_inbound(&self, ip: &str) -> anyhow::Result<u64> {
let conn = self.conn.lock().unwrap();
let today = chrono_date_today();
let result: i64 = conn.query_row(
"SELECT COALESCE(SUM(inbound_bytes),0) FROM ip_usage WHERE ip = ?1 AND date = ?2",
params![ip, today],
|row| row.get(0),
)?;
Ok(result as u64)
}
/// Get weekly inbound bytes for an IP.
pub fn get_ip_weekly_inbound(&self, ip: &str) -> anyhow::Result<u64> {
let conn = self.conn.lock().unwrap();
let result: i64 = conn.query_row(
"SELECT COALESCE(SUM(inbound_bytes),0) FROM ip_usage WHERE ip = ?1 AND date >= date('now', '-7 days')",
params![ip],
|row| row.get(0),
)?;
Ok(result as u64)
}
/// Get monthly inbound bytes for an IP.
pub fn get_ip_monthly_inbound(&self, ip: &str) -> anyhow::Result<u64> {
let conn = self.conn.lock().unwrap();
let result: i64 = conn.query_row(
"SELECT COALESCE(SUM(inbound_bytes),0) FROM ip_usage WHERE ip = ?1 AND date >= date('now', '-30 days')",
params![ip],
|row| row.get(0),
)?;
Ok(result as u64)
}
/// Get daily outbound bytes for an IP.
pub fn get_ip_daily_outbound(&self, ip: &str) -> anyhow::Result<u64> {
let conn = self.conn.lock().unwrap();
let today = chrono_date_today();
let result: i64 = conn.query_row(
"SELECT COALESCE(SUM(outbound_bytes),0) FROM ip_usage WHERE ip = ?1 AND date = ?2",
params![ip, today],
|row| row.get(0),
)?;
Ok(result as u64)
}
/// Get weekly outbound bytes for an IP.
pub fn get_ip_weekly_outbound(&self, ip: &str) -> anyhow::Result<u64> {
let conn = self.conn.lock().unwrap();
let result: i64 = conn.query_row(
"SELECT COALESCE(SUM(outbound_bytes),0) FROM ip_usage WHERE ip = ?1 AND date >= date('now', '-7 days')",
params![ip],
|row| row.get(0),
)?;
Ok(result as u64)
}
/// Get monthly outbound bytes for an IP.
pub fn get_ip_monthly_outbound(&self, ip: &str) -> anyhow::Result<u64> {
let conn = self.conn.lock().unwrap();
let result: i64 = conn.query_row(
"SELECT COALESCE(SUM(outbound_bytes),0) FROM ip_usage WHERE ip = ?1 AND date >= date('now', '-30 days')",
params![ip],
|row| row.get(0),
)?;
Ok(result as u64)
}
// --- Session tracking ---
pub fn start_session(&self, username: &str, peer_ip: &str, protocol: &str, direction: &str) -> anyhow::Result<i64> {
@@ -274,6 +424,125 @@ impl UserDb {
Ok(())
}
// --- Per-second interval tracking ---
/// Record a single per-second interval data point for a session.
#[allow(clippy::too_many_arguments)]
pub fn record_test_interval(
&self,
session_id: i64,
interval_num: i32,
tx_bytes: u64,
rx_bytes: u64,
tx_mbps: f64,
rx_mbps: f64,
local_cpu: i32,
remote_cpu: i32,
lost: i64,
) -> anyhow::Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO test_intervals (session_id, interval_num, tx_bytes, rx_bytes, tx_mbps, rx_mbps, local_cpu, remote_cpu, lost_packets)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![
session_id,
interval_num,
tx_bytes as i64,
rx_bytes as i64,
tx_mbps,
rx_mbps,
local_cpu,
remote_cpu,
lost,
],
)?;
Ok(())
}
/// Retrieve all interval data points for a given session, ordered by interval number.
pub fn get_session_intervals(&self, session_id: i64) -> anyhow::Result<Vec<IntervalData>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT interval_num, tx_mbps, rx_mbps, local_cpu, remote_cpu, lost_packets
FROM test_intervals WHERE session_id = ?1 ORDER BY interval_num"
)?;
let rows = stmt.query_map(params![session_id], |row| {
Ok(IntervalData {
interval_num: row.get(0)?,
tx_mbps: row.get(1)?,
rx_mbps: row.get(2)?,
local_cpu: row.get(3)?,
remote_cpu: row.get(4)?,
lost: row.get(5)?,
})
})?.filter_map(|r| r.ok()).collect();
Ok(rows)
}
/// Return the last N sessions for a given IP address, most recent first.
pub fn get_ip_sessions(&self, ip: &str, limit: u32) -> anyhow::Result<Vec<SessionSummary>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, started_at, ended_at, protocol, direction, tx_bytes, rx_bytes
FROM sessions WHERE peer_ip = ?1 ORDER BY started_at DESC LIMIT ?2"
)?;
let rows = stmt.query_map(params![ip, limit], |row| {
Ok(SessionSummary {
id: row.get(0)?,
started_at: row.get(1)?,
ended_at: row.get(2)?,
protocol: row.get::<_, Option<String>>(3)?.unwrap_or_default(),
direction: row.get::<_, Option<String>>(4)?.unwrap_or_default(),
tx_bytes: row.get::<_, i64>(5).map(|v| v as u64)?,
rx_bytes: row.get::<_, i64>(6).map(|v| v as u64)?,
})
})?.filter_map(|r| r.ok()).collect();
Ok(rows)
}
/// Return aggregate statistics for an IP address across all sessions.
pub fn get_ip_stats(&self, ip: &str) -> anyhow::Result<IpStats> {
let conn = self.conn.lock().unwrap();
let result = conn.query_row(
"SELECT
COUNT(*) as total_tests,
COALESCE(SUM(inbound_bytes), 0) as total_inbound,
COALESCE(SUM(outbound_bytes), 0) as total_outbound
FROM ip_usage WHERE ip = ?1",
params![ip],
|row| {
let total_tests: i64 = row.get(0)?;
let total_inbound: i64 = row.get(1)?;
let total_outbound: i64 = row.get(2)?;
Ok((total_tests as u64, total_inbound as u64, total_outbound as u64))
},
)?;
// Compute average Mbps from test_intervals joined through sessions
let (avg_tx, avg_rx) = conn.query_row(
"SELECT
COALESCE(AVG(ti.tx_mbps), 0.0),
COALESCE(AVG(ti.rx_mbps), 0.0)
FROM test_intervals ti
INNER JOIN sessions s ON ti.session_id = s.id
WHERE s.peer_ip = ?1",
params![ip],
|row| {
let avg_tx: f64 = row.get(0)?;
let avg_rx: f64 = row.get(1)?;
Ok((avg_tx, avg_rx))
},
)?;
Ok(IpStats {
total_tests: result.0,
total_inbound: result.1,
total_outbound: result.2,
avg_tx_mbps: avg_tx,
avg_rx_mbps: avg_rx,
})
}
pub fn delete_user(&self, username: &str) -> anyhow::Result<bool> {
let conn = self.conn.lock().unwrap();
let rows = conn.execute("DELETE FROM users WHERE username = ?1", params![username])?;