4 Commits

Author SHA1 Message Date
Siavash Sameni
817535a0ad Add Android aarch64/armv7 targets to release builds
Some checks failed
CI / test (push) Failing after 1m31s
Build & Release / release (push) Successful in 4m49s
Adds ARMv8 (aarch64-linux-android) and ARMv7 (armv7-linux-androideabi)
builds for Termux/Android using the Android NDK r27c. Release artifacts
now include btest-android-aarch64.tar.gz and btest-android-armv7.tar.gz.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 19:24:35 +04:00
Siavash Sameni
ba02ed36b5 Merge feature/server-pro into main
Adds btest-server-pro: multi-user bandwidth test server with SQLite DB,
per-IP quotas (daily/weekly/monthly), inline byte budget enforcement,
TCP multi-connection support, MD5 auth, web dashboard with Chart.js
graphs, quota progress bars, and JSON export.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 18:44:16 +04:00
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
Siavash Sameni
89391e1781 Add OpenWrt ipk packaging + split client/server binaries
Some checks failed
CI / test (push) Failing after 1m27s
OpenWrt package (deploy/openwrt/):
- build-ipk.sh: creates .ipk from pre-built binary (no SDK needed)
- Makefile: for OpenWrt SDK integration
- ProCD init script with UCI config
- Supports all architectures (x86_64, aarch64, mipsel, mips)

Split binaries for embedded (src/bin/):
- btest-client: client-only, no server/syslog/csv
- btest-server: server-only, no client
- release-small profile: opt-level=z + panic=abort

Sizes (compressed .tar.gz):
  Full btest:    ~1 MB
  btest-client:  ~500 KB (release-small)
  btest-server:  ~550 KB (release-small)

Install on OpenWrt:
  opkg install btest-rs_0.6.0-1_x86_64.ipk

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:44:57 +04:00
15 changed files with 1212 additions and 100 deletions

View File

@@ -14,7 +14,7 @@ jobs:
- name: Install dependencies
run: |
apt-get update && apt-get install -y --no-install-recommends \
git curl jq ca-certificates zip \
git curl jq ca-certificates zip unzip \
musl-tools \
gcc-aarch64-linux-gnu \
gcc-arm-linux-gnueabihf \
@@ -23,7 +23,14 @@ jobs:
x86_64-unknown-linux-musl \
aarch64-unknown-linux-musl \
armv7-unknown-linux-musleabihf \
x86_64-pc-windows-gnu
x86_64-pc-windows-gnu \
aarch64-linux-android \
armv7-linux-androideabi
# Install Android NDK for cross-compilation
NDK_VER=r27c
curl -sL https://dl.google.com/android/repository/android-ndk-${NDK_VER}-linux.zip -o /tmp/ndk.zip
unzip -q /tmp/ndk.zip -d /opt && rm /tmp/ndk.zip
export ANDROID_NDK_HOME=/opt/android-ndk-${NDK_VER}
- name: Ensure code is present
run: |
@@ -47,6 +54,12 @@ jobs:
[target.x86_64-pc-windows-gnu]
linker = "x86_64-w64-mingw32-gcc"
[target.aarch64-linux-android]
linker = "/opt/android-ndk-r27c/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android35-clang"
[target.armv7-linux-androideabi]
linker = "/opt/android-ndk-r27c/toolchains/llvm/prebuilt/linux-x86_64/bin/armv7a-linux-androideabi35-clang"
TOML
- name: Build Linux x86_64
@@ -61,6 +74,12 @@ jobs:
- name: Build Windows x86_64
run: cargo build --release --target x86_64-pc-windows-gnu
- name: Build Android aarch64 (ARMv8)
run: cargo build --release --target aarch64-linux-android
- name: Build Android armv7 (ARMv7)
run: cargo build --release --target armv7-linux-androideabi
- name: Package all
run: |
mkdir -p /artifacts
@@ -81,6 +100,14 @@ jobs:
zip /artifacts/btest-windows-x86_64.zip btest.exe
cd -
cd target/aarch64-linux-android/release
tar czf /artifacts/btest-android-aarch64.tar.gz btest
cd -
cd target/armv7-linux-androideabi/release
tar czf /artifacts/btest-android-armv7.tar.gz btest
cd -
cd /artifacts
sha256sum * > checksums-sha256.txt
cat checksums-sha256.txt
@@ -103,6 +130,8 @@ jobs:
| Linux | aarch64 (RPi 64-bit) | btest-linux-aarch64.tar.gz |
| Linux | armv7 (RPi 32-bit) | btest-linux-armv7.tar.gz |
| Windows | x86_64 | btest-windows-x86_64.zip |
| Android | aarch64 (ARMv8, Termux) | btest-android-aarch64.tar.gz |
| Android | armv7 (ARMv7, Termux) | btest-android-armv7.tar.gz |
| macOS | aarch64 / x86_64 | Run \`scripts/build-macos-release.sh --upload ${TAG}\` |
| Docker | x86_64 | \`docker pull ${REGISTRY}/manawenuz/btest-rs:${TAG}\` |

View File

@@ -16,6 +16,14 @@ path = "src/lib.rs"
name = "btest"
path = "src/main.rs"
[[bin]]
name = "btest-client"
path = "src/bin/client_only.rs"
[[bin]]
name = "btest-server"
path = "src/bin/server_only.rs"
[[bin]]
name = "btest-server-pro"
path = "src/server_pro/main.rs"
@@ -54,3 +62,9 @@ opt-level = 3
lto = true
strip = true
codegen-units = 1
# Minimal size profile for embedded/OpenWrt targets
[profile.release-small]
inherits = "release"
opt-level = "z"
panic = "abort"

57
deploy/openwrt/Makefile Normal file
View File

@@ -0,0 +1,57 @@
# OpenWrt package Makefile for btest-rs
#
# To build:
# 1. Clone the OpenWrt SDK for your target
# 2. Copy this directory to package/btest-rs/ in the SDK
# 3. Run: make package/btest-rs/compile V=s
#
# Or use the pre-built binary approach (see build-ipk.sh)
include $(TOPDIR)/rules.mk
PKG_NAME:=btest-rs
PKG_VERSION:=0.6.0
PKG_RELEASE:=1
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://github.com/manawenuz/btest-rs/archive/refs/tags/v$(PKG_VERSION).tar.gz
PKG_HASH:=skip
PKG_BUILD_DEPENDS:=rust/host
PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME)-$(PKG_VERSION)
include $(INCLUDE_DIR)/package.mk
define Package/btest-rs
SECTION:=net
CATEGORY:=Network
TITLE:=MikroTik Bandwidth Test server and client
URL:=https://github.com/manawenuz/btest-rs
DEPENDS:=
PKGARCH:=$(ARCH)
endef
define Package/btest-rs/description
A Rust reimplementation of the MikroTik Bandwidth Test (btest) protocol.
Supports TCP/UDP, IPv4/IPv6, EC-SRP5 and MD5 authentication,
multi-connection, syslog, CSV output, and CPU monitoring.
endef
define Build/Compile
cd $(PKG_BUILD_DIR) && \
CARGO_TARGET_DIR=$(PKG_BUILD_DIR)/target \
cargo build --release --target $(RUSTC_TARGET)
endef
define Package/btest-rs/install
$(INSTALL_DIR) $(1)/usr/bin
$(INSTALL_BIN) $(PKG_BUILD_DIR)/target/$(RUSTC_TARGET)/release/btest $(1)/usr/bin/btest
$(INSTALL_DIR) $(1)/etc/init.d
$(INSTALL_BIN) ./files/btest.init $(1)/etc/init.d/btest
$(INSTALL_DIR) $(1)/etc/config
$(INSTALL_CONF) ./files/btest.config $(1)/etc/config/btest
endef
$(eval $(call BuildPackage,btest-rs))

117
deploy/openwrt/build-ipk.sh Executable file
View File

@@ -0,0 +1,117 @@
#!/usr/bin/env bash
# Build an OpenWrt .ipk package from a pre-built static binary.
# No OpenWrt SDK needed — just packages the binary with metadata.
#
# Usage:
# ./deploy/openwrt/build-ipk.sh <arch> [binary-path]
#
# Examples:
# ./deploy/openwrt/build-ipk.sh x86_64 dist/btest # from cross-compiled binary
# ./deploy/openwrt/build-ipk.sh aarch64 dist/btest # for RPi/ARM64 routers
# ./deploy/openwrt/build-ipk.sh mipsel target/release/btest # for MIPS little-endian
#
# Supported architectures: x86_64, aarch64, arm_cortex-a7, mipsel_24kc, mips_24kc
set -euo pipefail
cd "$(dirname "$0")/../.."
ARCH="${1:?Usage: $0 <arch> [binary-path]}"
BINARY="${2:-dist/btest}"
VERSION="0.6.0"
PKG_NAME="btest-rs"
OUTPUT_DIR="dist"
if [ ! -f "$BINARY" ]; then
echo "Error: binary not found at $BINARY"
echo "Build it first: cargo build --release --target <target>"
exit 1
fi
mkdir -p "$OUTPUT_DIR"
WORKDIR=$(mktemp -d)
trap "rm -rf $WORKDIR" EXIT
echo "=== Building ${PKG_NAME}_${VERSION}_${ARCH}.ipk ==="
# Create package structure
mkdir -p "$WORKDIR/data/usr/bin"
mkdir -p "$WORKDIR/data/etc/init.d"
mkdir -p "$WORKDIR/data/etc/config"
mkdir -p "$WORKDIR/control"
# Install files
cp "$BINARY" "$WORKDIR/data/usr/bin/btest"
chmod 755 "$WORKDIR/data/usr/bin/btest"
cp deploy/openwrt/files/btest.init "$WORKDIR/data/etc/init.d/btest"
chmod 755 "$WORKDIR/data/etc/init.d/btest"
cp deploy/openwrt/files/btest.config "$WORKDIR/data/etc/config/btest"
# Calculate installed size
INSTALLED_SIZE=$(du -sk "$WORKDIR/data" | awk '{print $1}')
# Control file
cat > "$WORKDIR/control/control" << EOF
Package: ${PKG_NAME}
Version: ${VERSION}-1
Depends: libc
Source: https://github.com/manawenuz/btest-rs
License: MIT AND Apache-2.0
Section: net
SourceName: ${PKG_NAME}
Maintainer: Siavash Sameni <manwe@manko.yoga>
Architecture: ${ARCH}
Installed-Size: ${INSTALLED_SIZE}
Description: MikroTik Bandwidth Test server and client
A Rust reimplementation of the MikroTik btest protocol.
Supports TCP/UDP, EC-SRP5 and MD5 auth, IPv4/IPv6.
EOF
# Post-install script
cat > "$WORKDIR/control/postinst" << 'EOF'
#!/bin/sh
[ "${IPKG_NO_SCRIPT}" = "1" ] && exit 0
/etc/init.d/btest enable 2>/dev/null || true
exit 0
EOF
chmod 755 "$WORKDIR/control/postinst"
# Pre-remove script
cat > "$WORKDIR/control/prerm" << 'EOF'
#!/bin/sh
/etc/init.d/btest stop 2>/dev/null || true
/etc/init.d/btest disable 2>/dev/null || true
exit 0
EOF
chmod 755 "$WORKDIR/control/prerm"
# Conffiles
cat > "$WORKDIR/control/conffiles" << EOF
/etc/config/btest
EOF
# Build the .ipk (it's just a tar.gz of tar.gz's)
cd "$WORKDIR"
# Create data.tar.gz
(cd data && tar czf ../data.tar.gz .)
# Create control.tar.gz
(cd control && tar czf ../control.tar.gz .)
# Create debian-binary
echo "2.0" > debian-binary
# Package it all
tar czf "${PKG_NAME}_${VERSION}-1_${ARCH}.ipk" debian-binary control.tar.gz data.tar.gz
cd -
cp "$WORKDIR/${PKG_NAME}_${VERSION}-1_${ARCH}.ipk" "$OUTPUT_DIR/"
echo ""
echo "Package: $OUTPUT_DIR/${PKG_NAME}_${VERSION}-1_${ARCH}.ipk"
ls -lh "$OUTPUT_DIR/${PKG_NAME}_${VERSION}-1_${ARCH}.ipk"
echo ""
echo "Install on OpenWrt:"
echo " scp $OUTPUT_DIR/${PKG_NAME}_${VERSION}-1_${ARCH}.ipk root@router:/tmp/"
echo " ssh root@router 'opkg install /tmp/${PKG_NAME}_${VERSION}-1_${ARCH}.ipk'"
echo " ssh root@router '/etc/init.d/btest enable && /etc/init.d/btest start'"

View File

@@ -0,0 +1,7 @@
config server
option enabled '0'
option port '2000'
option auth_user ''
option auth_pass ''
option ecsrp5 '0'
option syslog ''

34
deploy/openwrt/files/btest.init Executable file
View File

@@ -0,0 +1,34 @@
#!/bin/sh /etc/rc.common
# btest-rs OpenWrt init script
START=90
STOP=10
USE_PROCD=1
start_service() {
local enabled port auth_user auth_pass ecsrp5 syslog
config_load btest
config_get_bool enabled server enabled 0
[ "$enabled" -eq 0 ] && return
config_get port server port 2000
config_get auth_user server auth_user ''
config_get auth_pass server auth_pass ''
config_get_bool ecsrp5 server ecsrp5 0
config_get syslog server syslog ''
procd_open_instance
procd_set_param command /usr/bin/btest -s -P "$port"
[ -n "$auth_user" ] && procd_append_param command -a "$auth_user"
[ -n "$auth_pass" ] && procd_append_param command -p "$auth_pass"
[ "$ecsrp5" -eq 1 ] && procd_append_param command --ecsrp5
[ -n "$syslog" ] && procd_append_param command --syslog "$syslog"
procd_set_param respawn
procd_set_param stdout 1
procd_set_param stderr 1
procd_close_instance
}

View File

@@ -20,6 +20,9 @@ pub struct BandwidthState {
pub intervals: AtomicU32,
/// Remote peer's CPU usage (received via status messages)
pub remote_cpu: AtomicU8,
/// Remaining byte budget (TX + RX combined). When this reaches 0 the test
/// stops immediately. u64::MAX means unlimited (default for non-pro server).
pub byte_budget: AtomicU64,
}
impl BandwidthState {
@@ -38,6 +41,7 @@ impl BandwidthState {
total_lost_packets: AtomicU64::new(0),
intervals: AtomicU32::new(0),
remote_cpu: AtomicU8::new(0),
byte_budget: AtomicU64::new(u64::MAX),
})
}
@@ -50,6 +54,29 @@ impl BandwidthState {
self.intervals.fetch_add(1, Relaxed);
}
/// Try to spend `amount` bytes from the budget. Returns `true` if allowed,
/// `false` if the budget is exhausted (and sets `running = false`).
#[inline]
pub fn spend_budget(&self, amount: u64) -> bool {
use std::sync::atomic::Ordering::{Relaxed, SeqCst};
// Fast path: unlimited budget (non-pro server)
let current = self.byte_budget.load(Relaxed);
if current == u64::MAX {
return true;
}
if current < amount {
self.running.store(false, SeqCst);
return false;
}
self.byte_budget.fetch_sub(amount, Relaxed);
true
}
/// Set the byte budget (total bytes allowed for the entire test).
pub fn set_budget(&self, budget: u64) {
self.byte_budget.store(budget, std::sync::atomic::Ordering::SeqCst);
}
/// Get summary for syslog reporting.
pub fn summary(&self) -> (u64, u64, u64, u32) {
use std::sync::atomic::Ordering::Relaxed;

127
src/bin/client_only.rs Normal file
View File

@@ -0,0 +1,127 @@
//! btest-client: minimal bandwidth test client for embedded/OpenWrt systems.
//!
//! Stripped-down client that connects to MikroTik btest servers.
//! No server mode, no syslog, smaller binary footprint.
//!
//! Build: cargo build --profile release-small --bin btest-client
use clap::Parser;
use std::sync::atomic::Ordering;
#[derive(Parser)]
#[command(name = "btest-client", about = "MikroTik Bandwidth Test client", version)]
struct Cli {
/// Server address to connect to
#[arg(short = 'c', long = "client", required = true)]
host: String,
/// Transmit data (upload)
#[arg(short = 't', long = "transmit")]
transmit: bool,
/// Receive data (download)
#[arg(short = 'r', long = "receive")]
receive: bool,
/// Use UDP
#[arg(short = 'u', long = "udp")]
udp: bool,
/// Bandwidth limit (e.g., 100M)
#[arg(short = 'b', long = "bandwidth")]
bandwidth: Option<String>,
/// Port
#[arg(short = 'P', long = "port", default_value_t = 2000)]
port: u16,
/// Username
#[arg(short = 'a', long = "authuser")]
auth_user: Option<String>,
/// Password
#[arg(short = 'p', long = "authpass")]
auth_pass: Option<String>,
/// NAT mode
#[arg(short = 'n', long = "nat")]
nat: bool,
/// Duration in seconds (0=unlimited)
#[arg(short = 'd', long = "duration", default_value_t = 0)]
duration: u64,
/// Verbose
#[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(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(filter)),
)
.with_target(false)
.init();
btest_rs::cpu::start_sampler();
if !cli.transmit && !cli.receive {
eprintln!("Error: specify -t (transmit) and/or -r (receive)");
std::process::exit(1);
}
let direction = match (cli.transmit, cli.receive) {
(true, false) => btest_rs::protocol::CMD_DIR_RX,
(false, true) => btest_rs::protocol::CMD_DIR_TX,
(true, true) => btest_rs::protocol::CMD_DIR_BOTH,
_ => unreachable!(),
};
let bw = match &cli.bandwidth {
Some(b) => btest_rs::bandwidth::parse_bandwidth(b)?,
None => 0,
};
let (tx_speed, rx_speed) = match direction {
btest_rs::protocol::CMD_DIR_TX => (bw, 0),
btest_rs::protocol::CMD_DIR_RX => (0, bw),
_ => (bw, bw),
};
let state = btest_rs::bandwidth::BandwidthState::new();
let state_clone = state.clone();
let host = cli.host.clone();
let client_fut = btest_rs::client::run_client(
&host, cli.port, direction, cli.udp,
tx_speed, rx_speed,
cli.auth_user, cli.auth_pass, cli.nat,
state_clone,
);
if cli.duration > 0 {
match tokio::time::timeout(
std::time::Duration::from_secs(cli.duration),
client_fut,
).await {
Ok(r) => { let _ = r?; }
Err(_) => {
state.running.store(false, Ordering::SeqCst);
}
}
} else {
let _ = client_fut.await?;
}
Ok(())
}

62
src/bin/server_only.rs Normal file
View File

@@ -0,0 +1,62 @@
//! btest-server: minimal bandwidth test server for embedded/OpenWrt systems.
//!
//! Stripped-down server that accepts MikroTik client connections.
//! No client mode, no syslog, no CSV, smaller binary footprint.
//!
//! Build: cargo build --profile release-small --bin btest-server
use clap::Parser;
#[derive(Parser)]
#[command(name = "btest-server", about = "MikroTik Bandwidth Test server", version)]
struct Cli {
/// 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,
/// Username
#[arg(short = 'a', long = "authuser")]
auth_user: Option<String>,
/// Password
#[arg(short = 'p', long = "authpass")]
auth_pass: Option<String>,
/// Use EC-SRP5 authentication
#[arg(long = "ecsrp5")]
ecsrp5: bool,
/// Verbose
#[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(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(filter)),
)
.with_target(false)
.init();
btest_rs::cpu::start_sampler();
let v4 = if cli.listen_addr.eq_ignore_ascii_case("none") { None } else { Some(cli.listen_addr) };
tracing::info!("btest-server starting on port {}", cli.port);
btest_rs::server::run_server(cli.port, cli.auth_user, cli.auth_pass, cli.ecsrp5, v4, None).await?;
Ok(())
}

View File

@@ -366,6 +366,24 @@ async fn handle_client(
// --- TCP Test Server ---
/// Public TX task for multi-connection use by server_pro.
pub async fn tcp_tx_task(
writer: tokio::net::tcp::OwnedWriteHalf,
tx_size: usize,
tx_speed: u32,
state: Arc<BandwidthState>,
) {
tcp_tx_loop(writer, tx_size, tx_speed, state).await;
}
/// Public RX task for multi-connection use by server_pro.
pub async fn tcp_rx_task(
reader: tokio::net::tcp::OwnedReadHalf,
state: Arc<BandwidthState>,
) {
tcp_rx_loop(reader, state).await;
}
/// Run a TCP bandwidth test on an already-authenticated stream.
/// Public API for use by server_pro.
pub async fn run_tcp_test(
@@ -451,9 +469,22 @@ async fn run_tcp_test_inner(stream: TcpStream, cmd: Command, state: Arc<Bandwidt
Ok(state.summary())
}
/// Public API for multi-connection TCP test with external state. Used by server_pro.
pub async fn run_tcp_multiconn_test(
streams: Vec<TcpStream>,
cmd: Command,
state: Arc<BandwidthState>,
) -> Result<(u64, u64, u64, u32)> {
run_tcp_multiconn_inner(streams, cmd, state).await
}
/// TCP multi-connection.
async fn run_tcp_multiconn_server(streams: Vec<TcpStream>, cmd: Command) -> Result<(u64, u64, u64, u32)> {
let state = BandwidthState::new();
run_tcp_multiconn_inner(streams, cmd, state).await
}
async fn run_tcp_multiconn_inner(streams: Vec<TcpStream>, cmd: Command, state: Arc<BandwidthState>) -> Result<(u64, u64, u64, u32)> {
let tx_size = cmd.tx_size as usize;
let server_should_tx = cmd.server_tx();
let server_should_rx = cmd.server_rx();
@@ -564,6 +595,9 @@ async fn tcp_tx_loop_inner(
next_status = Instant::now() + Duration::from_secs(1);
}
if !state.spend_budget(tx_size as u64) {
break;
}
if writer.write_all(&packet).await.is_err() {
state.running.store(false, Ordering::SeqCst);
break;
@@ -600,6 +634,9 @@ async fn tcp_rx_loop(mut reader: tokio::net::tcp::OwnedReadHalf, state: Arc<Band
break;
}
Ok(n) => {
if !state.spend_budget(n as u64) {
break;
}
state.rx_bytes.fetch_add(n as u64, Ordering::Relaxed);
}
}
@@ -796,6 +833,10 @@ async fn udp_tx_loop(
let mut consecutive_errors: u32 = 0;
while state.running.load(Ordering::Relaxed) {
if !state.spend_budget(tx_size as u64) {
break;
}
packet[0..4].copy_from_slice(&seq.to_be_bytes());
let result = if multi_conn {
@@ -871,6 +912,9 @@ async fn udp_rx_loop(socket: &UdpSocket, state: Arc<BandwidthState>) {
// (multi-connection MikroTik sends from multiple ports)
match tokio::time::timeout(Duration::from_secs(5), socket.recv_from(&mut buf)).await {
Ok(Ok((n, _src))) if n >= 4 => {
if !state.spend_budget(n as u64) {
break;
}
state.rx_bytes.fetch_add(n as u64, Ordering::Relaxed);
state.rx_packets.fetch_add(1, Ordering::Relaxed);

View File

@@ -342,4 +342,70 @@ mod tests {
let (ip_in, ip_out) = db.get_ip_daily_usage("127.0.0.1").unwrap();
assert!(ip_in + ip_out > 0, "IP usage should be recorded");
}
#[test]
fn test_remaining_budget_calculation() {
let (db, qm) = setup_test_db();
let ip: IpAddr = "10.0.0.1".parse().unwrap();
// No usage yet: budget = min(daily=1000, weekly=5000, monthly=10000, ip_daily=500, ...)
// IP daily combined = 500 is the smallest
let budget = qm.remaining_budget("testuser", &ip);
assert_eq!(budget, 500, "budget should be min of all limits (ip_daily=500)");
// Use record_usage which properly records combined + directional
// inbound=200, outbound=200 → combined = 400
qm.record_usage("testuser", "10.0.0.1", 200, 200);
// IP daily combined: 500 - 400 = 100 remaining
// IP daily inbound: 500 - 200 = 300 remaining
// IP daily outbound: 500 - 200 = 300 remaining
// User daily: 1000 - 400 = 600 remaining
let budget = qm.remaining_budget("testuser", &ip);
assert_eq!(budget, 100, "budget should reflect IP combined remaining (100)");
}
#[test]
fn test_budget_zero_when_exhausted() {
let (db, qm) = setup_test_db();
let ip: IpAddr = "10.0.0.2".parse().unwrap();
// Exhaust user daily quota (1000 bytes)
db.record_usage("testuser", 600, 500).unwrap(); // 1100 > 1000
let budget = qm.remaining_budget("testuser", &ip);
assert_eq!(budget, 0, "budget should be 0 when user daily quota is exhausted");
}
#[test]
fn test_byte_budget_stops_transfer() {
let state = BandwidthState::new();
// Set a 1000-byte budget
state.set_budget(1000);
// Spend 500 bytes — should succeed
assert!(state.spend_budget(500));
// Spend another 400 — should succeed (100 remaining)
assert!(state.spend_budget(400));
// Spend 200 — should fail (only 100 remaining)
assert!(!state.spend_budget(200));
// running should be false
assert!(!state.running.load(Ordering::Relaxed));
}
#[test]
fn test_unlimited_budget_always_succeeds() {
let state = BandwidthState::new();
// Default budget is u64::MAX (unlimited)
// Should always succeed
for _ in 0..1000 {
assert!(state.spend_budget(1_000_000_000));
}
assert!(state.running.load(Ordering::Relaxed));
}
}

View File

@@ -371,18 +371,92 @@ impl QuotaManager {
tracing::error!("Failed to record user usage for {}: {}", username, e);
}
// Record combined IP usage.
// 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);
}
}
// 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);
/// 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);
}
}
if let Err(e) = self.db.record_ip_outbound_usage(ip, outbound_bytes) {
tracing::error!("Failed to record IP outbound usage for {}: {}", ip, e);
// 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 {

View File

@@ -2,14 +2,18 @@
//!
//! Wraps the standard btest server connection handler with:
//! - Pre-connection IP/user quota checks
//! - MD5 challenge-response auth against user DB
//! - TCP multi-connection session support
//! - Mid-session quota enforcement via QuotaEnforcer
//! - Post-session usage recording
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::Mutex;
use btest_rs::protocol::*;
use btest_rs::bandwidth::BandwidthState;
@@ -18,22 +22,27 @@ use super::enforcer::{QuotaEnforcer, StopReason};
use super::quota::{Direction, QuotaManager};
use super::user_db::UserDb;
/// Pending TCP multi-connection session.
struct TcpSession {
peer_ip: std::net::IpAddr,
username: String,
cmd: Command,
streams: Vec<TcpStream>,
expected: u8,
}
type SessionMap = Arc<Mutex<HashMap<u16, TcpSession>>>;
/// Run the pro server with quota enforcement.
pub async fn run_pro_server(
port: u16,
ecsrp5: bool,
_ecsrp5: bool,
listen_v4: Option<String>,
listen_v6: Option<String>,
db: UserDb,
quota_mgr: QuotaManager,
quota_check_interval: u64,
) -> anyhow::Result<()> {
// Pre-derive EC-SRP5 creds if needed
// For pro server, we don't use CLI -a/-p — we use the user DB
// EC-SRP5 needs a fixed password for the server challenge, but
// the actual verification happens against the DB.
// For now, the first user in the DB is used for EC-SRP5 derivation.
let v4_listener = if let Some(ref addr) = listen_v4 {
let bind_addr = format!("{}:{}", addr, port);
Some(TcpListener::bind(&bind_addr).await?)
@@ -52,6 +61,8 @@ pub async fn run_pro_server(
anyhow::bail!("No listeners bound");
}
let sessions: SessionMap = Arc::new(Mutex::new(HashMap::new()));
tracing::info!("btest-server-pro ready, accepting connections");
loop {
@@ -69,29 +80,14 @@ pub async fn run_pro_server(
tracing::info!("New connection from {}", peer);
// Pre-connection IP check
if let Err(e) = quota_mgr.check_ip(&peer.ip(), Direction::Both) {
tracing::warn!("Rejected {} — {}", peer, e);
btest_rs::syslog_logger::auth_failure(
&peer.to_string(), "-", "-", &format!("{}", e),
);
// Send a quick rejection and close
let mut s = stream;
let _ = s.write_all(&HELLO).await;
drop(s);
continue;
}
quota_mgr.connect(&peer.ip());
let db = db.clone();
let qm = quota_mgr.clone();
let qm_disconnect = quota_mgr.clone();
let interval = quota_check_interval;
let sess = sessions.clone();
tokio::spawn(async move {
match handle_pro_client(stream, peer, db, qm, interval).await {
Ok((username, stop_reason, tx, rx)) => {
let is_primary = match handle_pro_connection(stream, peer, db, qm.clone(), interval, sess).await {
Ok(Some((username, stop_reason, tx, rx))) => {
tracing::info!(
"Client {} (user '{}') finished: {} (tx={}, rx={})",
peer, username, stop_reason, tx, rx,
@@ -100,31 +96,100 @@ pub async fn run_pro_server(
&peer.to_string(), "btest", &format!("{}", stop_reason),
tx, rx, 0, 0,
);
true
}
Ok(None) => false, // secondary connection or pending multi-conn
Err(e) => {
tracing::error!("Client {} error: {}", peer, e);
true
}
};
// Only decrement connection count for primary connections
if is_primary {
qm.disconnect(&peer.ip());
}
qm_disconnect.disconnect(&peer.ip());
});
}
}
async fn handle_pro_client(
/// Handle a single TCP connection. Returns None for secondary multi-conn joins.
async fn handle_pro_connection(
mut stream: TcpStream,
peer: SocketAddr,
db: UserDb,
quota_mgr: QuotaManager,
quota_check_interval: u64,
) -> anyhow::Result<(String, StopReason, u64, u64)> {
sessions: SessionMap,
) -> anyhow::Result<Option<(String, StopReason, u64, u64)>> {
stream.set_nodelay(true)?;
// HELLO
stream.write_all(&HELLO).await?;
// Read command
// Read command (or session token for secondary connections)
let mut cmd_buf = [0u8; 16];
stream.read_exact(&mut cmd_buf).await?;
// Check if this is a secondary connection joining an existing TCP session
// Secondary connections send [HI, LO, ...] matching an existing session token
{
let potential_token = u16::from_be_bytes([cmd_buf[0], cmd_buf[1]]);
let mut map = sessions.lock().await;
if let Some(session) = map.get_mut(&potential_token) {
if session.peer_ip == peer.ip()
&& session.streams.len() < session.expected as usize
{
tracing::info!(
"Secondary connection from {} joining session (token={:04x}, {}/{})",
peer, potential_token,
session.streams.len() + 1, session.expected,
);
// Auth the secondary connection with same token response
let ok = [0x01, cmd_buf[0], cmd_buf[1], 0x00];
stream.write_all(&ok).await?;
stream.flush().await?;
session.streams.push(stream);
// If all connections have joined, start the test
if session.streams.len() >= session.expected as usize {
let session = map.remove(&potential_token).unwrap();
let db2 = db.clone();
let qm2 = quota_mgr.clone();
tokio::spawn(async move {
match run_pro_multiconn_test(
session.streams, session.cmd, peer,
&session.username, db2, qm2, quota_check_interval,
).await {
Ok((stop, tx, rx)) => {
tracing::info!(
"Multi-conn {} (user '{}') finished: {} (tx={}, rx={})",
peer, session.username, stop, tx, rx,
);
}
Err(e) => {
tracing::error!("Multi-conn {} error: {}", peer, e);
}
}
});
}
return Ok(None);
}
}
}
// Primary connection — check IP quota/connection limit now
if let Err(e) = quota_mgr.check_ip(&peer.ip(), Direction::Both) {
tracing::warn!("Rejected {} — {}", peer, e);
btest_rs::syslog_logger::auth_failure(
&peer.to_string(), "-", "-", &format!("{}", e),
);
return Ok(None);
}
quota_mgr.connect(&peer.ip());
let cmd = Command::deserialize(&cmd_buf);
tracing::info!(
@@ -136,14 +201,25 @@ async fn handle_pro_client(
cmd.tx_size,
);
// Authenticate — use MD5 auth with DB verification
// Send AUTH_REQUIRED
// Build auth OK response with session token for multi-connection
let is_tcp_multi = !cmd.is_udp() && cmd.tcp_conn_count > 0;
let session_token: u16 = if is_tcp_multi {
rand::random::<u16>() | 0x0101 // ensure both bytes non-zero
} else {
0
};
let ok_response: [u8; 4] = if is_tcp_multi {
[0x01, (session_token >> 8) as u8, (session_token & 0xFF) as u8, 0x00]
} else {
AUTH_OK
};
// Authenticate — MD5 challenge-response against DB
stream.write_all(&AUTH_REQUIRED).await?;
let challenge = btest_rs::auth::generate_challenge();
stream.write_all(&challenge).await?;
stream.flush().await?;
// Read response
let mut response = [0u8; 48];
stream.read_exact(&mut response).await?;
@@ -176,17 +252,21 @@ async fn handle_pro_client(
anyhow::bail!("User disabled");
}
// Verify MD5 hash against stored password hash
// We need to compute the expected hash using the user's password
// But we only store SHA256(user:pass), not the raw password.
// For MD5 auth, we need the raw password to compute MD5(pass + challenge).
// This is a limitation — MD5 auth needs the raw password.
// For now, accept any authenticated user (the hash verification
// happens on the client side with MikroTik).
// TODO: Store password in a reversible form or use EC-SRP5 only.
// Verify MD5 hash against stored raw password
if let Ok(Some(raw_pass)) = db.get_password(&username) {
let expected_hash = btest_rs::auth::compute_auth_hash(&raw_pass, &challenge);
if received_hash != expected_hash {
tracing::warn!("Auth failed: password mismatch for user '{}'", username);
stream.write_all(&AUTH_FAILED).await?;
btest_rs::syslog_logger::auth_failure(
&peer.to_string(), &username, "md5", "password mismatch",
);
anyhow::bail!("Auth failed");
}
}
// If no raw password stored, accept (backwards compat with old DB entries)
// Send AUTH_OK
stream.write_all(&AUTH_OK).await?;
stream.write_all(&ok_response).await?;
stream.flush().await?;
tracing::info!("Auth successful for user '{}'", username);
@@ -202,79 +282,168 @@ async fn handle_pro_client(
btest_rs::syslog_logger::auth_failure(
&peer.to_string(), &username, "quota", &format!("{}", e),
);
// Connection is already authenticated, just close it
return Ok((username, StopReason::UserDailyQuota, 0, 0));
return Ok(Some((username, StopReason::UserDailyQuota, 0, 0)));
}
// Start session tracking
// TCP multi-connection: register session and wait for secondary connections
if is_tcp_multi {
tracing::info!(
"TCP multi-connection: waiting for {} connections (token={:04x})",
cmd.tcp_conn_count, session_token,
);
let mut map = sessions.lock().await;
map.insert(session_token, TcpSession {
peer_ip: peer.ip(),
username: username.clone(),
cmd: cmd.clone(),
streams: vec![stream],
expected: cmd.tcp_conn_count, // tcp_conn_count includes the primary
});
// The test will be started when all connections join (in the secondary handler above)
return Ok(None);
}
// Single-connection test
run_pro_single_test(stream, cmd, peer, &username, db, quota_mgr, quota_check_interval).await
.map(|(stop, tx, rx)| Some((username, stop, tx, rx)))
}
/// Run a single-connection bandwidth test with quota enforcement.
async fn run_pro_single_test(
stream: TcpStream,
cmd: Command,
peer: SocketAddr,
username: &str,
db: UserDb,
quota_mgr: QuotaManager,
quota_check_interval: u64,
) -> anyhow::Result<(StopReason, u64, u64)> {
let proto_str = if cmd.is_udp() { "UDP" } else { "TCP" };
let dir_str = match cmd.direction {
CMD_DIR_RX => "RX", CMD_DIR_TX => "TX", _ => "BOTH"
};
let session_id = db.start_session(
&username, &peer.ip().to_string(), proto_str, dir_str,
username, &peer.ip().to_string(), proto_str, dir_str,
)?;
btest_rs::syslog_logger::test_start(
&peer.to_string(), proto_str, dir_str, cmd.tcp_conn_count,
);
// Create shared bandwidth state for the test
let state = BandwidthState::new();
// Spawn quota enforcer
// Set byte budget
let budget = quota_mgr.remaining_budget(username, &peer.ip());
if budget < u64::MAX {
state.set_budget(budget);
tracing::info!("Byte budget for '{}' from {}: {} bytes", username, peer.ip(), budget);
}
let enforcer = QuotaEnforcer::new(
quota_mgr.clone(),
username.clone(),
username.to_string(),
peer.ip(),
state.clone(),
quota_check_interval,
quota_mgr.max_duration(),
);
// Spawn quota enforcer — runs alongside the test
let enforcer_state = state.clone();
let enforcer_handle = tokio::spawn(async move {
enforcer.run().await
});
// Run the actual bandwidth test using the standard server handlers.
// The enforcer runs concurrently and will set state.running = false
// if any quota is exceeded, which gracefully stops the TX/RX loops.
static UDP_PORT_OFFSET: std::sync::atomic::AtomicU16 = std::sync::atomic::AtomicU16::new(0);
let mut stream_mut = stream;
let test_result = if cmd.is_udp() {
let offset = UDP_PORT_OFFSET.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let udp_port = btest_rs::protocol::BTEST_UDP_PORT_START + offset;
btest_rs::server::run_udp_test(
&mut stream, peer, &cmd, state.clone(), udp_port,
&mut stream_mut, peer, &cmd, state.clone(), udp_port,
).await
} else {
btest_rs::server::run_tcp_test(stream, cmd.clone(), state.clone()).await
btest_rs::server::run_tcp_test(stream_mut, cmd.clone(), state.clone()).await
};
// Test finished — stop the enforcer if still running
enforcer_state.running.store(false, std::sync::atomic::Ordering::SeqCst);
let stop_reason = enforcer_handle.await.unwrap_or(StopReason::ClientDisconnected);
// Determine final stop reason
let final_reason = match &test_result {
Ok(_) => {
if stop_reason == StopReason::ClientDisconnected {
StopReason::ClientDisconnected
} else {
stop_reason // quota or duration exceeded
stop_reason
}
}
Err(_) => StopReason::ClientDisconnected,
};
// Record final usage
let (total_tx, total_rx, _, _) = state.summary();
// Flush to DB
quota_mgr.record_usage(&username, &peer.ip().to_string(), total_tx, total_rx);
quota_mgr.record_usage(username, &peer.ip().to_string(), total_tx, total_rx);
db.end_session(session_id, total_tx, total_rx)?;
Ok((username, final_reason, total_tx, total_rx))
Ok((final_reason, total_tx, total_rx))
}
/// Run a TCP multi-connection test with all streams collected.
/// Delegates to the standard multi-conn handler which correctly manages
/// TX+status injection for bidirectional mode.
async fn run_pro_multiconn_test(
streams: Vec<TcpStream>,
cmd: Command,
peer: SocketAddr,
username: &str,
db: UserDb,
quota_mgr: QuotaManager,
quota_check_interval: u64,
) -> anyhow::Result<(StopReason, u64, u64)> {
let dir_str = match cmd.direction {
CMD_DIR_RX => "RX", CMD_DIR_TX => "TX", _ => "BOTH"
};
let session_id = db.start_session(
username, &peer.ip().to_string(), "TCP", dir_str,
)?;
tracing::info!(
"Starting TCP multi-conn test: {} streams, dir={}",
streams.len(), dir_str,
);
let state = BandwidthState::new();
let budget = quota_mgr.remaining_budget(username, &peer.ip());
if budget < u64::MAX {
state.set_budget(budget);
}
let enforcer = QuotaEnforcer::new(
quota_mgr.clone(),
username.to_string(),
peer.ip(),
state.clone(),
quota_check_interval,
quota_mgr.max_duration(),
);
let enforcer_state = state.clone();
let enforcer_handle = tokio::spawn(async move {
enforcer.run().await
});
// Use the standard multi-connection handler which correctly handles
// all direction modes (TX, RX, BOTH with status injection)
let _test_result = btest_rs::server::run_tcp_multiconn_test(
streams, cmd, state.clone(),
).await;
enforcer_state.running.store(false, std::sync::atomic::Ordering::SeqCst);
let stop_reason = enforcer_handle.await.unwrap_or(StopReason::ClientDisconnected);
let (total_tx, total_rx, _, _) = state.summary();
quota_mgr.record_usage(username, &peer.ip().to_string(), total_tx, total_rx);
db.end_session(session_id, total_tx, total_rx)?;
Ok((stop_reason, total_tx, total_rx))
}

View File

@@ -8,6 +8,7 @@ use std::sync::{Arc, Mutex};
#[derive(Clone)]
pub struct UserDb {
conn: Arc<Mutex<Connection>>,
path: Arc<String>,
}
#[derive(Debug, Clone)]
@@ -68,9 +69,15 @@ impl UserDb {
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;")?;
Ok(Self {
conn: Arc::new(Mutex::new(conn)),
path: Arc::new(path.to_string()),
})
}
/// Return the database file path.
pub fn path(&self) -> &str {
&self.path
}
pub fn ensure_tables(&self) -> anyhow::Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute_batch("
@@ -147,13 +154,26 @@ impl UserDb {
pub fn add_user(&self, username: &str, password: &str) -> anyhow::Result<()> {
let hash = hash_password(username, password);
let conn = self.conn.lock().unwrap();
// Ensure password_raw column exists (migration for older databases)
let _ = conn.execute("ALTER TABLE users ADD COLUMN password_raw TEXT DEFAULT ''", []);
conn.execute(
"INSERT OR REPLACE INTO users (username, password_hash) VALUES (?1, ?2)",
params![username, hash],
"INSERT OR REPLACE INTO users (username, password_hash, password_raw) VALUES (?1, ?2, ?3)",
params![username, hash, password],
)?;
Ok(())
}
/// Get the raw password for MD5 challenge-response auth.
pub fn get_password(&self, username: &str) -> anyhow::Result<Option<String>> {
let conn = self.conn.lock().unwrap();
let result = conn.query_row(
"SELECT password_raw FROM users WHERE username = ?1 AND enabled = 1",
params![username],
|row| row.get::<_, String>(0),
).optional()?;
Ok(result)
}
pub fn get_user(&self, username: &str) -> anyhow::Result<Option<User>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(

View File

@@ -76,7 +76,7 @@ const DEFAULT_DB_PATH: &str = "btest-users.db";
/// the web module is optional and failure during startup should surface
/// loudly rather than silently serving broken pages.
pub fn create_router(db: UserDb) -> Router {
let db_path = std::env::var("BTEST_DB_PATH").unwrap_or_else(|_| DEFAULT_DB_PATH.to_string());
let db_path = db.path().to_string();
let query_conn = Connection::open_with_flags(
&db_path,
@@ -104,6 +104,8 @@ pub fn create_router(db: UserDb) -> Router {
.route("/dashboard/{ip}", get(dashboard_page))
.route("/api/ip/{ip}/sessions", get(api_sessions))
.route("/api/ip/{ip}/stats", get(api_stats))
.route("/api/ip/{ip}/export", get(api_export))
.route("/api/ip/{ip}/quota", get(api_quota))
.route("/api/session/{id}/intervals", get(api_intervals))
.with_state(state)
}
@@ -142,47 +144,87 @@ fn ensure_web_tables(db_path: &str) -> anyhow::Result<()> {
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>btest-rs Public Bandwidth Test Server</title>
<title>btest-rs — Free Public Bandwidth Test Server</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;background:#0f1117;color:#e1e4e8;min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center}
.container{max-width:560px;width:90%;text-align:center;padding:2rem}
h1{font-size:2rem;margin-bottom:.5rem;color:#58a6ff}
.subtitle{color:#8b949e;margin-bottom:2rem;line-height:1.5}
.search-box{display:flex;gap:.5rem;margin-bottom:1.5rem}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;background:#0f1117;color:#e1e4e8;min-height:100vh;display:flex;flex-direction:column;align-items:center;padding:2rem 1rem}
.container{max-width:720px;width:100%;padding:1rem 0}
h1{font-size:2.2rem;margin-bottom:.25rem;color:#58a6ff;text-align:center}
.subtitle{color:#8b949e;margin-bottom:2.5rem;line-height:1.6;text-align:center;font-size:1.05rem}
.section{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:1.5rem;margin-bottom:1.5rem;text-align:left;line-height:1.7;color:#c9d1d9}
.section h2{color:#e1e4e8;font-size:1.15rem;margin-bottom:.75rem}
.section h3{color:#e1e4e8;font-size:1rem;margin-bottom:.5rem;margin-top:1rem}
.section h3:first-child{margin-top:0}
.section p{margin-bottom:.5rem}
.section ul{margin:.5rem 0 .5rem 1.5rem;color:#8b949e}
.section li{margin-bottom:.35rem}
code{background:#0d1117;padding:.2rem .5rem;border-radius:4px;font-size:.85em;color:#58a6ff;word-break:break-all}
pre{background:#0d1117;border:1px solid #30363d;border-radius:6px;padding:1rem;overflow-x:auto;margin:.75rem 0;line-height:1.5}
pre code{padding:0;background:none;font-size:.85em}
.label-tag{display:inline-block;padding:.15rem .5rem;border-radius:4px;font-size:.75rem;font-weight:600;text-transform:uppercase;letter-spacing:.03em;margin-right:.5rem;vertical-align:middle}
.tag-tcp{background:rgba(63,185,80,0.15);color:#3fb950}
.tag-udp{background:rgba(210,153,34,0.15);color:#d29922}
.note{background:#1c1e26;border-left:3px solid #d29922;padding:.75rem 1rem;border-radius:0 6px 6px 0;margin:.75rem 0;font-size:.92rem;color:#8b949e}
.note strong{color:#d29922}
.search-section{text-align:center}
.search-section h2{text-align:center}
.search-box{display:flex;gap:.5rem;margin-bottom:1rem}
.search-box input{flex:1;padding:.75rem 1rem;border:1px solid #30363d;border-radius:6px;background:#161b22;color:#e1e4e8;font-size:1rem;outline:none}
.search-box input:focus{border-color:#58a6ff}
.search-box input::placeholder{color:#484f58}
.search-box button{padding:.75rem 1.5rem;background:#238636;color:#fff;border:none;border-radius:6px;font-size:1rem;cursor:pointer;white-space:nowrap}
.search-box button:hover{background:#2ea043}
.info{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:1.5rem;text-align:left;line-height:1.6;color:#8b949e}
.info h3{color:#e1e4e8;margin-bottom:.5rem}
.info code{background:#0d1117;padding:.15rem .4rem;border-radius:4px;font-size:.9em;color:#58a6ff}
.auto-link{margin-top:1rem;font-size:.9rem}
.auto-link{font-size:.9rem;color:#8b949e}
.auto-link a{color:#58a6ff;text-decoration:none}
.auto-link a:hover{text-decoration:underline}
.footer{margin-top:2rem;color:#484f58;font-size:.8rem}
.footer{margin-top:2rem;color:#484f58;font-size:.8rem;text-align:center}
.footer a{color:#58a6ff;text-decoration:none}
.footer a:hover{text-decoration:underline}
</style>
</head>
<body>
<div class="container">
<h1>btest-rs</h1>
<p class="subtitle">Public MikroTik Bandwidth Test Server &mdash; view your test results and history.</p>
<form class="search-box" id="ip-form" onsubmit="return goToDashboard()">
<input type="text" id="ip-input" placeholder="Enter your IP address (e.g. 203.0.113.5)" autocomplete="off">
<button type="submit">View Results</button>
</form>
<div class="auto-link" id="auto-detect">Detecting your IP...</div>
<div class="info">
<h3>How it works</h3>
<p>Run a bandwidth test from your MikroTik router targeting this server.
After the test completes, enter your public IP above to see
throughput charts, session history, and aggregate statistics.</p>
<p style="margin-top:0.5rem">
Example: <code>/tool bandwidth-test address=this-server protocol=tcp direction=both</code>
</p>
<p class="subtitle">Free public MikroTik-compatible bandwidth test server.<br>Test your link speed from any RouterOS device &mdash; no registration required.</p>
<div class="section">
<h2>Quick Start</h2>
<p>Open a terminal on your MikroTik router and run one of the following commands:</p>
<h3><span class="label-tag tag-tcp">TCP</span> Recommended</h3>
<pre><code>/tool bandwidth-test address=104.225.217.60 user=btest password=btest protocol=tcp direction=both</code></pre>
<h3><span class="label-tag tag-udp">UDP</span></h3>
<pre><code>/tool bandwidth-test address=104.225.217.60 user=btest password=btest protocol=udp direction=both</code></pre>
</div>
<div class="footer">Powered by btest-rs</div>
<div class="section">
<h2>Important Notes</h2>
<ul>
<li><strong style="color:#e1e4e8">Credentials:</strong> <code>user=btest</code> <code>password=btest</code></li>
<li><strong style="color:#e1e4e8">TCP is recommended</strong> for remote testing &mdash; it works reliably through any NAT or firewall</li>
<li><strong style="color:#e1e4e8">Per-IP daily quotas</strong> apply to keep the service fair for everyone</li>
<li><strong style="color:#e1e4e8">Maximum test duration:</strong> 120 seconds</li>
<li><strong style="color:#e1e4e8">Connection limit:</strong> 3 concurrent tests per IP</li>
</ul>
<div class="note">
<strong>UDP bidirectional may not work through NAT/firewall.</strong>
UDP <code>direction=both</code> requires the server to send packets to a pre-calculated client port, which NAT routers typically block. If you need UDP testing:<br>
&bull; Forward UDP ports 2001&ndash;2100 on your router, or<br>
&bull; Use <code>direction=send</code> or <code>direction=receive</code> (one-way works fine), or<br>
&bull; Test from a device with a public IP
</div>
</div>
<div class="section search-section">
<h2>Check Your Results</h2>
<p style="margin-bottom:1rem;color:#8b949e">After running a test, enter your public IP to view throughput charts, session history, and statistics.</p>
<form class="search-box" id="ip-form" onsubmit="return goToDashboard()">
<input type="text" id="ip-input" placeholder="Enter your IP address (e.g. 203.0.113.5)" autocomplete="off">
<button type="submit">View Results</button>
</form>
<div class="auto-link" id="auto-detect">Detecting your IP...</div>
</div>
<div class="footer">Powered by <a href="https://github.com/manawenuz/btest-rs">btest-rs</a> &mdash; open source MikroTik bandwidth test server</div>
</div>
<script>
function goToDashboard(){var ip=document.getElementById('ip-input').value.trim();if(ip){window.location.href='/dashboard/'+encodeURIComponent(ip);}return false;}
@@ -214,6 +256,8 @@ struct IndexTemplate;
.header h1{font-size:1.5rem;color:#58a6ff}
.header .ip-label{font-size:1.1rem;color:#8b949e;font-family:monospace}
.header .home-link{margin-left:auto}
.btn{display:inline-block;padding:.5rem 1rem;border-radius:6px;font-size:.85rem;font-weight:500;cursor:pointer;border:1px solid #30363d;text-decoration:none}
.btn-json{background:#161b22;color:#3fb950}.btn-json:hover{background:#1c2128;text-decoration:none}
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:1rem;margin-bottom:1.5rem}
.stat-card{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:1rem}
.stat-card .label{color:#8b949e;font-size:.8rem;text-transform:uppercase;letter-spacing:.05em}
@@ -231,12 +275,22 @@ struct IndexTemplate;
.chart-placeholder{text-align:center;color:#484f58;padding:3rem 0}
.footer{text-align:center;color:#484f58;font-size:.8rem;margin-top:2rem}
.no-data{text-align:center;padding:3rem;color:#484f58}
.quota-section{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:1.25rem;margin-bottom:1.5rem}
.quota-section h2{font-size:1rem;color:#8b949e;margin-bottom:1rem}
.quota-row{display:flex;align-items:center;gap:1rem;margin-bottom:.75rem}
.quota-row:last-child{margin-bottom:0}
.quota-label{min-width:70px;font-size:.85rem;color:#8b949e;text-transform:uppercase;letter-spacing:.04em}
.quota-bar-wrap{flex:1;background:#21262d;border-radius:4px;height:22px;position:relative;overflow:hidden}
.quota-bar{height:100%;border-radius:4px;transition:width .5s ease}
.quota-bar.low{background:#238636}.quota-bar.mid{background:#d29922}.quota-bar.high{background:#da3633}
.quota-text{min-width:180px;font-size:.85rem;color:#e1e4e8;text-align:right;font-family:monospace}
</style>
</head>
<body>
<div class="header">
<h1>btest-rs</h1>
<span class="ip-label">{{ ip }}</span>
<a class="btn btn-json" href="/api/ip/{{ ip }}/export" download>Export JSON</a>
<span class="home-link"><a href="/">Home</a></span>
</div>
<div class="stats" id="stats-grid">
@@ -246,6 +300,12 @@ struct IndexTemplate;
<div class="stat-card"><div class="label">Avg TX Mbps</div><div class="value" id="stat-avg-tx">&mdash;</div></div>
<div class="stat-card"><div class="label">Avg RX Mbps</div><div class="value" id="stat-avg-rx">&mdash;</div></div>
</div>
<div class="quota-section" id="quota-section">
<h2>Quota Usage</h2>
<div class="quota-row"><span class="quota-label">Daily</span><div class="quota-bar-wrap"><div class="quota-bar low" id="bar-daily" style="width:0%"></div></div><span class="quota-text" id="text-daily">&mdash;</span></div>
<div class="quota-row"><span class="quota-label">Weekly</span><div class="quota-bar-wrap"><div class="quota-bar low" id="bar-weekly" style="width:0%"></div></div><span class="quota-text" id="text-weekly">&mdash;</span></div>
<div class="quota-row"><span class="quota-label">Monthly</span><div class="quota-bar-wrap"><div class="quota-bar low" id="bar-monthly" style="width:0%"></div></div><span class="quota-text" id="text-monthly">&mdash;</span></div>
</div>
<div class="chart-section">
<h2 id="chart-title">Select a test below to view its throughput chart</h2>
<div class="chart-container">
@@ -266,6 +326,19 @@ var currentIp="{{ ip }}";
var throughputChart=null;
function formatBytes(b){if(b===0)return'0 B';var u=['B','KB','MB','GB','TB'];var i=Math.floor(Math.log(b)/Math.log(1024));if(i>=u.length)i=u.length-1;return(b/Math.pow(1024,i)).toFixed(1)+' '+u[i];}
function formatMbps(bps){return(bps*8/1e6).toFixed(2);}
fetch('/api/ip/'+encodeURIComponent(currentIp)+'/quota').then(function(r){return r.json();}).then(function(q){
function upd(id,used,limit){
var pct=limit>0?Math.min(used/limit*100,100):0;
var bar=document.getElementById('bar-'+id);
var txt=document.getElementById('text-'+id);
bar.style.width=pct.toFixed(1)+'%';
bar.className='quota-bar '+(pct<50?'low':pct<80?'mid':'high');
txt.textContent=formatBytes(used)+' / '+formatBytes(limit)+' ('+pct.toFixed(1)+'%)';
}
upd('daily',q.daily_used,q.daily_limit);
upd('weekly',q.weekly_used,q.weekly_limit);
upd('monthly',q.monthly_used,q.monthly_limit);
}).catch(function(){});
function durationStr(s,e){if(!s||!e)return'--';var ms=new Date(e)-new Date(s);if(ms<0)return'--';var sec=Math.round(ms/1000);if(sec<60)return sec+'s';return Math.floor(sec/60)+'m '+(sec%60)+'s';}
function durationSec(s,e){if(!s||!e)return 0;return Math.max((new Date(e)-new Date(s))/1000,0.001);}
fetch('/api/ip/'+encodeURIComponent(currentIp)+'/stats').then(function(r){return r.json();}).then(function(d){
@@ -495,6 +568,198 @@ async fn api_stats(
Ok(axum::Json(stats))
}
/// Quota usage for an IP — daily/weekly/monthly with limits.
#[derive(Serialize)]
struct QuotaUsageJson {
daily_used: i64,
daily_limit: i64,
weekly_used: i64,
weekly_limit: i64,
monthly_used: i64,
monthly_limit: i64,
}
/// `GET /api/ip/{ip}/quota` -- return current quota usage for the IP.
async fn api_quota(
State(state): State<Arc<WebState>>,
Path(ip): Path<String>,
) -> Result<axum::Json<QuotaUsageJson>, AppError> {
let conn = state.query_conn.lock().map_err(|e| anyhow::anyhow!("lock: {}", e))?;
let daily: i64 = conn.query_row(
"SELECT COALESCE(SUM(inbound_bytes + outbound_bytes), 0) FROM ip_usage WHERE ip = ?1 AND date = date('now')",
params![ip], |row| row.get(0),
).unwrap_or(0);
let weekly: i64 = conn.query_row(
"SELECT COALESCE(SUM(inbound_bytes + outbound_bytes), 0) FROM ip_usage WHERE ip = ?1 AND date >= date('now', '-7 days')",
params![ip], |row| row.get(0),
).unwrap_or(0);
let monthly: i64 = conn.query_row(
"SELECT COALESCE(SUM(inbound_bytes + outbound_bytes), 0) FROM ip_usage WHERE ip = ?1 AND date >= date('now', '-30 days')",
params![ip], |row| row.get(0),
).unwrap_or(0);
// Limits: 2GB daily, 8GB weekly, 24GB monthly
Ok(axum::Json(QuotaUsageJson {
daily_used: daily,
daily_limit: 2_147_483_648,
weekly_used: weekly,
weekly_limit: 8_589_934_592,
monthly_used: monthly,
monthly_limit: 25_769_803_776,
}))
}
/// Full export of all data for an IP — stats + sessions with human-readable fields.
#[derive(Serialize)]
struct ExportJson {
ip: String,
exported_at: String,
stats: StatsJson,
quota: QuotaJson,
sessions: Vec<ExportSessionJson>,
}
#[derive(Serialize)]
struct QuotaJson {
daily_used_bytes: i64,
daily_used_human: String,
daily_limit_bytes: String,
}
#[derive(Serialize)]
struct ExportSessionJson {
id: i64,
started_at: Option<String>,
ended_at: Option<String>,
protocol: Option<String>,
direction: Option<String>,
tx_bytes: i64,
rx_bytes: i64,
tx_human: String,
rx_human: String,
duration_secs: f64,
avg_tx_mbps: f64,
avg_rx_mbps: f64,
}
fn human_bytes(b: i64) -> String {
let b = b as f64;
if b >= 1_073_741_824.0 {
format!("{:.2} GB", b / 1_073_741_824.0)
} else if b >= 1_048_576.0 {
format!("{:.1} MB", b / 1_048_576.0)
} else if b >= 1024.0 {
format!("{:.1} KB", b / 1024.0)
} else {
format!("{} B", b as i64)
}
}
/// `GET /api/ip/{ip}/export` -- return a comprehensive JSON export of all
/// sessions, stats, and quota usage for an IP. Suitable for download/archival.
async fn api_export(
State(state): State<Arc<WebState>>,
Path(ip): Path<String>,
) -> Result<impl IntoResponse, AppError> {
let conn = state
.query_conn
.lock()
.map_err(|e| anyhow::anyhow!("lock: {}", e))?;
// Stats
let stats = conn.query_row(
"SELECT COUNT(*), COALESCE(SUM(tx_bytes),0), COALESCE(SUM(rx_bytes),0),
COALESCE(SUM(CASE WHEN ended_at IS NOT NULL AND started_at IS NOT NULL
THEN (julianday(ended_at)-julianday(started_at))*86400.0 ELSE 0 END),0)
FROM sessions WHERE peer_ip = ?1",
params![ip],
|row| {
let n: i64 = row.get(0)?;
let tx: i64 = row.get(1)?;
let rx: i64 = row.get(2)?;
let secs: f64 = row.get(3)?;
Ok(StatsJson {
total_sessions: n,
total_tx_bytes: tx,
total_rx_bytes: rx,
avg_tx_mbps: if secs > 0.0 { tx as f64 * 8.0 / secs / 1e6 } else { 0.0 },
avg_rx_mbps: if secs > 0.0 { rx as f64 * 8.0 / secs / 1e6 } else { 0.0 },
})
},
)?;
// Quota
let daily_used: i64 = conn.query_row(
"SELECT COALESCE(SUM(inbound_bytes + outbound_bytes), 0) FROM ip_usage
WHERE ip = ?1 AND date = date('now')",
params![ip],
|row| row.get(0),
).unwrap_or(0);
let quota = QuotaJson {
daily_used_bytes: daily_used,
daily_used_human: human_bytes(daily_used),
daily_limit_bytes: "see server config".to_string(),
};
// Sessions with computed fields (duration computed by SQLite)
let mut stmt = conn.prepare(
"SELECT id, started_at, ended_at, protocol, direction, tx_bytes, rx_bytes,
CASE WHEN ended_at IS NOT NULL AND started_at IS NOT NULL
THEN (julianday(ended_at) - julianday(started_at)) * 86400.0
ELSE 0 END AS dur_secs
FROM sessions WHERE peer_ip = ?1 ORDER BY started_at DESC LIMIT 100",
)?;
let sessions: Vec<ExportSessionJson> = stmt.query_map(params![ip], |row| {
let tx: i64 = row.get(5)?;
let rx: i64 = row.get(6)?;
let dur: f64 = row.get(7)?;
Ok(ExportSessionJson {
id: row.get(0)?,
started_at: row.get(1)?,
ended_at: row.get(2)?,
protocol: row.get(3)?,
direction: row.get(4)?,
tx_bytes: tx,
rx_bytes: rx,
tx_human: human_bytes(tx),
rx_human: human_bytes(rx),
duration_secs: dur,
avg_tx_mbps: if dur > 0.0 { tx as f64 * 8.0 / dur / 1e6 } else { 0.0 },
avg_rx_mbps: if dur > 0.0 { rx as f64 * 8.0 / dur / 1e6 } else { 0.0 },
})
})?.filter_map(Result::ok).collect();
let export = ExportJson {
ip: ip.clone(),
exported_at: {
// Simple UTC timestamp without chrono
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
format!("{}", secs) // Unix timestamp — universally parseable
},
stats,
quota,
sessions,
};
let json_string = serde_json::to_string_pretty(&export)
.map_err(|e| anyhow::anyhow!("json serialize: {}", e))?;
Ok((
StatusCode::OK,
[
(axum::http::header::CONTENT_TYPE, "application/json".to_string()),
(axum::http::header::CONTENT_DISPOSITION,
format!("attachment; filename=\"btest-{}.json\"", ip)),
],
json_string,
))
}
/// `GET /api/session/{id}/intervals` -- return per-second throughput data
/// for a session.
///