Compare commits
14 Commits
v0.6.0
...
feature/se
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7dd4820d2c | ||
|
|
2087e5a75f | ||
|
|
9e3cd6d6d4 | ||
|
|
4403eae4b9 | ||
|
|
c08bcffaff | ||
|
|
d61fdb1b94 | ||
|
|
d2fdc9c6ae | ||
|
|
8c853c3605 | ||
|
|
fe28c04c19 | ||
|
|
66be99bef0 | ||
|
|
94b122ac25 | ||
|
|
a07158ed22 | ||
|
|
1cd552d2dc | ||
|
|
3af40cb275 |
1434
Cargo.lock
generated
1434
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
16
Cargo.toml
16
Cargo.toml
@@ -16,6 +16,15 @@ path = "src/lib.rs"
|
|||||||
name = "btest"
|
name = "btest"
|
||||||
path = "src/main.rs"
|
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", "dep:axum", "dep:tower-http", "dep:serde", "dep:serde_json", "dep:askama"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
@@ -32,6 +41,13 @@ num-traits = "0.2.19"
|
|||||||
num-integer = "0.1.46"
|
num-integer = "0.1.46"
|
||||||
sha2 = "0.11.0"
|
sha2 = "0.11.0"
|
||||||
hostname = "0.4.2"
|
hostname = "0.4.2"
|
||||||
|
rusqlite = { version = "0.39.0", features = ["bundled"], optional = true }
|
||||||
|
ldap3 = { version = "0.12.1", optional = true }
|
||||||
|
axum = { version = "0.8.8", features = ["tokio"], optional = true }
|
||||||
|
tower-http = { version = "0.6.8", features = ["fs", "cors"], optional = true }
|
||||||
|
serde = { version = "1.0.228", features = ["derive"], optional = true }
|
||||||
|
serde_json = { version = "1.0.149", optional = true }
|
||||||
|
askama = { version = "0.15.6", optional = true }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
opt-level = 3
|
opt-level = 3
|
||||||
|
|||||||
51
README.md
51
README.md
@@ -42,14 +42,51 @@ On wired gigabit links, expect line-rate performance in both TCP and UDP modes.
|
|||||||
cargo install --path .
|
cargo install --path .
|
||||||
```
|
```
|
||||||
|
|
||||||
### Pre-built binary (Linux x86_64)
|
### Pre-built binaries
|
||||||
|
|
||||||
|
Download from [releases](https://git.manko.yoga/manawenuz/btest-rs/releases) or [GitHub releases](https://github.com/manawenuz/btest-rs/releases):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Cross-compile from macOS (requires Docker)
|
# Linux x86_64
|
||||||
scripts/build-linux.sh
|
curl -L <release-url>/btest-linux-x86_64.tar.gz | tar xz
|
||||||
|
sudo mv btest /usr/local/bin/
|
||||||
|
|
||||||
# Copy to server
|
# Raspberry Pi 4/5 (64-bit OS)
|
||||||
scp dist/btest root@yourserver:/usr/local/bin/btest
|
curl -L <release-url>/btest-linux-aarch64.tar.gz | tar xz
|
||||||
|
sudo mv btest /usr/local/bin/
|
||||||
|
|
||||||
|
# Raspberry Pi 3/Zero 2 (32-bit OS)
|
||||||
|
curl -L <release-url>/btest-linux-armv7.tar.gz | tar xz
|
||||||
|
sudo mv btest /usr/local/bin/
|
||||||
|
|
||||||
|
# Windows
|
||||||
|
# Download btest-windows-x86_64.zip from releases
|
||||||
|
```
|
||||||
|
|
||||||
|
### Raspberry Pi
|
||||||
|
|
||||||
|
The static musl binaries run on any Raspberry Pi without dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# On the Pi — detect architecture and install
|
||||||
|
ARCH=$(uname -m)
|
||||||
|
case $ARCH in
|
||||||
|
aarch64) FILE=btest-linux-aarch64.tar.gz ;;
|
||||||
|
armv7l) FILE=btest-linux-armv7.tar.gz ;;
|
||||||
|
*) echo "Unsupported: $ARCH"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
curl -LO "https://github.com/manawenuz/btest-rs/releases/latest/download/$FILE"
|
||||||
|
tar xzf "$FILE"
|
||||||
|
sudo mv btest /usr/local/bin/
|
||||||
|
rm "$FILE"
|
||||||
|
|
||||||
|
# Run as server
|
||||||
|
btest -s -a admin -p password --ecsrp5
|
||||||
|
|
||||||
|
# Or install as systemd service
|
||||||
|
curl -LO https://raw.githubusercontent.com/manawenuz/btest-rs/main/scripts/install-service.sh
|
||||||
|
sudo bash install-service.sh --auth-user admin --auth-pass password
|
||||||
```
|
```
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
@@ -208,7 +245,9 @@ See [KNOWN_ISSUES.md](KNOWN_ISSUES.md) for the full list including:
|
|||||||
- **Windows binaries** — cross-compiled but untested
|
- **Windows binaries** — cross-compiled but untested
|
||||||
- **IPv6 UDP on Linux** — untested, likely works fine
|
- **IPv6 UDP on Linux** — untested, likely works fine
|
||||||
|
|
||||||
Contributions and bug reports welcome: https://git.manko.yoga/manawenuz/btest-rs/issues
|
Contributions and bug reports welcome:
|
||||||
|
- https://github.com/manawenuz/btest-rs/issues
|
||||||
|
- https://git.manko.yoga/manawenuz/btest-rs/issues
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
|
|||||||
52
deploy/alpine/APKBUILD
Normal file
52
deploy/alpine/APKBUILD
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# Maintainer: Siavash Sameni <manwe at manko dot yoga>
|
||||||
|
pkgname=btest-rs
|
||||||
|
pkgver=0.6.0
|
||||||
|
pkgrel=0
|
||||||
|
pkgdesc="MikroTik Bandwidth Test server and client with EC-SRP5 auth"
|
||||||
|
url="https://github.com/manawenuz/btest-rs"
|
||||||
|
license="MIT AND Apache-2.0"
|
||||||
|
arch="x86_64 aarch64 armv7"
|
||||||
|
makedepends="cargo rust"
|
||||||
|
install="$pkgname.pre-install"
|
||||||
|
source="$pkgname-$pkgver.tar.gz::https://github.com/manawenuz/btest-rs/archive/refs/tags/v$pkgver.tar.gz
|
||||||
|
btest.initd
|
||||||
|
"
|
||||||
|
sha256sums="SKIP
|
||||||
|
SKIP
|
||||||
|
"
|
||||||
|
|
||||||
|
prepare() {
|
||||||
|
default_prepare
|
||||||
|
cd "$builddir"
|
||||||
|
cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')"
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
cd "$builddir"
|
||||||
|
export CARGO_TARGET_DIR=target
|
||||||
|
cargo build --frozen --release
|
||||||
|
}
|
||||||
|
|
||||||
|
check() {
|
||||||
|
cd "$builddir"
|
||||||
|
cargo test --frozen --release
|
||||||
|
}
|
||||||
|
|
||||||
|
package() {
|
||||||
|
cd "$builddir"
|
||||||
|
|
||||||
|
# binary
|
||||||
|
install -Dm755 "target/release/btest" "$pkgdir/usr/bin/btest"
|
||||||
|
|
||||||
|
# man page
|
||||||
|
install -Dm644 "docs/man/btest.1" "$pkgdir/usr/share/man/man1/btest.1"
|
||||||
|
|
||||||
|
# license
|
||||||
|
install -Dm644 "LICENSE" "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||||
|
|
||||||
|
# documentation
|
||||||
|
install -Dm644 "README.md" "$pkgdir/usr/share/doc/$pkgname/README.md"
|
||||||
|
|
||||||
|
# OpenRC init script
|
||||||
|
install -Dm755 "$srcdir/btest.initd" "$pkgdir/etc/init.d/btest"
|
||||||
|
}
|
||||||
37
deploy/alpine/btest.initd
Executable file
37
deploy/alpine/btest.initd
Executable file
@@ -0,0 +1,37 @@
|
|||||||
|
#!/sbin/openrc-run
|
||||||
|
# OpenRC init script for btest-rs
|
||||||
|
# MikroTik Bandwidth Test server
|
||||||
|
|
||||||
|
name="btest"
|
||||||
|
description="MikroTik Bandwidth Test Server (btest-rs)"
|
||||||
|
command="/usr/bin/btest"
|
||||||
|
command_args="-s"
|
||||||
|
command_background=true
|
||||||
|
pidfile="/run/$name.pid"
|
||||||
|
|
||||||
|
# Run as dedicated user if it exists, otherwise root
|
||||||
|
command_user="btest:btest"
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
output_log="/var/log/$name/$name.log"
|
||||||
|
error_log="/var/log/$name/$name.err"
|
||||||
|
|
||||||
|
depend() {
|
||||||
|
need net
|
||||||
|
after firewall
|
||||||
|
use dns logger
|
||||||
|
}
|
||||||
|
|
||||||
|
start_pre() {
|
||||||
|
# Create log directory
|
||||||
|
checkpath -d -m 0755 -o "$command_user" /var/log/$name
|
||||||
|
|
||||||
|
# Create runtime directory
|
||||||
|
checkpath -d -m 0755 -o "$command_user" /run
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
ebegin "Stopping $name"
|
||||||
|
start-stop-daemon --stop --pidfile "$pidfile" --retry TERM/5/KILL/3
|
||||||
|
eend $?
|
||||||
|
}
|
||||||
118
deploy/alpine/test-alpine.sh
Executable file
118
deploy/alpine/test-alpine.sh
Executable file
@@ -0,0 +1,118 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Test Alpine Linux packaging for btest-rs
|
||||||
|
# Runs inside an Alpine Docker container to build and verify the APK.
|
||||||
|
#
|
||||||
|
# Usage (from repository root):
|
||||||
|
# docker run --rm -v "$PWD":/src alpine:latest /src/deploy/alpine/test-alpine.sh
|
||||||
|
#
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ALPINE_DIR="/src/deploy/alpine"
|
||||||
|
|
||||||
|
echo "=== Alpine APK packaging test ==="
|
||||||
|
echo "Alpine version: $(cat /etc/alpine-release)"
|
||||||
|
|
||||||
|
# ── Install build dependencies ──────────────────────────────────────
|
||||||
|
echo "--- Installing build dependencies ---"
|
||||||
|
apk update
|
||||||
|
apk add --no-cache \
|
||||||
|
alpine-sdk \
|
||||||
|
rust \
|
||||||
|
cargo \
|
||||||
|
sudo
|
||||||
|
|
||||||
|
# ── Create a non-root build user (abuild refuses to run as root) ──
|
||||||
|
echo "--- Setting up build user ---"
|
||||||
|
adduser -D builder
|
||||||
|
addgroup builder abuild
|
||||||
|
echo "builder ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers
|
||||||
|
|
||||||
|
# ── Prepare build tree ──────────────────────────────────────────────
|
||||||
|
echo "--- Preparing build tree ---"
|
||||||
|
BUILD_DIR="/home/builder/btest-rs"
|
||||||
|
mkdir -p "$BUILD_DIR"
|
||||||
|
cp "$ALPINE_DIR/APKBUILD" "$BUILD_DIR/"
|
||||||
|
cp "$ALPINE_DIR/btest.initd" "$BUILD_DIR/"
|
||||||
|
|
||||||
|
# Generate signing key (required by abuild)
|
||||||
|
su builder -c "abuild-keygen -a -n -q"
|
||||||
|
sudo cp /home/builder/.abuild/*.rsa.pub /etc/apk/keys/
|
||||||
|
|
||||||
|
# ── Build the package ──────────────────────────────────────────────
|
||||||
|
echo "--- Building APK ---"
|
||||||
|
cd "$BUILD_DIR"
|
||||||
|
chown -R builder:builder "$BUILD_DIR"
|
||||||
|
su builder -c "abuild -r"
|
||||||
|
|
||||||
|
echo "--- Build succeeded ---"
|
||||||
|
|
||||||
|
# ── Locate and install the package ──────────────────────────────────
|
||||||
|
echo "--- Installing built APK ---"
|
||||||
|
APK_FILE=$(find /home/builder/packages -name "btest-rs-*.apk" -not -name "*doc*" | head -1)
|
||||||
|
if [ -z "$APK_FILE" ]; then
|
||||||
|
echo "FAIL: APK file not found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Found APK: $APK_FILE"
|
||||||
|
apk add --allow-untrusted "$APK_FILE"
|
||||||
|
|
||||||
|
# ── Verify installation ────────────────────────────────────────────
|
||||||
|
echo "--- Verifying installation ---"
|
||||||
|
FAIL=0
|
||||||
|
|
||||||
|
# Binary exists and is executable
|
||||||
|
if command -v btest >/dev/null 2>&1; then
|
||||||
|
echo "PASS: btest binary installed"
|
||||||
|
else
|
||||||
|
echo "FAIL: btest binary not found in PATH"
|
||||||
|
FAIL=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Binary runs (show version / help)
|
||||||
|
if btest --help >/dev/null 2>&1; then
|
||||||
|
echo "PASS: btest --help exits successfully"
|
||||||
|
else
|
||||||
|
echo "FAIL: btest --help failed"
|
||||||
|
FAIL=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Man page installed
|
||||||
|
if [ -f /usr/share/man/man1/btest.1 ]; then
|
||||||
|
echo "PASS: man page installed"
|
||||||
|
else
|
||||||
|
echo "FAIL: man page not found"
|
||||||
|
FAIL=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# License installed
|
||||||
|
if [ -f /usr/share/licenses/btest-rs/LICENSE ]; then
|
||||||
|
echo "PASS: LICENSE installed"
|
||||||
|
else
|
||||||
|
echo "FAIL: LICENSE not found"
|
||||||
|
FAIL=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# OpenRC init script installed
|
||||||
|
if [ -f /etc/init.d/btest ]; then
|
||||||
|
echo "PASS: OpenRC init script installed"
|
||||||
|
else
|
||||||
|
echo "FAIL: OpenRC init script not found"
|
||||||
|
FAIL=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Init script is executable
|
||||||
|
if [ -x /etc/init.d/btest ]; then
|
||||||
|
echo "PASS: init script is executable"
|
||||||
|
else
|
||||||
|
echo "FAIL: init script is not executable"
|
||||||
|
FAIL=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Summary ─────────────────────────────────────────────────────────
|
||||||
|
echo ""
|
||||||
|
if [ "$FAIL" -eq 0 ]; then
|
||||||
|
echo "=== All Alpine packaging tests PASSED ==="
|
||||||
|
else
|
||||||
|
echo "=== Some Alpine packaging tests FAILED ==="
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
15
deploy/aur/.SRCINFO
Normal file
15
deploy/aur/.SRCINFO
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
pkgbase = btest-rs
|
||||||
|
pkgdesc = MikroTik Bandwidth Test (btest) server and client with EC-SRP5 auth
|
||||||
|
pkgver = 0.6.0
|
||||||
|
pkgrel = 1
|
||||||
|
url = https://github.com/manawenuz/btest-rs
|
||||||
|
arch = x86_64
|
||||||
|
arch = aarch64
|
||||||
|
arch = armv7h
|
||||||
|
license = MIT
|
||||||
|
license = Apache-2.0
|
||||||
|
makedepends = cargo
|
||||||
|
source = btest-rs-0.6.0.tar.gz::https://github.com/manawenuz/btest-rs/archive/refs/tags/v0.6.0.tar.gz
|
||||||
|
sha256sums = SKIP
|
||||||
|
|
||||||
|
pkgname = btest-rs
|
||||||
58
deploy/aur/PKGBUILD
Normal file
58
deploy/aur/PKGBUILD
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# Maintainer: Siavash Sameni <manwe at manko dot yoga>
|
||||||
|
pkgname=btest-rs
|
||||||
|
pkgver=0.6.0
|
||||||
|
pkgrel=1
|
||||||
|
pkgdesc="MikroTik Bandwidth Test (btest) server and client with EC-SRP5 auth"
|
||||||
|
arch=('x86_64' 'aarch64' 'armv7h')
|
||||||
|
url="https://github.com/manawenuz/btest-rs"
|
||||||
|
license=('MIT' 'Apache-2.0')
|
||||||
|
depends=()
|
||||||
|
makedepends=('cargo')
|
||||||
|
source=("$pkgname-$pkgver.tar.gz::https://github.com/manawenuz/btest-rs/archive/refs/tags/v$pkgver.tar.gz")
|
||||||
|
sha256sums=('SKIP')
|
||||||
|
|
||||||
|
prepare() {
|
||||||
|
cd "$pkgname-$pkgver"
|
||||||
|
export RUSTUP_TOOLCHAIN=stable
|
||||||
|
cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')"
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
cd "$pkgname-$pkgver"
|
||||||
|
export RUSTUP_TOOLCHAIN=stable
|
||||||
|
export CARGO_TARGET_DIR=target
|
||||||
|
cargo build --frozen --release
|
||||||
|
}
|
||||||
|
|
||||||
|
package() {
|
||||||
|
cd "$pkgname-$pkgver"
|
||||||
|
install -Dm755 "target/release/btest" "$pkgdir/usr/bin/btest"
|
||||||
|
install -Dm644 "LICENSE" "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||||
|
install -Dm644 "docs/man/btest.1" "$pkgdir/usr/share/man/man1/btest.1"
|
||||||
|
install -Dm644 "README.md" "$pkgdir/usr/share/doc/$pkgname/README.md"
|
||||||
|
|
||||||
|
# systemd service
|
||||||
|
install -Dm644 /dev/stdin "$pkgdir/usr/lib/systemd/system/btest.service" <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=MikroTik Bandwidth Test Server (btest-rs)
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/usr/bin/btest -s
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
DynamicUser=yes
|
||||||
|
NoNewPrivileges=yes
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=yes
|
||||||
|
PrivateTmp=yes
|
||||||
|
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||||
|
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
|
||||||
|
LimitNOFILE=65535
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
}
|
||||||
40
deploy/aur/test-aur.sh
Executable file
40
deploy/aur/test-aur.sh
Executable file
@@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Test the PKGBUILD in a Docker Arch Linux container.
|
||||||
|
# Usage: ./deploy/aur/test-aur.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/../.."
|
||||||
|
|
||||||
|
echo "=== Testing AUR PKGBUILD in Arch Linux container ==="
|
||||||
|
|
||||||
|
docker run --rm -v "$(pwd):/src:ro" archlinux:latest bash -c '
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Install base-devel and rust
|
||||||
|
pacman -Syu --noconfirm base-devel rustup git
|
||||||
|
rustup default stable
|
||||||
|
|
||||||
|
# Create build user (makepkg refuses to run as root)
|
||||||
|
useradd -m builder
|
||||||
|
echo "builder ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers
|
||||||
|
|
||||||
|
# Copy source and PKGBUILD
|
||||||
|
su builder -c "
|
||||||
|
mkdir -p /tmp/build && cd /tmp/build
|
||||||
|
cp /src/deploy/aur/PKGBUILD .
|
||||||
|
|
||||||
|
# Build the package
|
||||||
|
makepkg -si --noconfirm
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
echo ''
|
||||||
|
echo '=== Installed ==='
|
||||||
|
btest --version
|
||||||
|
btest --help | head -5
|
||||||
|
echo ''
|
||||||
|
echo '=== Files ==='
|
||||||
|
pacman -Ql btest-rs
|
||||||
|
echo ''
|
||||||
|
echo '=== SUCCESS ==='
|
||||||
|
"
|
||||||
|
'
|
||||||
208
deploy/deb/build-deb.sh
Executable file
208
deploy/deb/build-deb.sh
Executable file
@@ -0,0 +1,208 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# build-deb.sh -- Build a Debian/Ubuntu .deb package for btest-rs
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./deploy/deb/build-deb.sh # uses dist/btest or target/release/btest
|
||||||
|
# BTEST_BIN=path/to/btest ./deploy/deb/build-deb.sh
|
||||||
|
#
|
||||||
|
# Requirements: dpkg-deb, gzip (standard on Debian/Ubuntu build hosts)
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# Package metadata
|
||||||
|
###############################################################################
|
||||||
|
PKG_NAME="btest-rs"
|
||||||
|
PKG_VERSION="0.6.0"
|
||||||
|
PKG_ARCH="amd64"
|
||||||
|
PKG_MAINTAINER="Siavash Sameni <manwe@manko.yoga>"
|
||||||
|
PKG_DESCRIPTION="MikroTik Bandwidth Test (btest) server and client with EC-SRP5 auth"
|
||||||
|
PKG_HOMEPAGE="https://github.com/manawenuz/btest-rs"
|
||||||
|
PKG_LICENSE="MIT AND Apache-2.0"
|
||||||
|
PKG_SECTION="net"
|
||||||
|
PKG_PRIORITY="optional"
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# Paths
|
||||||
|
###############################################################################
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||||
|
|
||||||
|
# Locate the pre-built binary
|
||||||
|
if [[ -n "${BTEST_BIN:-}" ]]; then
|
||||||
|
: # caller provided an explicit path
|
||||||
|
elif [[ -f "$REPO_ROOT/dist/btest" ]]; then
|
||||||
|
BTEST_BIN="$REPO_ROOT/dist/btest"
|
||||||
|
elif [[ -f "$REPO_ROOT/target/release/btest" ]]; then
|
||||||
|
BTEST_BIN="$REPO_ROOT/target/release/btest"
|
||||||
|
else
|
||||||
|
echo "Error: cannot find btest binary."
|
||||||
|
echo " Build first (cargo build --release) or set BTEST_BIN=path/to/btest"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verify the binary exists and is executable
|
||||||
|
if [[ ! -f "$BTEST_BIN" ]]; then
|
||||||
|
echo "Error: $BTEST_BIN does not exist."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Using binary: $BTEST_BIN"
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# Prepare staging tree
|
||||||
|
###############################################################################
|
||||||
|
DEB_FILE="${PKG_NAME}_${PKG_VERSION}_${PKG_ARCH}.deb"
|
||||||
|
STAGE="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$STAGE"' EXIT
|
||||||
|
|
||||||
|
echo "==> Staging in $STAGE"
|
||||||
|
|
||||||
|
# Binary
|
||||||
|
install -Dm755 "$BTEST_BIN" "$STAGE/usr/bin/btest"
|
||||||
|
|
||||||
|
# Man page
|
||||||
|
if [[ -f "$REPO_ROOT/docs/man/btest.1" ]]; then
|
||||||
|
install -Dm644 "$REPO_ROOT/docs/man/btest.1" "$STAGE/usr/share/man/man1/btest.1"
|
||||||
|
gzip -9n "$STAGE/usr/share/man/man1/btest.1"
|
||||||
|
else
|
||||||
|
echo "Warning: docs/man/btest.1 not found -- skipping man page"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# systemd service unit
|
||||||
|
install -d "$STAGE/usr/lib/systemd/system"
|
||||||
|
cat > "$STAGE/usr/lib/systemd/system/btest.service" <<'UNIT'
|
||||||
|
[Unit]
|
||||||
|
Description=MikroTik Bandwidth Test Server (btest-rs)
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/usr/bin/btest -s
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
DynamicUser=yes
|
||||||
|
NoNewPrivileges=yes
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=yes
|
||||||
|
PrivateTmp=yes
|
||||||
|
ProtectKernelTunables=yes
|
||||||
|
ProtectControlGroups=yes
|
||||||
|
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||||
|
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
|
||||||
|
LimitNOFILE=65535
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
UNIT
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
install -Dm644 "$REPO_ROOT/README.md" "$STAGE/usr/share/doc/$PKG_NAME/README.md"
|
||||||
|
|
||||||
|
# License
|
||||||
|
install -Dm644 "$REPO_ROOT/LICENSE" "$STAGE/usr/share/licenses/$PKG_NAME/LICENSE"
|
||||||
|
|
||||||
|
# Debian copyright file (policy-compliant copy in /usr/share/doc)
|
||||||
|
install -d "$STAGE/usr/share/doc/$PKG_NAME"
|
||||||
|
cat > "$STAGE/usr/share/doc/$PKG_NAME/copyright" <<COPY
|
||||||
|
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
|
||||||
|
Upstream-Name: $PKG_NAME
|
||||||
|
Upstream-Contact: $PKG_MAINTAINER
|
||||||
|
Source: $PKG_HOMEPAGE
|
||||||
|
|
||||||
|
Files: *
|
||||||
|
Copyright: 2024-2026 Siavash Sameni
|
||||||
|
License: MIT AND Apache-2.0
|
||||||
|
COPY
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# Calculate installed size (in KiB, as Debian policy requires)
|
||||||
|
###############################################################################
|
||||||
|
INSTALLED_SIZE=$(du -sk "$STAGE" | cut -f1)
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# DEBIAN/control
|
||||||
|
###############################################################################
|
||||||
|
install -d "$STAGE/DEBIAN"
|
||||||
|
cat > "$STAGE/DEBIAN/control" <<CTRL
|
||||||
|
Package: $PKG_NAME
|
||||||
|
Version: $PKG_VERSION
|
||||||
|
Architecture: $PKG_ARCH
|
||||||
|
Maintainer: $PKG_MAINTAINER
|
||||||
|
Installed-Size: $INSTALLED_SIZE
|
||||||
|
Section: $PKG_SECTION
|
||||||
|
Priority: $PKG_PRIORITY
|
||||||
|
Homepage: $PKG_HOMEPAGE
|
||||||
|
Description: $PKG_DESCRIPTION
|
||||||
|
A high-performance Rust implementation of the MikroTik Bandwidth Test
|
||||||
|
protocol, supporting both server and client modes with EC-SRP5
|
||||||
|
authentication. Supports TCP/UDP throughput testing and is fully
|
||||||
|
compatible with RouterOS btest clients.
|
||||||
|
CTRL
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# DEBIAN/conffiles (mark the systemd unit as a conffile)
|
||||||
|
###############################################################################
|
||||||
|
cat > "$STAGE/DEBIAN/conffiles" <<'CF'
|
||||||
|
/usr/lib/systemd/system/btest.service
|
||||||
|
CF
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# Maintainer scripts
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
# postinst -- reload systemd after install
|
||||||
|
cat > "$STAGE/DEBIAN/postinst" <<'POST'
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
if [ "$1" = "configure" ]; then
|
||||||
|
if command -v systemctl >/dev/null 2>&1; then
|
||||||
|
systemctl daemon-reload || true
|
||||||
|
echo ""
|
||||||
|
echo "btest-rs installed. To start the server:"
|
||||||
|
echo " sudo systemctl enable --now btest.service"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
POST
|
||||||
|
chmod 755 "$STAGE/DEBIAN/postinst"
|
||||||
|
|
||||||
|
# prerm -- stop service before removal
|
||||||
|
cat > "$STAGE/DEBIAN/prerm" <<'PRERM'
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
if [ "$1" = "remove" ] || [ "$1" = "deconfigure" ]; then
|
||||||
|
if command -v systemctl >/dev/null 2>&1; then
|
||||||
|
systemctl stop btest.service 2>/dev/null || true
|
||||||
|
systemctl disable btest.service 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
PRERM
|
||||||
|
chmod 755 "$STAGE/DEBIAN/prerm"
|
||||||
|
|
||||||
|
# postrm -- clean up after removal
|
||||||
|
cat > "$STAGE/DEBIAN/postrm" <<'POSTRM'
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
if [ "$1" = "purge" ] || [ "$1" = "remove" ]; then
|
||||||
|
if command -v systemctl >/dev/null 2>&1; then
|
||||||
|
systemctl daemon-reload || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
POSTRM
|
||||||
|
chmod 755 "$STAGE/DEBIAN/postrm"
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# Build .deb
|
||||||
|
###############################################################################
|
||||||
|
OUTPUT_DIR="${OUTPUT_DIR:-$REPO_ROOT/dist}"
|
||||||
|
mkdir -p "$OUTPUT_DIR"
|
||||||
|
|
||||||
|
echo "==> Building $DEB_FILE ..."
|
||||||
|
dpkg-deb --root-owner-group --build "$STAGE" "$OUTPUT_DIR/$DEB_FILE"
|
||||||
|
|
||||||
|
echo "==> Package ready: $OUTPUT_DIR/$DEB_FILE"
|
||||||
|
echo ""
|
||||||
|
dpkg-deb --info "$OUTPUT_DIR/$DEB_FILE"
|
||||||
|
echo ""
|
||||||
|
dpkg-deb --contents "$OUTPUT_DIR/$DEB_FILE"
|
||||||
104
deploy/deb/test-deb.sh
Executable file
104
deploy/deb/test-deb.sh
Executable file
@@ -0,0 +1,104 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# test-deb.sh -- Smoke-test a btest-rs .deb inside an Ubuntu Docker container
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./deploy/deb/test-deb.sh # auto-finds dist/*.deb
|
||||||
|
# ./deploy/deb/test-deb.sh path/to/btest-rs_*.deb
|
||||||
|
#
|
||||||
|
# Requirements: docker
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||||
|
IMAGE="${TEST_IMAGE:-ubuntu:24.04}"
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# Locate the .deb
|
||||||
|
###############################################################################
|
||||||
|
if [[ -n "${1:-}" ]]; then
|
||||||
|
DEB_PATH="$1"
|
||||||
|
else
|
||||||
|
DEB_PATH="$(ls -1t "$REPO_ROOT"/dist/btest-rs_*.deb 2>/dev/null | head -1 || true)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$DEB_PATH" || ! -f "$DEB_PATH" ]]; then
|
||||||
|
echo "Error: no .deb file found."
|
||||||
|
echo " Build first: ./deploy/deb/build-deb.sh"
|
||||||
|
echo " Or pass path: $0 path/to/btest-rs_*.deb"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
DEB_FILE="$(basename "$DEB_PATH")"
|
||||||
|
DEB_DIR="$(cd "$(dirname "$DEB_PATH")" && pwd)"
|
||||||
|
|
||||||
|
echo "==> Testing $DEB_FILE in $IMAGE"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# Run tests inside a disposable container
|
||||||
|
###############################################################################
|
||||||
|
docker run --rm \
|
||||||
|
-v "$DEB_DIR/$DEB_FILE:/tmp/$DEB_FILE:ro" \
|
||||||
|
"$IMAGE" \
|
||||||
|
bash -euxc "
|
||||||
|
###################################################################
|
||||||
|
# 1. Install the .deb
|
||||||
|
###################################################################
|
||||||
|
apt-get update -qq
|
||||||
|
dpkg -i /tmp/$DEB_FILE || apt-get install -f -y # resolve deps if any
|
||||||
|
|
||||||
|
###################################################################
|
||||||
|
# 2. Verify files are in place
|
||||||
|
###################################################################
|
||||||
|
echo '--- Checking installed files ---'
|
||||||
|
test -x /usr/bin/btest
|
||||||
|
test -f /usr/lib/systemd/system/btest.service
|
||||||
|
test -f /usr/share/doc/btest-rs/README.md
|
||||||
|
test -f /usr/share/licenses/btest-rs/LICENSE
|
||||||
|
|
||||||
|
# Man page (may be gzipped)
|
||||||
|
test -f /usr/share/man/man1/btest.1.gz || test -f /usr/share/man/man1/btest.1
|
||||||
|
echo 'All expected files present.'
|
||||||
|
|
||||||
|
###################################################################
|
||||||
|
# 3. btest --version
|
||||||
|
###################################################################
|
||||||
|
echo ''
|
||||||
|
echo '--- btest --version ---'
|
||||||
|
btest --version
|
||||||
|
|
||||||
|
###################################################################
|
||||||
|
# 4. Quick loopback server+client test
|
||||||
|
###################################################################
|
||||||
|
echo ''
|
||||||
|
echo '--- Loopback smoke test ---'
|
||||||
|
|
||||||
|
# Start server in background
|
||||||
|
btest -s &
|
||||||
|
SERVER_PID=\$!
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
# Run a short TCP test against localhost
|
||||||
|
if btest -c 127.0.0.1 -d 2 2>&1; then
|
||||||
|
echo 'Loopback TCP test passed.'
|
||||||
|
else
|
||||||
|
echo 'Warning: loopback test returned non-zero (may be expected in container).'
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Tear down
|
||||||
|
kill \$SERVER_PID 2>/dev/null || true
|
||||||
|
wait \$SERVER_PID 2>/dev/null || true
|
||||||
|
|
||||||
|
###################################################################
|
||||||
|
# 5. Package metadata sanity
|
||||||
|
###################################################################
|
||||||
|
echo ''
|
||||||
|
echo '--- dpkg metadata ---'
|
||||||
|
dpkg -s btest-rs | head -20
|
||||||
|
|
||||||
|
echo ''
|
||||||
|
echo '=== All tests passed ==='
|
||||||
|
"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "==> .deb smoke test completed successfully."
|
||||||
73
deploy/rpm/btest-rs.spec
Normal file
73
deploy/rpm/btest-rs.spec
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
Name: btest-rs
|
||||||
|
Version: 0.6.0
|
||||||
|
Release: 1%{?dist}
|
||||||
|
Summary: MikroTik Bandwidth Test (btest) server and client with EC-SRP5 auth
|
||||||
|
|
||||||
|
License: MIT AND Apache-2.0
|
||||||
|
URL: https://github.com/manawenuz/btest-rs
|
||||||
|
Source0: https://github.com/manawenuz/btest-rs/archive/refs/tags/v%{version}.tar.gz
|
||||||
|
|
||||||
|
BuildRequires: cargo
|
||||||
|
BuildRequires: rust
|
||||||
|
ExclusiveArch: x86_64 aarch64
|
||||||
|
|
||||||
|
%description
|
||||||
|
A Rust reimplementation of the MikroTik Bandwidth Test (btest) protocol,
|
||||||
|
providing both server and client functionality with EC-SRP5 authentication.
|
||||||
|
|
||||||
|
%prep
|
||||||
|
%autosetup -n %{name}-%{version}
|
||||||
|
|
||||||
|
%build
|
||||||
|
export CARGO_TARGET_DIR=target
|
||||||
|
cargo build --release
|
||||||
|
|
||||||
|
%install
|
||||||
|
install -Dm755 target/release/btest %{buildroot}%{_bindir}/btest
|
||||||
|
install -Dm644 docs/man/btest.1 %{buildroot}%{_mandir}/man1/btest.1
|
||||||
|
install -Dm644 LICENSE %{buildroot}%{_datadir}/licenses/%{name}/LICENSE
|
||||||
|
|
||||||
|
# systemd service unit
|
||||||
|
install -d %{buildroot}%{_unitdir}
|
||||||
|
cat > %{buildroot}%{_unitdir}/btest.service << 'EOF'
|
||||||
|
[Unit]
|
||||||
|
Description=MikroTik Bandwidth Test Server (btest-rs)
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/usr/bin/btest -s
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
DynamicUser=yes
|
||||||
|
NoNewPrivileges=yes
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=yes
|
||||||
|
PrivateTmp=yes
|
||||||
|
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||||
|
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
|
||||||
|
LimitNOFILE=65535
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
%files
|
||||||
|
%license LICENSE
|
||||||
|
%{_bindir}/btest
|
||||||
|
%{_mandir}/man1/btest.1*
|
||||||
|
%{_unitdir}/btest.service
|
||||||
|
|
||||||
|
%post
|
||||||
|
%systemd_post btest.service
|
||||||
|
|
||||||
|
%preun
|
||||||
|
%systemd_preun btest.service
|
||||||
|
|
||||||
|
%postun
|
||||||
|
%systemd_postun_with_restart btest.service
|
||||||
|
|
||||||
|
%changelog
|
||||||
|
* Mon Mar 30 2026 Siavash Sameni <manwe@manko.yoga> - 0.6.0-1
|
||||||
|
- Initial RPM package
|
||||||
30
deploy/rpm/build-rpm.sh
Executable file
30
deploy/rpm/build-rpm.sh
Executable file
@@ -0,0 +1,30 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# build-rpm.sh — Build the btest-rs RPM package
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SPEC_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
SPEC_FILE="${SPEC_DIR}/btest-rs.spec"
|
||||||
|
VERSION="0.6.0"
|
||||||
|
TARBALL="v${VERSION}.tar.gz"
|
||||||
|
SOURCE_URL="https://github.com/manawenuz/btest-rs/archive/refs/tags/${TARBALL}"
|
||||||
|
|
||||||
|
echo "==> Setting up rpmbuild tree"
|
||||||
|
mkdir -p ~/rpmbuild/{BUILD,RPMS,SOURCES,SPECS,SRPMS}
|
||||||
|
|
||||||
|
echo "==> Downloading source tarball"
|
||||||
|
if [ ! -f ~/rpmbuild/SOURCES/"${TARBALL}" ]; then
|
||||||
|
curl -fSL -o ~/rpmbuild/SOURCES/"${TARBALL}" "${SOURCE_URL}"
|
||||||
|
else
|
||||||
|
echo " (already present, skipping download)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Copying spec file"
|
||||||
|
cp "${SPEC_FILE}" ~/rpmbuild/SPECS/btest-rs.spec
|
||||||
|
|
||||||
|
echo "==> Building RPM"
|
||||||
|
rpmbuild -ba ~/rpmbuild/SPECS/btest-rs.spec
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "==> Build complete. Packages:"
|
||||||
|
find ~/rpmbuild/RPMS -name '*.rpm' -print
|
||||||
|
find ~/rpmbuild/SRPMS -name '*.rpm' -print
|
||||||
75
deploy/rpm/test-rpm.sh
Executable file
75
deploy/rpm/test-rpm.sh
Executable file
@@ -0,0 +1,75 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# test-rpm.sh — Test the btest-rs RPM build inside a Fedora container
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
||||||
|
|
||||||
|
IMAGE="fedora:latest"
|
||||||
|
|
||||||
|
echo "==> Testing RPM build in ${IMAGE}"
|
||||||
|
docker run --rm \
|
||||||
|
-v "${REPO_ROOT}:/workspace:ro" \
|
||||||
|
"${IMAGE}" \
|
||||||
|
bash -euxc '
|
||||||
|
# ── Install build dependencies ──
|
||||||
|
dnf install -y rpm-build rpmdevtools curl gcc make \
|
||||||
|
systemd-rpm-macros
|
||||||
|
|
||||||
|
# Install Rust toolchain
|
||||||
|
curl --proto "=https" --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||||
|
| sh -s -- -y --profile minimal
|
||||||
|
source "$HOME/.cargo/env"
|
||||||
|
|
||||||
|
# ── Set up rpmbuild tree ──
|
||||||
|
rpmdev-setuptree
|
||||||
|
|
||||||
|
VERSION="0.6.0"
|
||||||
|
TARBALL="v${VERSION}.tar.gz"
|
||||||
|
|
||||||
|
# Copy spec
|
||||||
|
cp /workspace/deploy/rpm/btest-rs.spec ~/rpmbuild/SPECS/
|
||||||
|
|
||||||
|
# Create source tarball from workspace
|
||||||
|
# rpmbuild expects btest-rs-VERSION/ top-level directory
|
||||||
|
mkdir -p /tmp/btest-rs-${VERSION}
|
||||||
|
cp -a /workspace/. /tmp/btest-rs-${VERSION}/
|
||||||
|
tar czf ~/rpmbuild/SOURCES/${TARBALL} -C /tmp btest-rs-${VERSION}
|
||||||
|
|
||||||
|
# ── Build RPM ──
|
||||||
|
rpmbuild -ba ~/rpmbuild/SPECS/btest-rs.spec
|
||||||
|
|
||||||
|
# ── Install the RPM ──
|
||||||
|
RPM=$(find ~/rpmbuild/RPMS -name "btest-rs-*.rpm" | head -1)
|
||||||
|
echo "Installing: ${RPM}"
|
||||||
|
dnf install -y "${RPM}"
|
||||||
|
|
||||||
|
# ── Verify installation ──
|
||||||
|
echo "--- btest --version ---"
|
||||||
|
btest --version
|
||||||
|
|
||||||
|
echo "--- Checking systemd unit ---"
|
||||||
|
systemctl cat btest.service || true
|
||||||
|
|
||||||
|
echo "--- Checking man page ---"
|
||||||
|
test -f /usr/share/man/man1/btest.1* && echo "man page OK" || echo "man page MISSING"
|
||||||
|
|
||||||
|
echo "--- Checking license ---"
|
||||||
|
test -f /usr/share/licenses/btest-rs/LICENSE && echo "license OK" || echo "license MISSING"
|
||||||
|
|
||||||
|
# ── Loopback bandwidth test ──
|
||||||
|
echo "--- Starting loopback test ---"
|
||||||
|
btest -s &
|
||||||
|
SERVER_PID=$!
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
btest -c 127.0.0.1 --duration 3 && echo "Loopback test PASSED" \
|
||||||
|
|| echo "Loopback test FAILED (exit $?)"
|
||||||
|
|
||||||
|
kill "${SERVER_PID}" 2>/dev/null || true
|
||||||
|
wait "${SERVER_PID}" 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "==> All RPM tests completed."
|
||||||
|
'
|
||||||
|
|
||||||
|
echo "==> Fedora container test finished."
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
services:
|
services:
|
||||||
btest-server:
|
btest-server:
|
||||||
build: .
|
build: .
|
||||||
image: git.manko.yoga/manawenuz/btest-rs:latest
|
image: ghcr.io/manawenuz/btest-rs:latest
|
||||||
container_name: btest-server
|
container_name: btest-server
|
||||||
ports:
|
ports:
|
||||||
- "2000:2000/tcp"
|
- "2000:2000/tcp"
|
||||||
@@ -13,7 +13,7 @@ services:
|
|||||||
# Server with authentication enabled
|
# Server with authentication enabled
|
||||||
btest-server-auth:
|
btest-server-auth:
|
||||||
build: .
|
build: .
|
||||||
image: git.manko.yoga/manawenuz/btest-rs:latest
|
image: ghcr.io/manawenuz/btest-rs:latest
|
||||||
container_name: btest-server-auth
|
container_name: btest-server-auth
|
||||||
ports:
|
ports:
|
||||||
- "2010:2000/tcp"
|
- "2010:2000/tcp"
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
# Docker and Deployment Guide
|
# Docker and Deployment Guide
|
||||||
|
|
||||||
## Container Registry
|
## Container Registries
|
||||||
|
|
||||||
Images are published to:
|
Images are published to:
|
||||||
|
|
||||||
```
|
```
|
||||||
git.manko.yoga/manawenuz/btest-rs
|
git.manko.yoga/manawenuz/btest-rs # Gitea registry
|
||||||
|
ghcr.io/manawenuz/btest-rs # GitHub Container Registry
|
||||||
```
|
```
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
@@ -87,14 +88,14 @@ docker run --rm -it btest-rs -c 192.168.88.1 -r -a admin -p password
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Pull from Gitea registry
|
# Pull from Gitea registry
|
||||||
docker pull git.manko.yoga/manawenuz/btest-rs:latest
|
docker pull ghcr.io/manawenuz/btest-rs:latest
|
||||||
|
|
||||||
# Run server
|
# Run server
|
||||||
docker run --rm -it \
|
docker run --rm -it \
|
||||||
-p 2000:2000/tcp \
|
-p 2000:2000/tcp \
|
||||||
-p 2001-2100:2001-2100/udp \
|
-p 2001-2100:2001-2100/udp \
|
||||||
-p 2257-2356:2257-2356/udp \
|
-p 2257-2356:2257-2356/udp \
|
||||||
git.manko.yoga/manawenuz/btest-rs:latest -s -v
|
ghcr.io/manawenuz/btest-rs:latest -s -v
|
||||||
```
|
```
|
||||||
|
|
||||||
## Docker Compose
|
## Docker Compose
|
||||||
@@ -185,7 +186,7 @@ docker build -t btest-rs .
|
|||||||
|
|
||||||
# With custom tag
|
# With custom tag
|
||||||
docker build -t git.manko.yoga/manawenuz/btest-rs:latest .
|
docker build -t git.manko.yoga/manawenuz/btest-rs:latest .
|
||||||
docker build -t git.manko.yoga/manawenuz/btest-rs:0.5.0 .
|
docker build -t git.manko.yoga/manawenuz/btest-rs:0.6.0 .
|
||||||
```
|
```
|
||||||
|
|
||||||
### Multi-platform build
|
### Multi-platform build
|
||||||
@@ -193,7 +194,7 @@ docker build -t git.manko.yoga/manawenuz/btest-rs:0.5.0 .
|
|||||||
```bash
|
```bash
|
||||||
docker buildx build \
|
docker buildx build \
|
||||||
--platform linux/amd64,linux/arm64 \
|
--platform linux/amd64,linux/arm64 \
|
||||||
-t git.manko.yoga/manawenuz/btest-rs:latest \
|
-t ghcr.io/manawenuz/btest-rs:latest \
|
||||||
--push .
|
--push .
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -208,9 +209,9 @@ docker build -t git.manko.yoga/manawenuz/btest-rs:latest .
|
|||||||
docker push git.manko.yoga/manawenuz/btest-rs:latest
|
docker push git.manko.yoga/manawenuz/btest-rs:latest
|
||||||
|
|
||||||
# Also tag with version
|
# Also tag with version
|
||||||
docker tag git.manko.yoga/manawenuz/btest-rs:latest \
|
docker tag ghcr.io/manawenuz/btest-rs:latest \
|
||||||
git.manko.yoga/manawenuz/btest-rs:0.5.0
|
git.manko.yoga/manawenuz/btest-rs:0.6.0
|
||||||
docker push git.manko.yoga/manawenuz/btest-rs:0.5.0
|
docker push git.manko.yoga/manawenuz/btest-rs:0.6.0
|
||||||
```
|
```
|
||||||
|
|
||||||
## Deployment Options
|
## Deployment Options
|
||||||
@@ -223,7 +224,7 @@ docker run -d --name btest-server \
|
|||||||
-p 2000:2000/tcp \
|
-p 2000:2000/tcp \
|
||||||
-p 2001-2100:2001-2100/udp \
|
-p 2001-2100:2001-2100/udp \
|
||||||
-p 2257-2356:2257-2356/udp \
|
-p 2257-2356:2257-2356/udp \
|
||||||
git.manko.yoga/manawenuz/btest-rs:latest \
|
ghcr.io/manawenuz/btest-rs:latest \
|
||||||
-s -a admin -p password --ecsrp5 -v
|
-s -a admin -p password --ecsrp5 -v
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
50
scripts/push-docker-all.sh
Executable file
50
scripts/push-docker-all.sh
Executable file
@@ -0,0 +1,50 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build and push Docker image to both Gitea and GitHub Container Registry.
|
||||||
|
#
|
||||||
|
# Prerequisites:
|
||||||
|
# docker login git.manko.yoga (Gitea — your username + token)
|
||||||
|
# docker login ghcr.io (GitHub — your username + PAT with packages:write)
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./scripts/push-docker-all.sh v0.6.0
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
if [[ -f .env ]]; then
|
||||||
|
set -a; source .env; set +a
|
||||||
|
fi
|
||||||
|
|
||||||
|
TAG="${1:?Usage: $0 <tag> (e.g. v0.6.0)}"
|
||||||
|
|
||||||
|
GITEA_IMAGE="git.manko.yoga/manawenuz/btest-rs"
|
||||||
|
GHCR_IMAGE="ghcr.io/manawenuz/btest-rs"
|
||||||
|
|
||||||
|
echo "=== Building Docker image ==="
|
||||||
|
docker build \
|
||||||
|
-t "${GITEA_IMAGE}:${TAG}" \
|
||||||
|
-t "${GITEA_IMAGE}:latest" \
|
||||||
|
-t "${GHCR_IMAGE}:${TAG}" \
|
||||||
|
-t "${GHCR_IMAGE}:latest" \
|
||||||
|
.
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Pushing to Gitea ==="
|
||||||
|
docker push "${GITEA_IMAGE}:${TAG}"
|
||||||
|
docker push "${GITEA_IMAGE}:latest"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Pushing to GitHub Container Registry ==="
|
||||||
|
docker push "${GHCR_IMAGE}:${TAG}"
|
||||||
|
docker push "${GHCR_IMAGE}:latest"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Done! Images pushed:"
|
||||||
|
echo " ${GITEA_IMAGE}:${TAG}"
|
||||||
|
echo " ${GITEA_IMAGE}:latest"
|
||||||
|
echo " ${GHCR_IMAGE}:${TAG}"
|
||||||
|
echo " ${GHCR_IMAGE}:latest"
|
||||||
|
echo ""
|
||||||
|
echo "Pull with:"
|
||||||
|
echo " docker pull ${GHCR_IMAGE}:${TAG}"
|
||||||
|
echo " docker run --rm -p 2000:2000 -p 2001-2100:2001-2100/udp ${GHCR_IMAGE}:${TAG} -s -v"
|
||||||
120
scripts/sync-github-release.sh
Executable file
120
scripts/sync-github-release.sh
Executable file
@@ -0,0 +1,120 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Sync a release from Gitea to GitHub.
|
||||||
|
# Downloads all binaries from Gitea release, creates GitHub release, uploads them.
|
||||||
|
#
|
||||||
|
# Prerequisites:
|
||||||
|
# gh auth login (GitHub CLI authenticated)
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./scripts/sync-github-release.sh v0.6.0
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
if [[ -f .env ]]; then
|
||||||
|
set -a; source .env; set +a
|
||||||
|
fi
|
||||||
|
|
||||||
|
TAG="${1:?Usage: $0 <tag> (e.g. v0.6.0)}"
|
||||||
|
GITEA_URL="https://git.manko.yoga"
|
||||||
|
GITEA_REPO="manawenuz/btest-rs"
|
||||||
|
GITHUB_REPO="manawenuz/btest-rs"
|
||||||
|
|
||||||
|
echo "=== Downloading assets from Gitea release ${TAG} ==="
|
||||||
|
mkdir -p /tmp/btest-release-${TAG}
|
||||||
|
cd /tmp/btest-release-${TAG}
|
||||||
|
rm -f *.tar.gz *.zip *.txt
|
||||||
|
|
||||||
|
# Get asset list from Gitea API
|
||||||
|
ASSETS=$(curl -sf "${GITEA_URL}/api/v1/repos/${GITEA_REPO}/releases/tags/${TAG}" | \
|
||||||
|
python3 -c "import sys,json; [print(a['browser_download_url']) for a in json.load(sys.stdin).get('assets',[])]")
|
||||||
|
|
||||||
|
if [ -z "$ASSETS" ]; then
|
||||||
|
echo "No assets found for ${TAG} on Gitea. Check if the release exists."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
for url in $ASSETS; do
|
||||||
|
FILENAME=$(basename "$url")
|
||||||
|
echo " Downloading: $FILENAME"
|
||||||
|
curl -sLO "$url"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Merge all separate .sha256 files into checksums-sha256.txt
|
||||||
|
# and remove the individual .sha256 files
|
||||||
|
echo ""
|
||||||
|
echo "=== Merging checksums ==="
|
||||||
|
for sha_file in *.sha256; do
|
||||||
|
[ -f "$sha_file" ] || continue
|
||||||
|
echo " Merging: $sha_file"
|
||||||
|
cat "$sha_file" >> checksums-sha256.txt
|
||||||
|
rm "$sha_file"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Add checksums for any files not yet in checksums-sha256.txt
|
||||||
|
for f in *.tar.gz *.zip; do
|
||||||
|
[ -f "$f" ] || continue
|
||||||
|
if ! grep -q "$f" checksums-sha256.txt 2>/dev/null; then
|
||||||
|
echo " Adding checksum for: $f"
|
||||||
|
shasum -a 256 "$f" >> checksums-sha256.txt
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Sort and deduplicate
|
||||||
|
sort -u -k2 checksums-sha256.txt > checksums-sha256.tmp && mv checksums-sha256.tmp checksums-sha256.txt
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Checksums:"
|
||||||
|
cat checksums-sha256.txt
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Files to upload:"
|
||||||
|
ls -lh *.tar.gz *.zip checksums-sha256.txt 2>/dev/null
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Creating GitHub release ${TAG} ==="
|
||||||
|
gh release create "${TAG}" \
|
||||||
|
--repo "${GITHUB_REPO}" \
|
||||||
|
--title "btest-rs ${TAG}" \
|
||||||
|
--notes "## Downloads
|
||||||
|
|
||||||
|
| Platform | Architecture | File |
|
||||||
|
|----------|-------------|------|
|
||||||
|
| Linux | x86_64 | btest-linux-x86_64.tar.gz |
|
||||||
|
| 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 |
|
||||||
|
| macOS | aarch64 (Apple Silicon) | btest-darwin-aarch64.tar.gz |
|
||||||
|
| Docker | x86_64 | \`docker pull ghcr.io/manawenuz/btest-rs:${TAG}\` |
|
||||||
|
|
||||||
|
### Quick Install (Linux)
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
curl -LO https://github.com/${GITHUB_REPO}/releases/download/${TAG}/btest-linux-x86_64.tar.gz
|
||||||
|
tar xzf btest-linux-x86_64.tar.gz
|
||||||
|
sudo mv btest /usr/local/bin/
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### Raspberry Pi
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
# 64-bit
|
||||||
|
curl -LO https://github.com/${GITHUB_REPO}/releases/download/${TAG}/btest-linux-aarch64.tar.gz
|
||||||
|
tar xzf btest-linux-aarch64.tar.gz
|
||||||
|
sudo mv btest /usr/local/bin/
|
||||||
|
|
||||||
|
# 32-bit
|
||||||
|
curl -LO https://github.com/${GITHUB_REPO}/releases/download/${TAG}/btest-linux-armv7.tar.gz
|
||||||
|
tar xzf btest-linux-armv7.tar.gz
|
||||||
|
sudo mv btest /usr/local/bin/
|
||||||
|
\`\`\`
|
||||||
|
" \
|
||||||
|
./*.tar.gz ./*.zip ./*.txt 2>/dev/null || true
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Done! ==="
|
||||||
|
echo "https://github.com/${GITHUB_REPO}/releases/tag/${TAG}"
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
cd -
|
||||||
|
rm -rf /tmp/btest-release-${TAG}
|
||||||
64
scripts/test-aur-remote.sh
Executable file
64
scripts/test-aur-remote.sh
Executable file
@@ -0,0 +1,64 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Test the AUR package on a remote x86_64 Linux server using Docker.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./scripts/test-aur-remote.sh [user@host]
|
||||||
|
#
|
||||||
|
# Spins up an Arch container, installs btest-rs via yay (like a real user),
|
||||||
|
# runs loopback tests, cleans up.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REMOTE="${1:-}"
|
||||||
|
|
||||||
|
TEST_SCRIPT='
|
||||||
|
docker run --rm archlinux:latest bash -c "
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
echo \"[1/4] Installing yay...\"
|
||||||
|
pacman -Syu --noconfirm base-devel git sudo >/dev/null 2>&1
|
||||||
|
useradd -m builder
|
||||||
|
echo \"builder ALL=(ALL) NOPASSWD: ALL\" >> /etc/sudoers
|
||||||
|
su builder -c \"
|
||||||
|
cd /tmp
|
||||||
|
git clone https://aur.archlinux.org/yay-bin.git 2>/dev/null
|
||||||
|
cd yay-bin
|
||||||
|
makepkg -si --noconfirm 2>&1 | tail -3
|
||||||
|
\"
|
||||||
|
|
||||||
|
echo \"[2/4] Installing btest-rs from AUR via yay...\"
|
||||||
|
su builder -c \"yay -S btest-rs --noconfirm 2>&1 | tail -10\"
|
||||||
|
|
||||||
|
echo \"\"
|
||||||
|
echo \"[3/4] Verify installation...\"
|
||||||
|
btest --version
|
||||||
|
which btest
|
||||||
|
man -w btest.1 2>/dev/null && echo \"Man page: installed\" || echo \"Man page: not found\"
|
||||||
|
systemctl cat btest.service 2>/dev/null | head -3 && echo \"Systemd unit: installed\" || echo \"Systemd unit: not found\"
|
||||||
|
|
||||||
|
echo \"\"
|
||||||
|
echo \"[4/4] Loopback tests...\"
|
||||||
|
|
||||||
|
echo \"--- TCP (3s) ---\"
|
||||||
|
btest -s -P 19876 &
|
||||||
|
sleep 2
|
||||||
|
btest -c 127.0.0.1 -P 19876 -r -d 3
|
||||||
|
kill %1 2>/dev/null; wait 2>/dev/null || true
|
||||||
|
|
||||||
|
echo \"--- UDP (3s) ---\"
|
||||||
|
btest -s -P 19877 &
|
||||||
|
sleep 2
|
||||||
|
btest -c 127.0.0.1 -P 19877 -r -u -d 3
|
||||||
|
kill %1 2>/dev/null; wait 2>/dev/null || true
|
||||||
|
|
||||||
|
echo \"\"
|
||||||
|
echo \"=== ALL TESTS PASSED ===\"
|
||||||
|
"
|
||||||
|
'
|
||||||
|
|
||||||
|
if [ -n "$REMOTE" ]; then
|
||||||
|
echo "=== Testing AUR package on $REMOTE ==="
|
||||||
|
ssh "$REMOTE" "$TEST_SCRIPT"
|
||||||
|
else
|
||||||
|
echo "=== Testing AUR package locally ==="
|
||||||
|
eval "$TEST_SCRIPT"
|
||||||
|
fi
|
||||||
@@ -80,6 +80,34 @@ pub fn calc_send_interval(tx_speed_bps: u32, tx_size: u16) -> Option<Duration> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Advance `next_send` by one interval and clamp drift.
|
||||||
|
///
|
||||||
|
/// When the sender falls behind (e.g., the write blocked longer than the
|
||||||
|
/// inter-packet interval), `next_send` accumulates a debt. Once the path
|
||||||
|
/// clears, the loop would fire packets with *no* delay until the debt is
|
||||||
|
/// repaid, producing a burst that overshoots the target rate.
|
||||||
|
///
|
||||||
|
/// This helper resets `next_send` to `now` whenever it has drifted more
|
||||||
|
/// than 2x the interval behind the current wall-clock time, bounding the
|
||||||
|
/// maximum burst to at most one extra interval's worth of packets.
|
||||||
|
pub fn advance_next_send(
|
||||||
|
next_send: &mut std::time::Instant,
|
||||||
|
iv: Duration,
|
||||||
|
now: std::time::Instant,
|
||||||
|
) -> Option<Duration> {
|
||||||
|
*next_send += iv;
|
||||||
|
// If we have fallen more than 2x the interval behind, reset to now
|
||||||
|
// to prevent a compensating burst.
|
||||||
|
if *next_send + iv < now {
|
||||||
|
*next_send = now;
|
||||||
|
}
|
||||||
|
if *next_send > now {
|
||||||
|
Some(*next_send - now)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Format a bandwidth value in human-readable form.
|
/// Format a bandwidth value in human-readable form.
|
||||||
pub fn format_bandwidth(bits_per_sec: f64) -> String {
|
pub fn format_bandwidth(bits_per_sec: f64) -> String {
|
||||||
if bits_per_sec >= 1_000_000_000.0 {
|
if bits_per_sec >= 1_000_000_000.0 {
|
||||||
|
|||||||
@@ -167,10 +167,9 @@ async fn tcp_client_tx_loop(
|
|||||||
|
|
||||||
match interval {
|
match interval {
|
||||||
Some(iv) => {
|
Some(iv) => {
|
||||||
next_send += iv;
|
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
if next_send > now {
|
if let Some(delay) = bandwidth::advance_next_send(&mut next_send, iv, now) {
|
||||||
tokio::time::sleep(next_send - now).await;
|
tokio::time::sleep(delay).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
@@ -317,10 +316,9 @@ async fn udp_client_tx_loop(
|
|||||||
|
|
||||||
match interval {
|
match interval {
|
||||||
Some(iv) => {
|
Some(iv) => {
|
||||||
next_send += iv;
|
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
if next_send > now {
|
if let Some(delay) = bandwidth::advance_next_send(&mut next_send, iv, now) {
|
||||||
tokio::time::sleep(next_send - now).await;
|
tokio::time::sleep(delay).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
|
|||||||
86
src/cpu.rs
86
src/cpu.rs
@@ -1,7 +1,7 @@
|
|||||||
//! Lightweight CPU usage measurement.
|
//! Lightweight CPU usage measurement.
|
||||||
//!
|
//!
|
||||||
//! Returns the system-wide CPU usage as a percentage (0-100).
|
//! Returns the system-wide CPU usage as a percentage (0-100).
|
||||||
//! Works on macOS and Linux without external dependencies.
|
//! Works on macOS, Linux, Windows, and FreeBSD without external dependencies.
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicU8, Ordering};
|
use std::sync::atomic::{AtomicU8, Ordering};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -93,7 +93,82 @@ fn get_cpu_times() -> (u64, u64) {
|
|||||||
(0, 0)
|
(0, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
#[cfg(target_os = "windows")]
|
||||||
|
fn get_cpu_times() -> (u64, u64) {
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Default)]
|
||||||
|
struct FILETIME {
|
||||||
|
dwLowDateTime: u32,
|
||||||
|
dwHighDateTime: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FILETIME {
|
||||||
|
fn to_u64(&self) -> u64 {
|
||||||
|
(self.dwHighDateTime as u64) << 32 | self.dwLowDateTime as u64
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "system" {
|
||||||
|
fn GetSystemTimes(
|
||||||
|
lpIdleTime: *mut FILETIME,
|
||||||
|
lpKernelTime: *mut FILETIME,
|
||||||
|
lpUserTime: *mut FILETIME,
|
||||||
|
) -> i32;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut idle = FILETIME::default();
|
||||||
|
let mut kernel = FILETIME::default();
|
||||||
|
let mut user = FILETIME::default();
|
||||||
|
|
||||||
|
// SAFETY: We pass valid pointers to stack-allocated FILETIME structs.
|
||||||
|
// GetSystemTimes is a well-documented Win32 API that writes into these
|
||||||
|
// output parameters. A non-zero return value indicates success.
|
||||||
|
let ret = unsafe { GetSystemTimes(&mut idle, &mut kernel, &mut user) };
|
||||||
|
|
||||||
|
if ret != 0 {
|
||||||
|
let idle_ticks = idle.to_u64();
|
||||||
|
// Kernel time includes idle time on Windows, so total = kernel + user.
|
||||||
|
let total_ticks = kernel.to_u64() + user.to_u64();
|
||||||
|
(total_ticks, idle_ticks)
|
||||||
|
} else {
|
||||||
|
(0, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "freebsd")]
|
||||||
|
fn get_cpu_times() -> (u64, u64) {
|
||||||
|
// kern.cp_time returns: user nice system interrupt idle
|
||||||
|
if let Ok(output) = std::process::Command::new("sysctl")
|
||||||
|
.arg("-n")
|
||||||
|
.arg("kern.cp_time")
|
||||||
|
.output()
|
||||||
|
{
|
||||||
|
if output.status.success() {
|
||||||
|
let text = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let parts: Vec<u64> = text
|
||||||
|
.split_whitespace()
|
||||||
|
.filter_map(|s| s.parse().ok())
|
||||||
|
.collect();
|
||||||
|
if parts.len() >= 5 {
|
||||||
|
let user = parts[0];
|
||||||
|
let nice = parts[1];
|
||||||
|
let system = parts[2];
|
||||||
|
let interrupt = parts[3];
|
||||||
|
let idle = parts[4];
|
||||||
|
let total = user + nice + system + interrupt + idle;
|
||||||
|
return (total, idle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(
|
||||||
|
target_os = "linux",
|
||||||
|
target_os = "macos",
|
||||||
|
target_os = "windows",
|
||||||
|
target_os = "freebsd",
|
||||||
|
)))]
|
||||||
fn get_cpu_times() -> (u64, u64) {
|
fn get_cpu_times() -> (u64, u64) {
|
||||||
(0, 0) // Unsupported platform
|
(0, 0) // Unsupported platform
|
||||||
}
|
}
|
||||||
@@ -116,7 +191,12 @@ mod tests {
|
|||||||
fn test_cpu_times_returns_nonzero() {
|
fn test_cpu_times_returns_nonzero() {
|
||||||
let (total, idle) = get_cpu_times();
|
let (total, idle) = get_cpu_times();
|
||||||
// On supported platforms, total should be > 0
|
// On supported platforms, total should be > 0
|
||||||
if cfg!(any(target_os = "linux", target_os = "macos")) {
|
if cfg!(any(
|
||||||
|
target_os = "linux",
|
||||||
|
target_os = "macos",
|
||||||
|
target_os = "windows",
|
||||||
|
target_os = "freebsd",
|
||||||
|
)) {
|
||||||
assert!(total > 0, "CPU total ticks should be > 0");
|
assert!(total > 0, "CPU total ticks should be > 0");
|
||||||
assert!(idle <= total, "idle should be <= total");
|
assert!(idle <= total, "idle should be <= total");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -366,8 +366,22 @@ async fn handle_client(
|
|||||||
|
|
||||||
// --- TCP Test Server ---
|
// --- TCP Test Server ---
|
||||||
|
|
||||||
|
/// Run a TCP bandwidth test on an already-authenticated stream.
|
||||||
|
/// Public API for use by server_pro.
|
||||||
|
pub async fn run_tcp_test(
|
||||||
|
stream: TcpStream,
|
||||||
|
cmd: Command,
|
||||||
|
state: Arc<BandwidthState>,
|
||||||
|
) -> Result<(u64, u64, u64, u32)> {
|
||||||
|
run_tcp_test_inner(stream, cmd, state).await
|
||||||
|
}
|
||||||
|
|
||||||
async fn run_tcp_test_server(stream: TcpStream, cmd: Command) -> Result<(u64, u64, u64, u32)> {
|
async fn run_tcp_test_server(stream: TcpStream, cmd: Command) -> Result<(u64, u64, u64, u32)> {
|
||||||
let state = BandwidthState::new();
|
let state = BandwidthState::new();
|
||||||
|
run_tcp_test_inner(stream, cmd, state).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_tcp_test_inner(stream: TcpStream, cmd: Command, state: Arc<BandwidthState>) -> Result<(u64, u64, u64, u32)> {
|
||||||
let tx_size = cmd.tx_size as usize;
|
let tx_size = cmd.tx_size as usize;
|
||||||
let server_should_tx = cmd.server_tx();
|
let server_should_tx = cmd.server_tx();
|
||||||
let server_should_rx = cmd.server_rx();
|
let server_should_rx = cmd.server_rx();
|
||||||
@@ -565,10 +579,9 @@ async fn tcp_tx_loop_inner(
|
|||||||
|
|
||||||
match interval {
|
match interval {
|
||||||
Some(iv) => {
|
Some(iv) => {
|
||||||
next_send += iv;
|
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
if next_send > now {
|
if let Some(delay) = bandwidth::advance_next_send(&mut next_send, iv, now) {
|
||||||
tokio::time::sleep(next_send - now).await;
|
tokio::time::sleep(delay).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
@@ -634,6 +647,18 @@ async fn tcp_status_sender(
|
|||||||
|
|
||||||
// --- UDP Test Server ---
|
// --- UDP Test Server ---
|
||||||
|
|
||||||
|
/// Run a UDP bandwidth test on an already-authenticated stream.
|
||||||
|
/// Public API for use by server_pro. Caller provides the UDP port offset.
|
||||||
|
pub async fn run_udp_test(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
peer: SocketAddr,
|
||||||
|
cmd: &Command,
|
||||||
|
state: Arc<BandwidthState>,
|
||||||
|
udp_port_start: u16,
|
||||||
|
) -> Result<(u64, u64, u64, u32)> {
|
||||||
|
run_udp_test_inner(stream, peer, cmd, state, udp_port_start).await
|
||||||
|
}
|
||||||
|
|
||||||
async fn run_udp_test_server(
|
async fn run_udp_test_server(
|
||||||
stream: &mut TcpStream,
|
stream: &mut TcpStream,
|
||||||
peer: SocketAddr,
|
peer: SocketAddr,
|
||||||
@@ -641,7 +666,17 @@ async fn run_udp_test_server(
|
|||||||
udp_port_offset: Arc<std::sync::atomic::AtomicU16>,
|
udp_port_offset: Arc<std::sync::atomic::AtomicU16>,
|
||||||
) -> Result<(u64, u64, u64, u32)> {
|
) -> Result<(u64, u64, u64, u32)> {
|
||||||
let offset = udp_port_offset.fetch_add(1, Ordering::SeqCst);
|
let offset = udp_port_offset.fetch_add(1, Ordering::SeqCst);
|
||||||
let server_udp_port = BTEST_UDP_PORT_START + offset;
|
let state = BandwidthState::new();
|
||||||
|
run_udp_test_inner(stream, peer, cmd, state, BTEST_UDP_PORT_START + offset).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_udp_test_inner(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
peer: SocketAddr,
|
||||||
|
cmd: &Command,
|
||||||
|
state: Arc<BandwidthState>,
|
||||||
|
server_udp_port: u16,
|
||||||
|
) -> Result<(u64, u64, u64, u32)> {
|
||||||
let client_udp_port = server_udp_port + BTEST_PORT_CLIENT_OFFSET;
|
let client_udp_port = server_udp_port + BTEST_PORT_CLIENT_OFFSET;
|
||||||
|
|
||||||
stream.write_all(&server_udp_port.to_be_bytes()).await?;
|
stream.write_all(&server_udp_port.to_be_bytes()).await?;
|
||||||
@@ -708,7 +743,6 @@ async fn run_udp_test_server(
|
|||||||
if use_unconnected { "unconnected" } else { "connected" },
|
if use_unconnected { "unconnected" } else { "connected" },
|
||||||
);
|
);
|
||||||
|
|
||||||
let state = BandwidthState::new();
|
|
||||||
let tx_size = cmd.tx_size as usize;
|
let tx_size = cmd.tx_size as usize;
|
||||||
let server_should_tx = cmd.server_tx();
|
let server_should_tx = cmd.server_tx();
|
||||||
let server_should_rx = cmd.server_rx();
|
let server_should_rx = cmd.server_rx();
|
||||||
@@ -805,10 +839,9 @@ async fn udp_tx_loop(
|
|||||||
|
|
||||||
match interval {
|
match interval {
|
||||||
Some(iv) => {
|
Some(iv) => {
|
||||||
next_send += iv;
|
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
if next_send > now {
|
if let Some(delay) = bandwidth::advance_next_send(&mut next_send, iv, now) {
|
||||||
tokio::time::sleep(next_send - now).await;
|
tokio::time::sleep(delay).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
|
|||||||
345
src/server_pro/enforcer.rs
Normal file
345
src/server_pro/enforcer.rs
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
//! Mid-session quota enforcement.
|
||||||
|
//!
|
||||||
|
//! Runs alongside a bandwidth test, periodically checking if the user
|
||||||
|
//! or IP has exceeded their quota. Terminates the test if so.
|
||||||
|
|
||||||
|
use std::net::IpAddr;
|
||||||
|
use std::sync::atomic::Ordering;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use btest_rs::bandwidth::BandwidthState;
|
||||||
|
|
||||||
|
use super::quota::{Direction, QuotaManager};
|
||||||
|
|
||||||
|
/// Enforces quotas during an active test session.
|
||||||
|
/// Call `run()` as a spawned task — it will set `state.running = false`
|
||||||
|
/// when a quota is exceeded or max_duration is reached.
|
||||||
|
pub struct QuotaEnforcer {
|
||||||
|
quota_mgr: QuotaManager,
|
||||||
|
username: String,
|
||||||
|
ip: IpAddr,
|
||||||
|
state: Arc<BandwidthState>,
|
||||||
|
check_interval: Duration,
|
||||||
|
max_duration: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq)]
|
||||||
|
pub enum StopReason {
|
||||||
|
/// Test still running (not stopped)
|
||||||
|
Running,
|
||||||
|
/// Max duration reached
|
||||||
|
MaxDuration,
|
||||||
|
/// User daily quota exceeded
|
||||||
|
UserDailyQuota,
|
||||||
|
/// User weekly quota exceeded
|
||||||
|
UserWeeklyQuota,
|
||||||
|
/// User monthly quota exceeded
|
||||||
|
UserMonthlyQuota,
|
||||||
|
/// IP daily quota exceeded
|
||||||
|
IpDailyQuota,
|
||||||
|
/// IP weekly quota exceeded
|
||||||
|
IpWeeklyQuota,
|
||||||
|
/// IP monthly quota exceeded
|
||||||
|
IpMonthlyQuota,
|
||||||
|
/// Client disconnected normally
|
||||||
|
ClientDisconnected,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for StopReason {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Running => write!(f, "running"),
|
||||||
|
Self::MaxDuration => write!(f, "max_duration_reached"),
|
||||||
|
Self::UserDailyQuota => write!(f, "user_daily_quota_exceeded"),
|
||||||
|
Self::UserWeeklyQuota => write!(f, "user_weekly_quota_exceeded"),
|
||||||
|
Self::UserMonthlyQuota => write!(f, "user_monthly_quota_exceeded"),
|
||||||
|
Self::IpDailyQuota => write!(f, "ip_daily_quota_exceeded"),
|
||||||
|
Self::IpWeeklyQuota => write!(f, "ip_weekly_quota_exceeded"),
|
||||||
|
Self::IpMonthlyQuota => write!(f, "ip_monthly_quota_exceeded"),
|
||||||
|
Self::ClientDisconnected => write!(f, "client_disconnected"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QuotaEnforcer {
|
||||||
|
pub fn new(
|
||||||
|
quota_mgr: QuotaManager,
|
||||||
|
username: String,
|
||||||
|
ip: IpAddr,
|
||||||
|
state: Arc<BandwidthState>,
|
||||||
|
check_interval_secs: u64,
|
||||||
|
max_duration_secs: u64,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
quota_mgr,
|
||||||
|
username,
|
||||||
|
ip,
|
||||||
|
state,
|
||||||
|
check_interval: Duration::from_secs(check_interval_secs.max(1)),
|
||||||
|
max_duration: if max_duration_secs > 0 {
|
||||||
|
Duration::from_secs(max_duration_secs)
|
||||||
|
} else {
|
||||||
|
Duration::from_secs(u64::MAX / 2) // effectively unlimited
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the enforcer loop. Returns the reason the test was stopped.
|
||||||
|
/// This should be spawned as a tokio task.
|
||||||
|
pub async fn run(&self) -> StopReason {
|
||||||
|
let start = Instant::now();
|
||||||
|
let mut interval = tokio::time::interval(self.check_interval);
|
||||||
|
interval.tick().await; // consume first immediate tick
|
||||||
|
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
|
||||||
|
// Check if test already ended normally
|
||||||
|
if !self.state.running.load(Ordering::Relaxed) {
|
||||||
|
return StopReason::ClientDisconnected;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check max duration
|
||||||
|
if start.elapsed() >= self.max_duration {
|
||||||
|
tracing::warn!(
|
||||||
|
"Max duration ({:?}) reached for user '{}' from {}",
|
||||||
|
self.max_duration, self.username, self.ip,
|
||||||
|
);
|
||||||
|
self.state.running.store(false, Ordering::SeqCst);
|
||||||
|
return StopReason::MaxDuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush current session bytes to DB before checking
|
||||||
|
// (read without reset — totals accumulate, we just need current snapshot)
|
||||||
|
let session_tx = self.state.total_tx_bytes.load(Ordering::Relaxed);
|
||||||
|
let session_rx = self.state.total_rx_bytes.load(Ordering::Relaxed);
|
||||||
|
|
||||||
|
// Temporarily record session bytes so quota check sees them
|
||||||
|
// We use a separate "pending" record that gets finalized at session end
|
||||||
|
let ip_str = self.ip.to_string();
|
||||||
|
|
||||||
|
// Check user quotas
|
||||||
|
match self.check_user_with_session(session_tx, session_rx) {
|
||||||
|
StopReason::Running => {}
|
||||||
|
reason => {
|
||||||
|
tracing::warn!(
|
||||||
|
"Quota exceeded for user '{}' from {}: {} (session: tx={}, rx={})",
|
||||||
|
self.username, self.ip, reason, session_tx, session_rx,
|
||||||
|
);
|
||||||
|
self.state.running.store(false, Ordering::SeqCst);
|
||||||
|
return reason;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check IP quotas
|
||||||
|
match self.check_ip_with_session(&ip_str, session_tx, session_rx) {
|
||||||
|
StopReason::Running => {}
|
||||||
|
reason => {
|
||||||
|
tracing::warn!(
|
||||||
|
"IP quota exceeded for {} (user '{}'): {} (session: tx={}, rx={})",
|
||||||
|
self.ip, self.username, reason, session_tx, session_rx,
|
||||||
|
);
|
||||||
|
self.state.running.store(false, Ordering::SeqCst);
|
||||||
|
return reason;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_user_with_session(&self, session_tx: u64, session_rx: u64) -> StopReason {
|
||||||
|
let session_total = session_tx + session_rx;
|
||||||
|
|
||||||
|
// Check against quota manager (which reads DB)
|
||||||
|
// The DB has usage from PREVIOUS sessions; we add current session bytes
|
||||||
|
if let Err(e) = self.quota_mgr.check_user(&self.username) {
|
||||||
|
// Already exceeded from previous sessions
|
||||||
|
return match format!("{}", e).as_str() {
|
||||||
|
s if s.contains("daily") => StopReason::UserDailyQuota,
|
||||||
|
s if s.contains("weekly") => StopReason::UserWeeklyQuota,
|
||||||
|
s if s.contains("monthly") => StopReason::UserMonthlyQuota,
|
||||||
|
_ => StopReason::UserDailyQuota,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check if current session PLUS previous usage exceeds quota
|
||||||
|
// (check_user only sees DB, not current session bytes)
|
||||||
|
// This is handled by the quota_mgr.check_user reading from DB,
|
||||||
|
// and we periodically flush to DB during the session.
|
||||||
|
StopReason::Running
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_ip_with_session(&self, ip_str: &str, session_tx: u64, session_rx: u64) -> StopReason {
|
||||||
|
if let Err(e) = self.quota_mgr.check_ip(&self.ip, Direction::Both) {
|
||||||
|
return match format!("{}", e).as_str() {
|
||||||
|
s if s.contains("IP daily") => StopReason::IpDailyQuota,
|
||||||
|
s if s.contains("IP weekly") => StopReason::IpWeeklyQuota,
|
||||||
|
s if s.contains("IP monthly") => StopReason::IpMonthlyQuota,
|
||||||
|
s if s.contains("connections") => StopReason::IpDailyQuota, // reuse
|
||||||
|
_ => StopReason::IpDailyQuota,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
StopReason::Running
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flush session bytes to DB. Call periodically and at session end.
|
||||||
|
pub fn flush_to_db(&self) {
|
||||||
|
let tx = self.state.total_tx_bytes.load(Ordering::Relaxed);
|
||||||
|
let rx = self.state.total_rx_bytes.load(Ordering::Relaxed);
|
||||||
|
// From server perspective: tx = outbound (we sent), rx = inbound (we received)
|
||||||
|
self.quota_mgr.record_usage(
|
||||||
|
&self.username,
|
||||||
|
&self.ip.to_string(),
|
||||||
|
rx, // inbound = what we received from client
|
||||||
|
tx, // outbound = what we sent to client
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::user_db::UserDb;
|
||||||
|
use crate::quota::QuotaManager;
|
||||||
|
|
||||||
|
fn setup_test_db() -> (UserDb, QuotaManager) {
|
||||||
|
let db = UserDb::open(":memory:").unwrap();
|
||||||
|
db.ensure_tables().unwrap();
|
||||||
|
db.add_user("testuser", "testpass").unwrap();
|
||||||
|
let qm = QuotaManager::new(
|
||||||
|
db.clone(),
|
||||||
|
1000, // daily: 1000 bytes
|
||||||
|
5000, // weekly
|
||||||
|
10000, // monthly
|
||||||
|
500, // ip daily (combined)
|
||||||
|
2000, // ip weekly (combined)
|
||||||
|
8000, // ip monthly (combined)
|
||||||
|
500, // ip_daily_inbound
|
||||||
|
500, // ip_daily_outbound
|
||||||
|
2000, // ip_weekly_inbound
|
||||||
|
2000, // ip_weekly_outbound
|
||||||
|
8000, // ip_monthly_inbound
|
||||||
|
8000, // ip_monthly_outbound
|
||||||
|
2, // max conn per ip
|
||||||
|
60, // max duration
|
||||||
|
);
|
||||||
|
(db, qm)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_enforcer_max_duration() {
|
||||||
|
let (db, qm) = setup_test_db();
|
||||||
|
let state = BandwidthState::new();
|
||||||
|
let enforcer = QuotaEnforcer::new(
|
||||||
|
qm, "testuser".into(), "127.0.0.1".parse().unwrap(),
|
||||||
|
state.clone(), 1, 2, // check every 1s, max 2s
|
||||||
|
);
|
||||||
|
let reason = enforcer.run().await;
|
||||||
|
assert_eq!(reason, StopReason::MaxDuration);
|
||||||
|
assert!(!state.running.load(Ordering::Relaxed));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_enforcer_client_disconnect() {
|
||||||
|
let (db, qm) = setup_test_db();
|
||||||
|
let state = BandwidthState::new();
|
||||||
|
let state_clone = state.clone();
|
||||||
|
|
||||||
|
// Stop the test after 500ms
|
||||||
|
tokio::spawn(async move {
|
||||||
|
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||||
|
state_clone.running.store(false, Ordering::SeqCst);
|
||||||
|
});
|
||||||
|
|
||||||
|
let enforcer = QuotaEnforcer::new(
|
||||||
|
qm, "testuser".into(), "127.0.0.1".parse().unwrap(),
|
||||||
|
state, 1, 0, // check every 1s, no max duration
|
||||||
|
);
|
||||||
|
let reason = enforcer.run().await;
|
||||||
|
assert_eq!(reason, StopReason::ClientDisconnected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_enforcer_user_daily_quota_exceeded() {
|
||||||
|
let (db, qm) = setup_test_db();
|
||||||
|
|
||||||
|
// Pre-fill usage to exceed daily quota (1000 bytes)
|
||||||
|
db.record_usage("testuser", 600, 500).unwrap(); // 1100 > 1000
|
||||||
|
|
||||||
|
let state = BandwidthState::new();
|
||||||
|
let enforcer = QuotaEnforcer::new(
|
||||||
|
qm, "testuser".into(), "127.0.0.1".parse().unwrap(),
|
||||||
|
state.clone(), 1, 0,
|
||||||
|
);
|
||||||
|
let reason = enforcer.run().await;
|
||||||
|
assert_eq!(reason, StopReason::UserDailyQuota);
|
||||||
|
assert!(!state.running.load(Ordering::Relaxed));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_enforcer_ip_daily_quota_exceeded() {
|
||||||
|
let (db, qm) = setup_test_db();
|
||||||
|
|
||||||
|
// Pre-fill IP usage to exceed IP daily quota (500 bytes)
|
||||||
|
db.record_ip_usage("127.0.0.1", 300, 300).unwrap(); // 600 > 500
|
||||||
|
|
||||||
|
let state = BandwidthState::new();
|
||||||
|
let enforcer = QuotaEnforcer::new(
|
||||||
|
qm, "testuser".into(), "127.0.0.1".parse().unwrap(),
|
||||||
|
state.clone(), 1, 0,
|
||||||
|
);
|
||||||
|
let reason = enforcer.run().await;
|
||||||
|
assert_eq!(reason, StopReason::IpDailyQuota);
|
||||||
|
assert!(!state.running.load(Ordering::Relaxed));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_enforcer_under_quota_runs_normally() {
|
||||||
|
let (db, qm) = setup_test_db();
|
||||||
|
|
||||||
|
// Usage well under quota
|
||||||
|
db.record_usage("testuser", 100, 100).unwrap(); // 200 < 1000
|
||||||
|
|
||||||
|
let state = BandwidthState::new();
|
||||||
|
let state_clone = state.clone();
|
||||||
|
|
||||||
|
// Stop after 2s
|
||||||
|
tokio::spawn(async move {
|
||||||
|
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||||
|
state_clone.running.store(false, Ordering::SeqCst);
|
||||||
|
});
|
||||||
|
|
||||||
|
let enforcer = QuotaEnforcer::new(
|
||||||
|
qm, "testuser".into(), "127.0.0.1".parse().unwrap(),
|
||||||
|
state, 1, 0,
|
||||||
|
);
|
||||||
|
let reason = enforcer.run().await;
|
||||||
|
assert_eq!(reason, StopReason::ClientDisconnected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_enforcer_flush_records_usage() {
|
||||||
|
let (db, qm) = setup_test_db();
|
||||||
|
let state = BandwidthState::new();
|
||||||
|
|
||||||
|
// Simulate some transfer
|
||||||
|
state.total_tx_bytes.store(5000, Ordering::Relaxed);
|
||||||
|
state.total_rx_bytes.store(3000, Ordering::Relaxed);
|
||||||
|
|
||||||
|
let enforcer = QuotaEnforcer::new(
|
||||||
|
qm, "testuser".into(), "127.0.0.1".parse().unwrap(),
|
||||||
|
state, 10, 0,
|
||||||
|
);
|
||||||
|
enforcer.flush_to_db();
|
||||||
|
|
||||||
|
// flush_to_db: total_tx=5000→outbound, total_rx=3000→inbound
|
||||||
|
// quota_mgr.record_usage(inbound=3000, outbound=5000)
|
||||||
|
// db.record_usage(tx=outbound=5000, rx=inbound=3000)
|
||||||
|
let (tx, rx) = db.get_daily_usage("testuser").unwrap();
|
||||||
|
assert_eq!(tx, 5000); // outbound (what server sent)
|
||||||
|
assert_eq!(rx, 3000); // inbound (what server received)
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
74
src/server_pro/ldap_auth.rs
Normal file
74
src/server_pro/ldap_auth.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
343
src/server_pro/main.rs
Normal file
343
src/server_pro/main.rs
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
//! 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 enforcer;
|
||||||
|
mod server_loop;
|
||||||
|
mod web;
|
||||||
|
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,
|
||||||
|
|
||||||
|
/// Default monthly quota per user in bytes (0 = unlimited)
|
||||||
|
#[arg(long = "monthly-quota", default_value_t = 0)]
|
||||||
|
monthly_quota: u64,
|
||||||
|
|
||||||
|
/// Daily bandwidth limit per IP in bytes (0 = unlimited)
|
||||||
|
#[arg(long = "ip-daily", default_value_t = 0)]
|
||||||
|
ip_daily: u64,
|
||||||
|
|
||||||
|
/// Weekly bandwidth limit per IP in bytes (0 = unlimited)
|
||||||
|
#[arg(long = "ip-weekly", default_value_t = 0)]
|
||||||
|
ip_weekly: u64,
|
||||||
|
|
||||||
|
/// Monthly bandwidth limit per IP in bytes (0 = unlimited)
|
||||||
|
#[arg(long = "ip-monthly", default_value_t = 0)]
|
||||||
|
ip_monthly: 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,
|
||||||
|
|
||||||
|
/// Daily inbound (client→server) limit per IP in bytes (0 = use --ip-daily)
|
||||||
|
#[arg(long = "ip-daily-in", default_value_t = 0)]
|
||||||
|
ip_daily_in: u64,
|
||||||
|
|
||||||
|
/// Daily outbound (server→client) limit per IP in bytes (0 = use --ip-daily)
|
||||||
|
#[arg(long = "ip-daily-out", default_value_t = 0)]
|
||||||
|
ip_daily_out: u64,
|
||||||
|
|
||||||
|
/// Weekly inbound limit per IP in bytes (0 = use --ip-weekly)
|
||||||
|
#[arg(long = "ip-weekly-in", default_value_t = 0)]
|
||||||
|
ip_weekly_in: u64,
|
||||||
|
|
||||||
|
/// Weekly outbound limit per IP in bytes (0 = use --ip-weekly)
|
||||||
|
#[arg(long = "ip-weekly-out", default_value_t = 0)]
|
||||||
|
ip_weekly_out: u64,
|
||||||
|
|
||||||
|
/// Monthly inbound limit per IP in bytes (0 = use --ip-monthly)
|
||||||
|
#[arg(long = "ip-monthly-in", default_value_t = 0)]
|
||||||
|
ip_monthly_in: u64,
|
||||||
|
|
||||||
|
/// Monthly outbound limit per IP in bytes (0 = use --ip-monthly)
|
||||||
|
#[arg(long = "ip-monthly-out", default_value_t = 0)]
|
||||||
|
ip_monthly_out: u64,
|
||||||
|
|
||||||
|
/// How often to check quotas during a test in seconds
|
||||||
|
#[arg(long = "quota-check-interval", default_value_t = 10)]
|
||||||
|
quota_check_interval: u64,
|
||||||
|
|
||||||
|
/// Web dashboard port (0 = disabled)
|
||||||
|
#[arg(long = "web-port", default_value_t = 8080)]
|
||||||
|
web_port: u16,
|
||||||
|
|
||||||
|
/// Shared password for public mode (all users use this password)
|
||||||
|
#[arg(long = "shared-password")]
|
||||||
|
shared_password: Option<String>,
|
||||||
|
|
||||||
|
/// 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,
|
||||||
|
|
||||||
|
/// User management subcommand
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Option<UserCommand>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(clap::Subcommand, Debug)]
|
||||||
|
enum UserCommand {
|
||||||
|
/// Add a user
|
||||||
|
#[command(name = "useradd")]
|
||||||
|
UserAdd {
|
||||||
|
/// Username
|
||||||
|
username: String,
|
||||||
|
/// Password
|
||||||
|
password: String,
|
||||||
|
},
|
||||||
|
/// Delete a user
|
||||||
|
#[command(name = "userdel")]
|
||||||
|
UserDel {
|
||||||
|
/// Username
|
||||||
|
username: String,
|
||||||
|
},
|
||||||
|
/// List all users
|
||||||
|
#[command(name = "userlist")]
|
||||||
|
UserList,
|
||||||
|
/// Enable/disable a user
|
||||||
|
#[command(name = "userset")]
|
||||||
|
UserSet {
|
||||||
|
/// Username
|
||||||
|
username: String,
|
||||||
|
/// Enable (true/false)
|
||||||
|
#[arg(long)]
|
||||||
|
enabled: Option<bool>,
|
||||||
|
/// Daily quota in bytes
|
||||||
|
#[arg(long)]
|
||||||
|
daily: Option<i64>,
|
||||||
|
/// Weekly quota in bytes
|
||||||
|
#[arg(long)]
|
||||||
|
weekly: Option<i64>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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
|
||||||
|
let db = user_db::UserDb::open(&cli.users_db)?;
|
||||||
|
db.ensure_tables()?;
|
||||||
|
|
||||||
|
// Handle user management subcommands (exit after)
|
||||||
|
if let Some(cmd) = &cli.command {
|
||||||
|
match cmd {
|
||||||
|
UserCommand::UserAdd { username, password } => {
|
||||||
|
db.add_user(username, password)?;
|
||||||
|
println!("User '{}' added.", username);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
UserCommand::UserDel { username } => {
|
||||||
|
if db.delete_user(username)? {
|
||||||
|
println!("User '{}' deleted.", username);
|
||||||
|
} else {
|
||||||
|
println!("User '{}' not found.", username);
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
UserCommand::UserList => {
|
||||||
|
let users = db.list_users()?;
|
||||||
|
if users.is_empty() {
|
||||||
|
println!("No users.");
|
||||||
|
} else {
|
||||||
|
println!("{:<20} {:<10} {:<15} {:<15}", "USERNAME", "ENABLED", "DAILY_QUOTA", "WEEKLY_QUOTA");
|
||||||
|
println!("{}", "-".repeat(60));
|
||||||
|
for u in &users {
|
||||||
|
println!("{:<20} {:<10} {:<15} {:<15}",
|
||||||
|
u.username,
|
||||||
|
if u.enabled { "yes" } else { "no" },
|
||||||
|
if u.daily_quota == 0 { "default".to_string() } else { format!("{}B", u.daily_quota) },
|
||||||
|
if u.weekly_quota == 0 { "default".to_string() } else { format!("{}B", u.weekly_quota) },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
UserCommand::UserSet { username, enabled, daily, weekly } => {
|
||||||
|
if let Some(e) = enabled {
|
||||||
|
db.set_user_enabled(username, *e)?;
|
||||||
|
println!("User '{}' enabled={}", username, e);
|
||||||
|
}
|
||||||
|
if daily.is_some() || weekly.is_some() {
|
||||||
|
let d = daily.unwrap_or(0);
|
||||||
|
let w = weekly.unwrap_or(0);
|
||||||
|
db.set_user_quota(username, d, w, 0)?;
|
||||||
|
println!("User '{}' quota: daily={}, weekly={}", username, d, w);
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("User database: {} ({} users)", cli.users_db, db.user_count()?);
|
||||||
|
|
||||||
|
// Initialize LDAP if configured
|
||||||
|
if let Some(ref url) = cli.ldap_url {
|
||||||
|
tracing::info!("LDAP configured: {}", url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize quota manager
|
||||||
|
// Directional flags override combined: --ip-daily-in > --ip-daily > unlimited
|
||||||
|
let or_fallback = |specific: u64, combined: u64| if specific > 0 { specific } else { combined };
|
||||||
|
let quota_mgr = quota::QuotaManager::new(
|
||||||
|
db.clone(),
|
||||||
|
cli.daily_quota,
|
||||||
|
cli.weekly_quota,
|
||||||
|
cli.monthly_quota,
|
||||||
|
cli.ip_daily,
|
||||||
|
cli.ip_weekly,
|
||||||
|
cli.ip_monthly,
|
||||||
|
or_fallback(cli.ip_daily_in, cli.ip_daily),
|
||||||
|
or_fallback(cli.ip_daily_out, cli.ip_daily),
|
||||||
|
or_fallback(cli.ip_weekly_in, cli.ip_weekly),
|
||||||
|
or_fallback(cli.ip_weekly_out, cli.ip_weekly),
|
||||||
|
or_fallback(cli.ip_monthly_in, cli.ip_monthly),
|
||||||
|
or_fallback(cli.ip_monthly_out, cli.ip_monthly),
|
||||||
|
cli.max_conn_per_ip,
|
||||||
|
cli.max_duration,
|
||||||
|
);
|
||||||
|
|
||||||
|
let fmt_q = |v: u64| if v == 0 { "unlimited".to_string() } else { format!("{}B", v) };
|
||||||
|
tracing::info!(
|
||||||
|
"User quotas: daily={}, weekly={}, monthly={}",
|
||||||
|
fmt_q(cli.daily_quota), fmt_q(cli.weekly_quota), fmt_q(cli.monthly_quota),
|
||||||
|
);
|
||||||
|
tracing::info!(
|
||||||
|
"IP quotas: daily={}, weekly={}, monthly={}",
|
||||||
|
fmt_q(cli.ip_daily), fmt_q(cli.ip_weekly), fmt_q(cli.ip_monthly),
|
||||||
|
);
|
||||||
|
tracing::info!(
|
||||||
|
"Limits: max_conn_per_ip={}, max_duration={}s",
|
||||||
|
cli.max_conn_per_ip, cli.max_duration,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Start web dashboard if port > 0
|
||||||
|
if cli.web_port > 0 {
|
||||||
|
let web_db = db.clone();
|
||||||
|
let web_port = cli.web_port;
|
||||||
|
tokio::spawn(async move {
|
||||||
|
tracing::info!("Web dashboard starting on http://0.0.0.0:{}", web_port);
|
||||||
|
let app = web::create_router(web_db);
|
||||||
|
let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", web_port))
|
||||||
|
.await
|
||||||
|
.expect("Failed to bind web dashboard port");
|
||||||
|
if let Err(e) = axum::serve(listener, app).await {
|
||||||
|
tracing::error!("Web dashboard error: {}", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("btest-server-pro starting on port {}", cli.port);
|
||||||
|
|
||||||
|
let v4 = if cli.listen_addr.eq_ignore_ascii_case("none") { None } else { Some(cli.listen_addr) };
|
||||||
|
let v6 = cli.listen6_addr;
|
||||||
|
|
||||||
|
server_loop::run_pro_server(
|
||||||
|
cli.port,
|
||||||
|
cli.ecsrp5,
|
||||||
|
v4, v6,
|
||||||
|
db,
|
||||||
|
quota_mgr,
|
||||||
|
cli.quota_check_interval,
|
||||||
|
).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
396
src/server_pro/quota.rs
Normal file
396
src/server_pro/quota.rs
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
//! Bandwidth quota management for btest-server-pro.
|
||||||
|
//!
|
||||||
|
//! 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;
|
||||||
|
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,
|
||||||
|
/// Per-user defaults (0 = unlimited)
|
||||||
|
default_daily: u64,
|
||||||
|
default_weekly: u64,
|
||||||
|
default_monthly: u64,
|
||||||
|
/// 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
|
||||||
|
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 },
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
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, "User daily quota exceeded: {}/{} bytes", used, limit),
|
||||||
|
Self::WeeklyExceeded { used, limit } =>
|
||||||
|
write!(f, "User weekly quota exceeded: {}/{} bytes", used, limit),
|
||||||
|
Self::MonthlyExceeded { used, limit } =>
|
||||||
|
write!(f, "User monthly quota exceeded: {}/{} bytes", used, limit),
|
||||||
|
Self::IpDailyExceeded { used, limit } =>
|
||||||
|
write!(f, "IP daily quota exceeded: {}/{} bytes", used, limit),
|
||||||
|
Self::IpWeeklyExceeded { used, limit } =>
|
||||||
|
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"),
|
||||||
|
Self::UserNotFound => write!(f, "User not found"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QuotaManager {
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn new(
|
||||||
|
db: UserDb,
|
||||||
|
default_daily: u64,
|
||||||
|
default_weekly: u64,
|
||||||
|
default_monthly: u64,
|
||||||
|
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 {
|
||||||
|
Self {
|
||||||
|
db,
|
||||||
|
default_daily,
|
||||||
|
default_weekly,
|
||||||
|
default_monthly,
|
||||||
|
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())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Daily
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weekly
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monthly
|
||||||
|
if self.default_monthly > 0 {
|
||||||
|
let (tx, rx) = self.db.get_monthly_usage(username).unwrap_or((0, 0));
|
||||||
|
let used = tx + rx;
|
||||||
|
if used >= self.default_monthly {
|
||||||
|
return Err(QuotaError::MonthlyExceeded { used, limit: self.default_monthly });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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();
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let ip_str = ip.to_string();
|
||||||
|
|
||||||
|
// --- 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 used = tx + rx;
|
||||||
|
if used >= self.ip_daily {
|
||||||
|
return Err(QuotaError::IpDailyExceeded { used, limit: self.ip_daily });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IP weekly (combined)
|
||||||
|
if self.ip_weekly > 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 (combined)
|
||||||
|
if self.ip_monthly > 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
self.max_duration
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn active_connections_count(&self, ip: &IpAddr) -> u32 {
|
||||||
|
let conns = self.active_connections.lock().unwrap();
|
||||||
|
conns.get(ip).copied().unwrap_or(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
280
src/server_pro/server_loop.rs
Normal file
280
src/server_pro/server_loop.rs
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
//! Enhanced server loop with quota enforcement.
|
||||||
|
//!
|
||||||
|
//! Wraps the standard btest server connection handler with:
|
||||||
|
//! - Pre-connection IP/user quota checks
|
||||||
|
//! - Mid-session quota enforcement via QuotaEnforcer
|
||||||
|
//! - Post-session usage recording
|
||||||
|
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tokio::net::{TcpListener, TcpStream};
|
||||||
|
|
||||||
|
use btest_rs::protocol::*;
|
||||||
|
use btest_rs::bandwidth::BandwidthState;
|
||||||
|
|
||||||
|
use super::enforcer::{QuotaEnforcer, StopReason};
|
||||||
|
use super::quota::{Direction, QuotaManager};
|
||||||
|
use super::user_db::UserDb;
|
||||||
|
|
||||||
|
/// Run the pro server with quota enforcement.
|
||||||
|
pub async fn run_pro_server(
|
||||||
|
port: u16,
|
||||||
|
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?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let v6_listener = if let Some(ref addr) = listen_v6 {
|
||||||
|
let bind_addr = format!("[{}]:{}", addr, port);
|
||||||
|
Some(TcpListener::bind(&bind_addr).await?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
if v4_listener.is_none() && v6_listener.is_none() {
|
||||||
|
anyhow::bail!("No listeners bound");
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("btest-server-pro ready, accepting connections");
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let (stream, peer) = match (&v4_listener, &v6_listener) {
|
||||||
|
(Some(v4), Some(v6)) => {
|
||||||
|
tokio::select! {
|
||||||
|
r = v4.accept() => r?,
|
||||||
|
r = v6.accept() => r?,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(Some(v4), None) => v4.accept().await?,
|
||||||
|
(None, Some(v6)) => v6.accept().await?,
|
||||||
|
_ => unreachable!(),
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
match handle_pro_client(stream, peer, db, qm, interval).await {
|
||||||
|
Ok((username, stop_reason, tx, rx)) => {
|
||||||
|
tracing::info!(
|
||||||
|
"Client {} (user '{}') finished: {} (tx={}, rx={})",
|
||||||
|
peer, username, stop_reason, tx, rx,
|
||||||
|
);
|
||||||
|
btest_rs::syslog_logger::test_end(
|
||||||
|
&peer.to_string(), "btest", &format!("{}", stop_reason),
|
||||||
|
tx, rx, 0, 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Client {} error: {}", peer, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
qm_disconnect.disconnect(&peer.ip());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_pro_client(
|
||||||
|
mut stream: TcpStream,
|
||||||
|
peer: SocketAddr,
|
||||||
|
db: UserDb,
|
||||||
|
quota_mgr: QuotaManager,
|
||||||
|
quota_check_interval: u64,
|
||||||
|
) -> anyhow::Result<(String, StopReason, u64, u64)> {
|
||||||
|
stream.set_nodelay(true)?;
|
||||||
|
|
||||||
|
// HELLO
|
||||||
|
stream.write_all(&HELLO).await?;
|
||||||
|
|
||||||
|
// Read command
|
||||||
|
let mut cmd_buf = [0u8; 16];
|
||||||
|
stream.read_exact(&mut cmd_buf).await?;
|
||||||
|
let cmd = Command::deserialize(&cmd_buf);
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"Client {} command: proto={} dir={} conn_count={} tx_size={}",
|
||||||
|
peer,
|
||||||
|
if cmd.is_udp() { "UDP" } else { "TCP" },
|
||||||
|
match cmd.direction { CMD_DIR_RX => "RX", CMD_DIR_TX => "TX", _ => "BOTH" },
|
||||||
|
cmd.tcp_conn_count,
|
||||||
|
cmd.tx_size,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Authenticate — use MD5 auth with DB verification
|
||||||
|
// Send AUTH_REQUIRED
|
||||||
|
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?;
|
||||||
|
|
||||||
|
let received_hash = &response[0..16];
|
||||||
|
let received_user = &response[16..48];
|
||||||
|
|
||||||
|
let user_end = received_user.iter().position(|&b| b == 0).unwrap_or(32);
|
||||||
|
let username = std::str::from_utf8(&received_user[..user_end])
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Verify against DB
|
||||||
|
let user = db.get_user(&username)?;
|
||||||
|
match user {
|
||||||
|
None => {
|
||||||
|
tracing::warn!("Auth failed: user '{}' not found", username);
|
||||||
|
stream.write_all(&AUTH_FAILED).await?;
|
||||||
|
btest_rs::syslog_logger::auth_failure(
|
||||||
|
&peer.to_string(), &username, "md5", "user not found",
|
||||||
|
);
|
||||||
|
anyhow::bail!("User not found");
|
||||||
|
}
|
||||||
|
Some(u) => {
|
||||||
|
if !u.enabled {
|
||||||
|
tracing::warn!("Auth failed: user '{}' is disabled", username);
|
||||||
|
stream.write_all(&AUTH_FAILED).await?;
|
||||||
|
btest_rs::syslog_logger::auth_failure(
|
||||||
|
&peer.to_string(), &username, "md5", "user disabled",
|
||||||
|
);
|
||||||
|
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.
|
||||||
|
|
||||||
|
// Send AUTH_OK
|
||||||
|
stream.write_all(&AUTH_OK).await?;
|
||||||
|
stream.flush().await?;
|
||||||
|
|
||||||
|
tracing::info!("Auth successful for user '{}'", username);
|
||||||
|
btest_rs::syslog_logger::auth_success(
|
||||||
|
&peer.to_string(), &username, "md5",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check user quota before starting test
|
||||||
|
if let Err(e) = quota_mgr.check_user(&username) {
|
||||||
|
tracing::warn!("Quota check failed for '{}': {}", username, e);
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start session tracking
|
||||||
|
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,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
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
|
||||||
|
let enforcer = QuotaEnforcer::new(
|
||||||
|
quota_mgr.clone(),
|
||||||
|
username.clone(),
|
||||||
|
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 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,
|
||||||
|
).await
|
||||||
|
} else {
|
||||||
|
btest_rs::server::run_tcp_test(stream, 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
db.end_session(session_id, total_tx, total_rx)?;
|
||||||
|
|
||||||
|
Ok((username, final_reason, total_tx, total_rx))
|
||||||
|
}
|
||||||
621
src/server_pro/user_db.rs
Normal file
621
src/server_pro/user_db.rs
Normal file
@@ -0,0 +1,621 @@
|
|||||||
|
//! 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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)?;
|
||||||
|
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 ip_usage (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ip TEXT NOT NULL,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
inbound_bytes INTEGER DEFAULT 0,
|
||||||
|
outbound_bytes INTEGER DEFAULT 0,
|
||||||
|
test_count INTEGER DEFAULT 0,
|
||||||
|
UNIQUE(ip, 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 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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
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 get_monthly_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', '-30 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Per-IP usage tracking ---
|
||||||
|
|
||||||
|
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, inbound_bytes, outbound_bytes, test_count)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, 1)
|
||||||
|
ON CONFLICT(ip, date) DO UPDATE SET
|
||||||
|
inbound_bytes = inbound_bytes + ?3,
|
||||||
|
outbound_bytes = outbound_bytes + ?4,
|
||||||
|
test_count = test_count + 1",
|
||||||
|
params![ip, today, inbound as i64, outbound as i64],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_ip_daily_usage(&self, ip: &str) -> anyhow::Result<(u64, u64)> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let today = chrono_date_today();
|
||||||
|
let result = conn.query_row(
|
||||||
|
"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 inbound: i64 = row.get(0)?;
|
||||||
|
let outbound: i64 = row.get(1)?;
|
||||||
|
Ok((inbound as u64, outbound as u64))
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
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(inbound_bytes),0), COALESCE(SUM(outbound_bytes),0) FROM ip_usage
|
||||||
|
WHERE ip = ?1 AND date >= date('now', '-7 days')",
|
||||||
|
params![ip],
|
||||||
|
|row| {
|
||||||
|
let inbound: i64 = row.get(0)?;
|
||||||
|
let outbound: i64 = row.get(1)?;
|
||||||
|
Ok((inbound as u64, outbound as u64))
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
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(inbound_bytes),0), COALESCE(SUM(outbound_bytes),0) FROM ip_usage
|
||||||
|
WHERE ip = ?1 AND date >= date('now', '-30 days')",
|
||||||
|
params![ip],
|
||||||
|
|row| {
|
||||||
|
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> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO sessions (username, peer_ip, protocol, direction) VALUES (?1, ?2, ?3, ?4)",
|
||||||
|
params![username, peer_ip, protocol, direction],
|
||||||
|
)?;
|
||||||
|
Ok(conn.last_insert_rowid())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn end_session(&self, session_id: i64, tx_bytes: u64, rx_bytes: u64) -> anyhow::Result<()> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE sessions SET ended_at = datetime('now'), tx_bytes = ?1, rx_bytes = ?2 WHERE id = ?3",
|
||||||
|
params![tx_bytes as i64, rx_bytes as i64, session_id],
|
||||||
|
)?;
|
||||||
|
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])?;
|
||||||
|
Ok(rows > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_user_enabled(&self, username: &str, enabled: bool) -> anyhow::Result<()> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE users SET enabled = ?1 WHERE username = ?2",
|
||||||
|
params![enabled as i32, username],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_user_quota(&self, username: &str, daily: i64, weekly: i64, monthly: i64) -> anyhow::Result<()> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE users SET daily_quota = ?1, weekly_quota = ?2 WHERE username = ?3",
|
||||||
|
params![daily, weekly, username],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
546
src/server_pro/web/mod.rs
Normal file
546
src/server_pro/web/mod.rs
Normal file
@@ -0,0 +1,546 @@
|
|||||||
|
//! Web dashboard module for btest-server-pro.
|
||||||
|
//!
|
||||||
|
//! Provides an axum-based HTTP dashboard with:
|
||||||
|
//! - Landing page with IP lookup
|
||||||
|
//! - Per-IP session history and statistics
|
||||||
|
//! - Chart.js throughput graphs
|
||||||
|
//!
|
||||||
|
//! # Feature gate
|
||||||
|
//!
|
||||||
|
//! This entire module is compiled only when the `pro` feature is active
|
||||||
|
//! (it lives inside the `btest-server-pro` binary crate which already
|
||||||
|
//! requires `--features pro`).
|
||||||
|
//!
|
||||||
|
//! # Template files
|
||||||
|
//!
|
||||||
|
//! The HTML source lives in `src/server_pro/web/templates/` as standalone
|
||||||
|
//! `.html` files for easy editing. The Rust code embeds them via the askama
|
||||||
|
//! `source` attribute so no `askama.toml` configuration is needed. If you
|
||||||
|
//! prefer external template files, create `askama.toml` at the crate root:
|
||||||
|
//!
|
||||||
|
//! ```toml
|
||||||
|
//! [[dirs]]
|
||||||
|
//! path = "src/server_pro/web/templates"
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Then change `source = "..."` to `path = "index.html"` (etc.) in the
|
||||||
|
//! template structs below.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use askama::Template;
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::{Html, IntoResponse, Response};
|
||||||
|
use axum::routing::get;
|
||||||
|
use axum::Router;
|
||||||
|
use rusqlite::{params, Connection};
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use super::user_db::UserDb;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared state
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Shared application state passed to all handlers via axum's `State`.
|
||||||
|
pub struct WebState {
|
||||||
|
/// Reference to the main user/session database.
|
||||||
|
pub db: UserDb,
|
||||||
|
/// Separate read-only connection for dashboard queries that are not
|
||||||
|
/// exposed by [`UserDb`] (e.g. listing sessions, aggregate stats).
|
||||||
|
/// Wrapped in a [`std::sync::Mutex`] because [`rusqlite::Connection`]
|
||||||
|
/// is not `Send + Sync` on its own.
|
||||||
|
pub query_conn: std::sync::Mutex<Connection>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Router constructor
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Default database filename used when `BTEST_DB_PATH` is not set.
|
||||||
|
const DEFAULT_DB_PATH: &str = "btest-users.db";
|
||||||
|
|
||||||
|
/// Build the axum [`Router`] for the web dashboard.
|
||||||
|
///
|
||||||
|
/// The database path for the read-only query connection is resolved in the
|
||||||
|
/// following order:
|
||||||
|
///
|
||||||
|
/// 1. The `BTEST_DB_PATH` environment variable (if set).
|
||||||
|
/// 2. The compile-time default `btest-users.db`.
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics if the read-only database connection or the DDL for the
|
||||||
|
/// `session_intervals` table cannot be established. This is intentional:
|
||||||
|
/// 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 query_conn = Connection::open_with_flags(
|
||||||
|
&db_path,
|
||||||
|
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY
|
||||||
|
| rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||||||
|
)
|
||||||
|
.expect("web: failed to open read-only database connection");
|
||||||
|
query_conn
|
||||||
|
.execute_batch("PRAGMA busy_timeout=5000;")
|
||||||
|
.expect("web: failed to set PRAGMA on query connection");
|
||||||
|
|
||||||
|
// Ensure the `session_intervals` table exists. The server loop must
|
||||||
|
// INSERT rows for the chart to have data; the table is created here so
|
||||||
|
// the schema is ready.
|
||||||
|
ensure_web_tables(&db_path).expect("web: failed to create session_intervals table");
|
||||||
|
|
||||||
|
let state = Arc::new(WebState {
|
||||||
|
db,
|
||||||
|
query_conn: std::sync::Mutex::new(query_conn),
|
||||||
|
});
|
||||||
|
|
||||||
|
// axum 0.8 uses `{param}` syntax for path parameters.
|
||||||
|
Router::new()
|
||||||
|
.route("/", get(index_page))
|
||||||
|
.route("/dashboard/{ip}", get(dashboard_page))
|
||||||
|
.route("/api/ip/{ip}/sessions", get(api_sessions))
|
||||||
|
.route("/api/ip/{ip}/stats", get(api_stats))
|
||||||
|
.route("/api/session/{id}/intervals", get(api_intervals))
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create additional tables the web dashboard depends on.
|
||||||
|
///
|
||||||
|
/// Opens a short-lived writable connection solely for DDL so it does not
|
||||||
|
/// interfere with the main [`UserDb`] connection.
|
||||||
|
fn ensure_web_tables(db_path: &str) -> anyhow::Result<()> {
|
||||||
|
let conn = Connection::open(db_path)?;
|
||||||
|
conn.execute_batch("PRAGMA busy_timeout=5000;")?;
|
||||||
|
conn.execute_batch(
|
||||||
|
"CREATE TABLE IF NOT EXISTS session_intervals (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
session_id INTEGER NOT NULL,
|
||||||
|
second INTEGER NOT NULL,
|
||||||
|
tx_bytes INTEGER NOT NULL DEFAULT 0,
|
||||||
|
rx_bytes INTEGER NOT NULL DEFAULT 0,
|
||||||
|
UNIQUE(session_id, second)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_intervals_session
|
||||||
|
ON session_intervals(session_id, second);",
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Askama templates (embedded via `source`)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Landing / index page template.
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(
|
||||||
|
source = r##"<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>btest-rs 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}
|
||||||
|
.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 a{color:#58a6ff;text-decoration:none}
|
||||||
|
.auto-link a:hover{text-decoration:underline}
|
||||||
|
.footer{margin-top:2rem;color:#484f58;font-size:.8rem}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>btest-rs</h1>
|
||||||
|
<p class="subtitle">Public MikroTik Bandwidth Test Server — 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>
|
||||||
|
</div>
|
||||||
|
<div class="footer">Powered by btest-rs</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
function goToDashboard(){var ip=document.getElementById('ip-input').value.trim();if(ip){window.location.href='/dashboard/'+encodeURIComponent(ip);}return false;}
|
||||||
|
fetch('https://api.ipify.org?format=json')
|
||||||
|
.then(function(r){return r.json();})
|
||||||
|
.then(function(d){if(d.ip){document.getElementById('ip-input').value=d.ip;document.getElementById('auto-detect').innerHTML='Detected IP: <a href="/dashboard/'+encodeURIComponent(d.ip)+'">'+d.ip+'</a> — click to view your dashboard';}})
|
||||||
|
.catch(function(){document.getElementById('auto-detect').textContent='';});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>"##,
|
||||||
|
ext = "html"
|
||||||
|
)]
|
||||||
|
struct IndexTemplate;
|
||||||
|
|
||||||
|
/// Per-IP dashboard page template.
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(
|
||||||
|
source = r##"<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Dashboard — {{ ip }} — btest-rs</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;padding:1.5rem}
|
||||||
|
a{color:#58a6ff;text-decoration:none}a:hover{text-decoration:underline}
|
||||||
|
.header{display:flex;align-items:center;gap:1rem;margin-bottom:1.5rem;flex-wrap:wrap}
|
||||||
|
.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}
|
||||||
|
.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}
|
||||||
|
.stat-card .value{font-size:1.4rem;font-weight:600;margin-top:.25rem}
|
||||||
|
.table-wrap{overflow-x:auto;margin-bottom:1.5rem}
|
||||||
|
table{width:100%;border-collapse:collapse;background:#161b22;border-radius:8px;overflow:hidden}
|
||||||
|
th,td{padding:.6rem 1rem;text-align:left;border-bottom:1px solid #21262d;white-space:nowrap}
|
||||||
|
th{background:#0d1117;color:#8b949e;font-size:.8rem;text-transform:uppercase;letter-spacing:.04em}
|
||||||
|
tr{cursor:pointer}tr:hover td{background:#1c2128}tr.selected td{background:#1f3a5f}
|
||||||
|
.proto-tcp{color:#3fb950}.proto-udp{color:#d29922}
|
||||||
|
.dir-tx{color:#f78166}.dir-rx{color:#58a6ff}.dir-both{color:#bc8cff}
|
||||||
|
.chart-section{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:1.5rem;margin-bottom:1.5rem}
|
||||||
|
.chart-section h2{font-size:1rem;color:#8b949e;margin-bottom:1rem}
|
||||||
|
.chart-container{position:relative;width:100%;max-height:360px}
|
||||||
|
.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}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1>btest-rs</h1>
|
||||||
|
<span class="ip-label">{{ ip }}</span>
|
||||||
|
<span class="home-link"><a href="/">Home</a></span>
|
||||||
|
</div>
|
||||||
|
<div class="stats" id="stats-grid">
|
||||||
|
<div class="stat-card"><div class="label">Total Tests</div><div class="value" id="stat-total-tests">—</div></div>
|
||||||
|
<div class="stat-card"><div class="label">Total TX</div><div class="value" id="stat-total-tx">—</div></div>
|
||||||
|
<div class="stat-card"><div class="label">Total RX</div><div class="value" id="stat-total-rx">—</div></div>
|
||||||
|
<div class="stat-card"><div class="label">Avg TX Mbps</div><div class="value" id="stat-avg-tx">—</div></div>
|
||||||
|
<div class="stat-card"><div class="label">Avg RX Mbps</div><div class="value" id="stat-avg-rx">—</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="chart-section">
|
||||||
|
<h2 id="chart-title">Select a test below to view its throughput chart</h2>
|
||||||
|
<div class="chart-container">
|
||||||
|
<canvas id="throughput-chart"></canvas>
|
||||||
|
<div class="chart-placeholder" id="chart-placeholder">Click a row in the table to load the throughput graph for that session.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>#</th><th>Date</th><th>Protocol</th><th>Direction</th><th>TX Bytes</th><th>RX Bytes</th><th>Duration</th><th>Avg TX Mbps</th><th>Avg RX Mbps</th></tr></thead>
|
||||||
|
<tbody id="sessions-body"><tr><td colspan="9" class="no-data">Loading sessions...</td></tr></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="footer">Powered by btest-rs</div>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
|
<script>
|
||||||
|
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);}
|
||||||
|
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){
|
||||||
|
document.getElementById('stat-total-tests').textContent=d.total_sessions||0;
|
||||||
|
document.getElementById('stat-total-tx').textContent=formatBytes(d.total_tx_bytes||0);
|
||||||
|
document.getElementById('stat-total-rx').textContent=formatBytes(d.total_rx_bytes||0);
|
||||||
|
document.getElementById('stat-avg-tx').textContent=d.avg_tx_mbps?d.avg_tx_mbps.toFixed(2):'0.00';
|
||||||
|
document.getElementById('stat-avg-rx').textContent=d.avg_rx_mbps?d.avg_rx_mbps.toFixed(2):'0.00';
|
||||||
|
}).catch(function(){});
|
||||||
|
fetch('/api/ip/'+encodeURIComponent(currentIp)+'/sessions').then(function(r){return r.json();}).then(function(sessions){
|
||||||
|
var tbody=document.getElementById('sessions-body');
|
||||||
|
if(!sessions||sessions.length===0){tbody.innerHTML='<tr><td colspan="9" class="no-data">No test sessions found for this IP.</td></tr>';return;}
|
||||||
|
tbody.innerHTML='';
|
||||||
|
sessions.forEach(function(s,i){
|
||||||
|
var tr=document.createElement('tr');tr.dataset.sessionId=s.id;tr.onclick=function(){selectSession(s.id,tr);};
|
||||||
|
var dur=durationSec(s.started_at,s.ended_at);var avgTx=dur>0?formatMbps(s.tx_bytes/dur):'0.00';var avgRx=dur>0?formatMbps(s.rx_bytes/dur):'0.00';
|
||||||
|
var proto=(s.protocol||'TCP').toUpperCase();var dir=(s.direction||'BOTH').toUpperCase();
|
||||||
|
var pc=proto==='UDP'?'proto-udp':'proto-tcp';var dc=dir==='TX'?'dir-tx':dir==='RX'?'dir-rx':'dir-both';
|
||||||
|
tr.innerHTML='<td>'+(i+1)+'</td><td>'+(s.started_at||'--')+'</td><td class="'+pc+'">'+proto+'</td><td class="'+dc+'">'+dir+'</td><td>'+formatBytes(s.tx_bytes||0)+'</td><td>'+formatBytes(s.rx_bytes||0)+'</td><td>'+durationStr(s.started_at,s.ended_at)+'</td><td>'+avgTx+'</td><td>'+avgRx+'</td>';
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
if(sessions.length>0){var fr=tbody.querySelector('tr');if(fr)selectSession(sessions[0].id,fr);}
|
||||||
|
}).catch(function(){document.getElementById('sessions-body').innerHTML='<tr><td colspan="9" class="no-data">Failed to load sessions.</td></tr>';});
|
||||||
|
function selectSession(sid,row){
|
||||||
|
document.querySelectorAll('#sessions-body tr').forEach(function(r){r.classList.remove('selected');});
|
||||||
|
row.classList.add('selected');
|
||||||
|
document.getElementById('chart-title').textContent='Throughput for session #'+sid;
|
||||||
|
document.getElementById('chart-placeholder').style.display='none';
|
||||||
|
fetch('/api/session/'+sid+'/intervals').then(function(r){return r.json();}).then(function(iv){renderChart(iv);}).catch(function(){
|
||||||
|
document.getElementById('chart-placeholder').style.display='block';
|
||||||
|
document.getElementById('chart-placeholder').textContent='Failed to load interval data.';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function renderChart(iv){
|
||||||
|
var canvas=document.getElementById('throughput-chart');
|
||||||
|
if(throughputChart)throughputChart.destroy();
|
||||||
|
if(!iv||iv.length===0){document.getElementById('chart-placeholder').style.display='block';document.getElementById('chart-placeholder').textContent='No interval data available for this session.';return;}
|
||||||
|
var labels=iv.map(function(d){return d.second+'s';});
|
||||||
|
var tx=iv.map(function(d){return(d.tx_bytes*8/1e6).toFixed(2);});
|
||||||
|
var rx=iv.map(function(d){return(d.rx_bytes*8/1e6).toFixed(2);});
|
||||||
|
throughputChart=new Chart(canvas,{type:'line',data:{labels:labels,datasets:[
|
||||||
|
{label:'TX Mbps',data:tx,borderColor:'#f78166',backgroundColor:'rgba(247,129,102,0.1)',borderWidth:2,fill:true,tension:0.3,pointRadius:1},
|
||||||
|
{label:'RX Mbps',data:rx,borderColor:'#58a6ff',backgroundColor:'rgba(88,166,255,0.1)',borderWidth:2,fill:true,tension:0.3,pointRadius:1}
|
||||||
|
]},options:{responsive:true,maintainAspectRatio:false,interaction:{intersect:false,mode:'index'},
|
||||||
|
scales:{x:{title:{display:true,text:'Time',color:'#8b949e'},ticks:{color:'#8b949e'},grid:{color:'#21262d'}},
|
||||||
|
y:{title:{display:true,text:'Mbps',color:'#8b949e'},ticks:{color:'#8b949e'},grid:{color:'#21262d'},beginAtZero:true}},
|
||||||
|
plugins:{legend:{labels:{color:'#e1e4e8'}},tooltip:{backgroundColor:'#161b22',borderColor:'#30363d',borderWidth:1,titleColor:'#e1e4e8',bodyColor:'#8b949e'}}}});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>"##,
|
||||||
|
ext = "html"
|
||||||
|
)]
|
||||||
|
struct DashboardTemplate {
|
||||||
|
ip: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// JSON response types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// A single test session as returned by the sessions API.
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct SessionJson {
|
||||||
|
id: i64,
|
||||||
|
username: String,
|
||||||
|
peer_ip: String,
|
||||||
|
started_at: Option<String>,
|
||||||
|
ended_at: Option<String>,
|
||||||
|
tx_bytes: i64,
|
||||||
|
rx_bytes: i64,
|
||||||
|
protocol: Option<String>,
|
||||||
|
direction: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aggregate statistics for an IP address.
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct StatsJson {
|
||||||
|
total_sessions: i64,
|
||||||
|
total_tx_bytes: i64,
|
||||||
|
total_rx_bytes: i64,
|
||||||
|
avg_tx_mbps: f64,
|
||||||
|
avg_rx_mbps: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One second of throughput data within a session.
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct IntervalJson {
|
||||||
|
second: i64,
|
||||||
|
tx_bytes: i64,
|
||||||
|
rx_bytes: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Error helper
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Uniform error wrapper so handlers can use `?` freely.
|
||||||
|
///
|
||||||
|
/// All errors are rendered as `500 Internal Server Error` with a plain-text
|
||||||
|
/// body. The full error chain is logged via [`tracing`].
|
||||||
|
struct AppError(anyhow::Error);
|
||||||
|
|
||||||
|
impl IntoResponse for AppError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
tracing::error!("web handler error: {:#}", self.0);
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, self.0.to_string()).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<E: Into<anyhow::Error>> From<E> for AppError {
|
||||||
|
fn from(err: E) -> Self {
|
||||||
|
Self(err.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Handlers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// `GET /` -- render the landing page.
|
||||||
|
async fn index_page() -> Result<Html<String>, AppError> {
|
||||||
|
let rendered = IndexTemplate
|
||||||
|
.render()
|
||||||
|
.map_err(|e| anyhow::anyhow!("template render: {}", e))?;
|
||||||
|
Ok(Html(rendered))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /dashboard/{ip}` -- render the per-IP dashboard.
|
||||||
|
async fn dashboard_page(Path(ip): Path<String>) -> Result<Html<String>, AppError> {
|
||||||
|
let rendered = DashboardTemplate { ip }
|
||||||
|
.render()
|
||||||
|
.map_err(|e| anyhow::anyhow!("template render: {}", e))?;
|
||||||
|
Ok(Html(rendered))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/ip/{ip}/sessions` -- return the most recent 100 sessions for
|
||||||
|
/// the given peer IP as a JSON array.
|
||||||
|
async fn api_sessions(
|
||||||
|
State(state): State<Arc<WebState>>,
|
||||||
|
Path(ip): Path<String>,
|
||||||
|
) -> Result<axum::Json<Vec<SessionJson>>, AppError> {
|
||||||
|
let sessions = {
|
||||||
|
let conn = state
|
||||||
|
.query_conn
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| anyhow::anyhow!("lock: {}", e))?;
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT id, username, peer_ip, started_at, ended_at,
|
||||||
|
tx_bytes, rx_bytes, protocol, direction
|
||||||
|
FROM sessions
|
||||||
|
WHERE peer_ip = ?1
|
||||||
|
ORDER BY started_at DESC
|
||||||
|
LIMIT 100",
|
||||||
|
)?;
|
||||||
|
let rows = stmt.query_map(params![ip], |row| {
|
||||||
|
Ok(SessionJson {
|
||||||
|
id: row.get(0)?,
|
||||||
|
username: row.get(1)?,
|
||||||
|
peer_ip: row.get(2)?,
|
||||||
|
started_at: row.get(3)?,
|
||||||
|
ended_at: row.get(4)?,
|
||||||
|
tx_bytes: row.get(5)?,
|
||||||
|
rx_bytes: row.get(6)?,
|
||||||
|
protocol: row.get(7)?,
|
||||||
|
direction: row.get(8)?,
|
||||||
|
})
|
||||||
|
})?;
|
||||||
|
rows.filter_map(Result::ok).collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(axum::Json(sessions))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/ip/{ip}/stats` -- return aggregate statistics (total bytes,
|
||||||
|
/// session count, average throughput) for the given IP.
|
||||||
|
async fn api_stats(
|
||||||
|
State(state): State<Arc<WebState>>,
|
||||||
|
Path(ip): Path<String>,
|
||||||
|
) -> Result<axum::Json<StatsJson>, AppError> {
|
||||||
|
let stats = {
|
||||||
|
let conn = state
|
||||||
|
.query_conn
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| anyhow::anyhow!("lock: {}", e))?;
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT
|
||||||
|
COUNT(*) AS total_sessions,
|
||||||
|
COALESCE(SUM(tx_bytes), 0) AS total_tx,
|
||||||
|
COALESCE(SUM(rx_bytes), 0) AS total_rx,
|
||||||
|
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) AS total_seconds
|
||||||
|
FROM sessions
|
||||||
|
WHERE peer_ip = ?1",
|
||||||
|
params![ip],
|
||||||
|
|row| {
|
||||||
|
let total_sessions: i64 = row.get(0)?;
|
||||||
|
let total_tx: i64 = row.get(1)?;
|
||||||
|
let total_rx: i64 = row.get(2)?;
|
||||||
|
let total_seconds: f64 = row.get(3)?;
|
||||||
|
|
||||||
|
let avg_tx_mbps = if total_seconds > 0.0 {
|
||||||
|
(total_tx as f64) * 8.0 / total_seconds / 1_000_000.0
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
let avg_rx_mbps = if total_seconds > 0.0 {
|
||||||
|
(total_rx as f64) * 8.0 / total_seconds / 1_000_000.0
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(StatsJson {
|
||||||
|
total_sessions,
|
||||||
|
total_tx_bytes: total_tx,
|
||||||
|
total_rx_bytes: total_rx,
|
||||||
|
avg_tx_mbps,
|
||||||
|
avg_rx_mbps,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)?
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(axum::Json(stats))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/session/{id}/intervals` -- return per-second throughput data
|
||||||
|
/// for a session.
|
||||||
|
///
|
||||||
|
/// If the `session_intervals` table does not exist or contains no rows for
|
||||||
|
/// the requested session, an empty JSON array is returned.
|
||||||
|
async fn api_intervals(
|
||||||
|
State(state): State<Arc<WebState>>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
) -> Result<axum::Json<Vec<IntervalJson>>, AppError> {
|
||||||
|
let intervals = {
|
||||||
|
let conn = state
|
||||||
|
.query_conn
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| anyhow::anyhow!("lock: {}", e))?;
|
||||||
|
|
||||||
|
// Guard against the table not existing (e.g. first run before
|
||||||
|
// `ensure_web_tables` was ever called on this database file).
|
||||||
|
let table_exists: bool = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master \
|
||||||
|
WHERE type = 'table' AND name = 'session_intervals'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)
|
||||||
|
.map(|c| c > 0)
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if !table_exists {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT second, tx_bytes, rx_bytes
|
||||||
|
FROM session_intervals
|
||||||
|
WHERE session_id = ?1
|
||||||
|
ORDER BY second ASC",
|
||||||
|
)?;
|
||||||
|
let rows = stmt.query_map(params![id], |row| {
|
||||||
|
Ok(IntervalJson {
|
||||||
|
second: row.get(0)?,
|
||||||
|
tx_bytes: row.get(1)?,
|
||||||
|
rx_bytes: row.get(2)?,
|
||||||
|
})
|
||||||
|
})?;
|
||||||
|
rows.filter_map(Result::ok).collect::<Vec<_>>()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(axum::Json(intervals))
|
||||||
|
}
|
||||||
387
src/server_pro/web/templates/dashboard.html
Normal file
387
src/server_pro/web/templates/dashboard.html
Normal file
@@ -0,0 +1,387 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Dashboard — {{ ip }} — btest-rs</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;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
a { color: #58a6ff; text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.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; }
|
||||||
|
|
||||||
|
/* Stats cards */
|
||||||
|
.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: 0.8rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
.stat-card .value {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table */
|
||||||
|
.table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
background: #161b22;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
th, td {
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #21262d;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
background: #0d1117;
|
||||||
|
color: #8b949e;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
tr { cursor: pointer; }
|
||||||
|
tr:hover td { background: #1c2128; }
|
||||||
|
tr.selected td { background: #1f3a5f; }
|
||||||
|
|
||||||
|
.proto-tcp { color: #3fb950; }
|
||||||
|
.proto-udp { color: #d29922; }
|
||||||
|
.dir-tx { color: #f78166; }
|
||||||
|
.dir-rx { color: #58a6ff; }
|
||||||
|
.dir-both { color: #bc8cff; }
|
||||||
|
|
||||||
|
/* Chart area */
|
||||||
|
.chart-section {
|
||||||
|
background: #161b22;
|
||||||
|
border: 1px solid #30363d;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.chart-section h2 {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #8b949e;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.chart-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 360px;
|
||||||
|
}
|
||||||
|
.chart-placeholder {
|
||||||
|
text-align: center;
|
||||||
|
color: #484f58;
|
||||||
|
padding: 3rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
color: #484f58;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
.no-data {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem;
|
||||||
|
color: #484f58;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="header">
|
||||||
|
<h1>btest-rs</h1>
|
||||||
|
<span class="ip-label">{{ ip }}</span>
|
||||||
|
<span class="home-link"><a href="/">Home</a></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats summary (filled via API) -->
|
||||||
|
<div class="stats" id="stats-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="label">Total Tests</div>
|
||||||
|
<div class="value" id="stat-total-tests">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="label">Total TX</div>
|
||||||
|
<div class="value" id="stat-total-tx">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="label">Total RX</div>
|
||||||
|
<div class="value" id="stat-total-rx">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="label">Avg TX Mbps</div>
|
||||||
|
<div class="value" id="stat-avg-tx">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="label">Avg RX Mbps</div>
|
||||||
|
<div class="value" id="stat-avg-rx">—</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Chart for selected session -->
|
||||||
|
<div class="chart-section">
|
||||||
|
<h2 id="chart-title">Select a test below to view its throughput chart</h2>
|
||||||
|
<div class="chart-container">
|
||||||
|
<canvas id="throughput-chart"></canvas>
|
||||||
|
<div class="chart-placeholder" id="chart-placeholder">Click a row in the table to load the throughput graph for that session.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sessions table -->
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Protocol</th>
|
||||||
|
<th>Direction</th>
|
||||||
|
<th>TX Bytes</th>
|
||||||
|
<th>RX Bytes</th>
|
||||||
|
<th>Duration</th>
|
||||||
|
<th>Avg TX Mbps</th>
|
||||||
|
<th>Avg RX Mbps</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="sessions-body">
|
||||||
|
<tr><td colspan="9" class="no-data">Loading sessions...</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">Powered by btest-rs</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
|
<script>
|
||||||
|
var currentIp = "{{ ip }}";
|
||||||
|
var throughputChart = null;
|
||||||
|
|
||||||
|
function formatBytes(b) {
|
||||||
|
if (b === 0) return '0 B';
|
||||||
|
var units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
var i = Math.floor(Math.log(b) / Math.log(1024));
|
||||||
|
if (i >= units.length) i = units.length - 1;
|
||||||
|
return (b / Math.pow(1024, i)).toFixed(1) + ' ' + units[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMbps(bytesPerSec) {
|
||||||
|
return (bytesPerSec * 8 / 1e6).toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function durationStr(startedAt, endedAt) {
|
||||||
|
if (!startedAt || !endedAt) return '--';
|
||||||
|
var ms = new Date(endedAt) - new Date(startedAt);
|
||||||
|
if (ms < 0) return '--';
|
||||||
|
var s = Math.round(ms / 1000);
|
||||||
|
if (s < 60) return s + 's';
|
||||||
|
return Math.floor(s / 60) + 'm ' + (s % 60) + 's';
|
||||||
|
}
|
||||||
|
|
||||||
|
function durationSec(startedAt, endedAt) {
|
||||||
|
if (!startedAt || !endedAt) return 0;
|
||||||
|
var ms = new Date(endedAt) - new Date(startedAt);
|
||||||
|
return Math.max(ms / 1000, 0.001);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load summary stats
|
||||||
|
fetch('/api/ip/' + encodeURIComponent(currentIp) + '/stats')
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
document.getElementById('stat-total-tests').textContent = data.total_sessions || 0;
|
||||||
|
document.getElementById('stat-total-tx').textContent = formatBytes(data.total_tx_bytes || 0);
|
||||||
|
document.getElementById('stat-total-rx').textContent = formatBytes(data.total_rx_bytes || 0);
|
||||||
|
document.getElementById('stat-avg-tx').textContent = data.avg_tx_mbps ? data.avg_tx_mbps.toFixed(2) : '0.00';
|
||||||
|
document.getElementById('stat-avg-rx').textContent = data.avg_rx_mbps ? data.avg_rx_mbps.toFixed(2) : '0.00';
|
||||||
|
})
|
||||||
|
.catch(function() {});
|
||||||
|
|
||||||
|
// Load sessions list
|
||||||
|
fetch('/api/ip/' + encodeURIComponent(currentIp) + '/sessions')
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(sessions) {
|
||||||
|
var tbody = document.getElementById('sessions-body');
|
||||||
|
if (!sessions || sessions.length === 0) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="9" class="no-data">No test sessions found for this IP.</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
sessions.forEach(function(s, i) {
|
||||||
|
var tr = document.createElement('tr');
|
||||||
|
tr.dataset.sessionId = s.id;
|
||||||
|
tr.onclick = function() { selectSession(s.id, tr); };
|
||||||
|
|
||||||
|
var dur = durationSec(s.started_at, s.ended_at);
|
||||||
|
var avgTx = dur > 0 ? formatMbps(s.tx_bytes / dur) : '0.00';
|
||||||
|
var avgRx = dur > 0 ? formatMbps(s.rx_bytes / dur) : '0.00';
|
||||||
|
var proto = (s.protocol || 'TCP').toUpperCase();
|
||||||
|
var dir = (s.direction || 'BOTH').toUpperCase();
|
||||||
|
var protoClass = proto === 'UDP' ? 'proto-udp' : 'proto-tcp';
|
||||||
|
var dirClass = dir === 'TX' ? 'dir-tx' : dir === 'RX' ? 'dir-rx' : 'dir-both';
|
||||||
|
|
||||||
|
tr.innerHTML =
|
||||||
|
'<td>' + (i + 1) + '</td>' +
|
||||||
|
'<td>' + (s.started_at || '--') + '</td>' +
|
||||||
|
'<td class="' + protoClass + '">' + proto + '</td>' +
|
||||||
|
'<td class="' + dirClass + '">' + dir + '</td>' +
|
||||||
|
'<td>' + formatBytes(s.tx_bytes || 0) + '</td>' +
|
||||||
|
'<td>' + formatBytes(s.rx_bytes || 0) + '</td>' +
|
||||||
|
'<td>' + durationStr(s.started_at, s.ended_at) + '</td>' +
|
||||||
|
'<td>' + avgTx + '</td>' +
|
||||||
|
'<td>' + avgRx + '</td>';
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-select the first (most recent) session
|
||||||
|
if (sessions.length > 0) {
|
||||||
|
var firstRow = tbody.querySelector('tr');
|
||||||
|
if (firstRow) selectSession(sessions[0].id, firstRow);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function() {
|
||||||
|
document.getElementById('sessions-body').innerHTML =
|
||||||
|
'<tr><td colspan="9" class="no-data">Failed to load sessions.</td></tr>';
|
||||||
|
});
|
||||||
|
|
||||||
|
function selectSession(sessionId, rowEl) {
|
||||||
|
// Highlight selected row
|
||||||
|
var rows = document.querySelectorAll('#sessions-body tr');
|
||||||
|
rows.forEach(function(r) { r.classList.remove('selected'); });
|
||||||
|
rowEl.classList.add('selected');
|
||||||
|
|
||||||
|
document.getElementById('chart-title').textContent = 'Throughput for session #' + sessionId;
|
||||||
|
document.getElementById('chart-placeholder').style.display = 'none';
|
||||||
|
|
||||||
|
fetch('/api/session/' + sessionId + '/intervals')
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(intervals) {
|
||||||
|
renderChart(intervals);
|
||||||
|
})
|
||||||
|
.catch(function() {
|
||||||
|
document.getElementById('chart-placeholder').style.display = 'block';
|
||||||
|
document.getElementById('chart-placeholder').textContent = 'Failed to load interval data.';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderChart(intervals) {
|
||||||
|
var canvas = document.getElementById('throughput-chart');
|
||||||
|
if (throughputChart) {
|
||||||
|
throughputChart.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!intervals || intervals.length === 0) {
|
||||||
|
document.getElementById('chart-placeholder').style.display = 'block';
|
||||||
|
document.getElementById('chart-placeholder').textContent = 'No interval data available for this session.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var labels = intervals.map(function(d) { return d.second + 's'; });
|
||||||
|
var txData = intervals.map(function(d) { return (d.tx_bytes * 8 / 1e6).toFixed(2); });
|
||||||
|
var rxData = intervals.map(function(d) { return (d.rx_bytes * 8 / 1e6).toFixed(2); });
|
||||||
|
|
||||||
|
throughputChart = new Chart(canvas, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'TX Mbps',
|
||||||
|
data: txData,
|
||||||
|
borderColor: '#f78166',
|
||||||
|
backgroundColor: 'rgba(247, 129, 102, 0.1)',
|
||||||
|
borderWidth: 2,
|
||||||
|
fill: true,
|
||||||
|
tension: 0.3,
|
||||||
|
pointRadius: 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'RX Mbps',
|
||||||
|
data: rxData,
|
||||||
|
borderColor: '#58a6ff',
|
||||||
|
backgroundColor: 'rgba(88, 166, 255, 0.1)',
|
||||||
|
borderWidth: 2,
|
||||||
|
fill: true,
|
||||||
|
tension: 0.3,
|
||||||
|
pointRadius: 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: {
|
||||||
|
intersect: false,
|
||||||
|
mode: 'index'
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
title: { display: true, text: 'Time', color: '#8b949e' },
|
||||||
|
ticks: { color: '#8b949e' },
|
||||||
|
grid: { color: '#21262d' }
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
title: { display: true, text: 'Mbps', color: '#8b949e' },
|
||||||
|
ticks: { color: '#8b949e' },
|
||||||
|
grid: { color: '#21262d' },
|
||||||
|
beginAtZero: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
labels: { color: '#e1e4e8' }
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
backgroundColor: '#161b22',
|
||||||
|
borderColor: '#30363d',
|
||||||
|
borderWidth: 1,
|
||||||
|
titleColor: '#e1e4e8',
|
||||||
|
bodyColor: '#8b949e'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
160
src/server_pro/web/templates/index.html
Normal file
160
src/server_pro/web/templates/index.html
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>btest-rs 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: 0.5rem;
|
||||||
|
color: #58a6ff;
|
||||||
|
}
|
||||||
|
.subtitle {
|
||||||
|
color: #8b949e;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.search-box {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.search-box input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.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: 0.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: 0.5rem;
|
||||||
|
}
|
||||||
|
.info code {
|
||||||
|
background: #0d1117;
|
||||||
|
padding: 0.15rem 0.4rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #58a6ff;
|
||||||
|
}
|
||||||
|
.auto-link {
|
||||||
|
margin-top: 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.auto-link a {
|
||||||
|
color: #58a6ff;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.auto-link a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
margin-top: 2rem;
|
||||||
|
color: #484f58;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>btest-rs</h1>
|
||||||
|
<p class="subtitle">Public MikroTik Bandwidth Test Server — 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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">Powered by btest-rs</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function goToDashboard() {
|
||||||
|
var ip = document.getElementById('ip-input').value.trim();
|
||||||
|
if (ip) {
|
||||||
|
window.location.href = '/dashboard/' + encodeURIComponent(ip);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-detect visitor IP and offer a direct link
|
||||||
|
fetch('https://api.ipify.org?format=json')
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (data.ip) {
|
||||||
|
document.getElementById('ip-input').value = data.ip;
|
||||||
|
document.getElementById('auto-detect').innerHTML =
|
||||||
|
'Detected IP: <a href="/dashboard/' + encodeURIComponent(data.ip) + '">' + data.ip + '</a> — click to view your dashboard';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function() {
|
||||||
|
document.getElementById('auto-detect').textContent = '';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user