Compare commits
1 Commits
feature/se
...
89391e1781
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89391e1781 |
1434
Cargo.lock
generated
1434
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
24
Cargo.toml
24
Cargo.toml
@@ -17,13 +17,12 @@ name = "btest"
|
|||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "btest-server-pro"
|
name = "btest-client"
|
||||||
path = "src/server_pro/main.rs"
|
path = "src/bin/client_only.rs"
|
||||||
required-features = ["pro"]
|
|
||||||
|
|
||||||
[features]
|
[[bin]]
|
||||||
default = []
|
name = "btest-server"
|
||||||
pro = ["dep:rusqlite", "dep:ldap3", "dep:axum", "dep:tower-http", "dep:serde", "dep:serde_json", "dep:askama"]
|
path = "src/bin/server_only.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
@@ -41,16 +40,15 @@ 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
|
||||||
lto = true
|
lto = true
|
||||||
strip = true
|
strip = true
|
||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
|
|
||||||
|
# Minimal size profile for embedded/OpenWrt targets
|
||||||
|
[profile.release-small]
|
||||||
|
inherits = "release"
|
||||||
|
opt-level = "z"
|
||||||
|
panic = "abort"
|
||||||
|
|||||||
57
deploy/openwrt/Makefile
Normal file
57
deploy/openwrt/Makefile
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# OpenWrt package Makefile for btest-rs
|
||||||
|
#
|
||||||
|
# To build:
|
||||||
|
# 1. Clone the OpenWrt SDK for your target
|
||||||
|
# 2. Copy this directory to package/btest-rs/ in the SDK
|
||||||
|
# 3. Run: make package/btest-rs/compile V=s
|
||||||
|
#
|
||||||
|
# Or use the pre-built binary approach (see build-ipk.sh)
|
||||||
|
|
||||||
|
include $(TOPDIR)/rules.mk
|
||||||
|
|
||||||
|
PKG_NAME:=btest-rs
|
||||||
|
PKG_VERSION:=0.6.0
|
||||||
|
PKG_RELEASE:=1
|
||||||
|
|
||||||
|
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
|
||||||
|
PKG_SOURCE_URL:=https://github.com/manawenuz/btest-rs/archive/refs/tags/v$(PKG_VERSION).tar.gz
|
||||||
|
PKG_HASH:=skip
|
||||||
|
|
||||||
|
PKG_BUILD_DEPENDS:=rust/host
|
||||||
|
PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME)-$(PKG_VERSION)
|
||||||
|
|
||||||
|
include $(INCLUDE_DIR)/package.mk
|
||||||
|
|
||||||
|
define Package/btest-rs
|
||||||
|
SECTION:=net
|
||||||
|
CATEGORY:=Network
|
||||||
|
TITLE:=MikroTik Bandwidth Test server and client
|
||||||
|
URL:=https://github.com/manawenuz/btest-rs
|
||||||
|
DEPENDS:=
|
||||||
|
PKGARCH:=$(ARCH)
|
||||||
|
endef
|
||||||
|
|
||||||
|
define Package/btest-rs/description
|
||||||
|
A Rust reimplementation of the MikroTik Bandwidth Test (btest) protocol.
|
||||||
|
Supports TCP/UDP, IPv4/IPv6, EC-SRP5 and MD5 authentication,
|
||||||
|
multi-connection, syslog, CSV output, and CPU monitoring.
|
||||||
|
endef
|
||||||
|
|
||||||
|
define Build/Compile
|
||||||
|
cd $(PKG_BUILD_DIR) && \
|
||||||
|
CARGO_TARGET_DIR=$(PKG_BUILD_DIR)/target \
|
||||||
|
cargo build --release --target $(RUSTC_TARGET)
|
||||||
|
endef
|
||||||
|
|
||||||
|
define Package/btest-rs/install
|
||||||
|
$(INSTALL_DIR) $(1)/usr/bin
|
||||||
|
$(INSTALL_BIN) $(PKG_BUILD_DIR)/target/$(RUSTC_TARGET)/release/btest $(1)/usr/bin/btest
|
||||||
|
|
||||||
|
$(INSTALL_DIR) $(1)/etc/init.d
|
||||||
|
$(INSTALL_BIN) ./files/btest.init $(1)/etc/init.d/btest
|
||||||
|
|
||||||
|
$(INSTALL_DIR) $(1)/etc/config
|
||||||
|
$(INSTALL_CONF) ./files/btest.config $(1)/etc/config/btest
|
||||||
|
endef
|
||||||
|
|
||||||
|
$(eval $(call BuildPackage,btest-rs))
|
||||||
117
deploy/openwrt/build-ipk.sh
Executable file
117
deploy/openwrt/build-ipk.sh
Executable file
@@ -0,0 +1,117 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build an OpenWrt .ipk package from a pre-built static binary.
|
||||||
|
# No OpenWrt SDK needed — just packages the binary with metadata.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./deploy/openwrt/build-ipk.sh <arch> [binary-path]
|
||||||
|
#
|
||||||
|
# Examples:
|
||||||
|
# ./deploy/openwrt/build-ipk.sh x86_64 dist/btest # from cross-compiled binary
|
||||||
|
# ./deploy/openwrt/build-ipk.sh aarch64 dist/btest # for RPi/ARM64 routers
|
||||||
|
# ./deploy/openwrt/build-ipk.sh mipsel target/release/btest # for MIPS little-endian
|
||||||
|
#
|
||||||
|
# Supported architectures: x86_64, aarch64, arm_cortex-a7, mipsel_24kc, mips_24kc
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/../.."
|
||||||
|
|
||||||
|
ARCH="${1:?Usage: $0 <arch> [binary-path]}"
|
||||||
|
BINARY="${2:-dist/btest}"
|
||||||
|
VERSION="0.6.0"
|
||||||
|
PKG_NAME="btest-rs"
|
||||||
|
OUTPUT_DIR="dist"
|
||||||
|
|
||||||
|
if [ ! -f "$BINARY" ]; then
|
||||||
|
echo "Error: binary not found at $BINARY"
|
||||||
|
echo "Build it first: cargo build --release --target <target>"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$OUTPUT_DIR"
|
||||||
|
WORKDIR=$(mktemp -d)
|
||||||
|
trap "rm -rf $WORKDIR" EXIT
|
||||||
|
|
||||||
|
echo "=== Building ${PKG_NAME}_${VERSION}_${ARCH}.ipk ==="
|
||||||
|
|
||||||
|
# Create package structure
|
||||||
|
mkdir -p "$WORKDIR/data/usr/bin"
|
||||||
|
mkdir -p "$WORKDIR/data/etc/init.d"
|
||||||
|
mkdir -p "$WORKDIR/data/etc/config"
|
||||||
|
mkdir -p "$WORKDIR/control"
|
||||||
|
|
||||||
|
# Install files
|
||||||
|
cp "$BINARY" "$WORKDIR/data/usr/bin/btest"
|
||||||
|
chmod 755 "$WORKDIR/data/usr/bin/btest"
|
||||||
|
cp deploy/openwrt/files/btest.init "$WORKDIR/data/etc/init.d/btest"
|
||||||
|
chmod 755 "$WORKDIR/data/etc/init.d/btest"
|
||||||
|
cp deploy/openwrt/files/btest.config "$WORKDIR/data/etc/config/btest"
|
||||||
|
|
||||||
|
# Calculate installed size
|
||||||
|
INSTALLED_SIZE=$(du -sk "$WORKDIR/data" | awk '{print $1}')
|
||||||
|
|
||||||
|
# Control file
|
||||||
|
cat > "$WORKDIR/control/control" << EOF
|
||||||
|
Package: ${PKG_NAME}
|
||||||
|
Version: ${VERSION}-1
|
||||||
|
Depends: libc
|
||||||
|
Source: https://github.com/manawenuz/btest-rs
|
||||||
|
License: MIT AND Apache-2.0
|
||||||
|
Section: net
|
||||||
|
SourceName: ${PKG_NAME}
|
||||||
|
Maintainer: Siavash Sameni <manwe@manko.yoga>
|
||||||
|
Architecture: ${ARCH}
|
||||||
|
Installed-Size: ${INSTALLED_SIZE}
|
||||||
|
Description: MikroTik Bandwidth Test server and client
|
||||||
|
A Rust reimplementation of the MikroTik btest protocol.
|
||||||
|
Supports TCP/UDP, EC-SRP5 and MD5 auth, IPv4/IPv6.
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Post-install script
|
||||||
|
cat > "$WORKDIR/control/postinst" << 'EOF'
|
||||||
|
#!/bin/sh
|
||||||
|
[ "${IPKG_NO_SCRIPT}" = "1" ] && exit 0
|
||||||
|
/etc/init.d/btest enable 2>/dev/null || true
|
||||||
|
exit 0
|
||||||
|
EOF
|
||||||
|
chmod 755 "$WORKDIR/control/postinst"
|
||||||
|
|
||||||
|
# Pre-remove script
|
||||||
|
cat > "$WORKDIR/control/prerm" << 'EOF'
|
||||||
|
#!/bin/sh
|
||||||
|
/etc/init.d/btest stop 2>/dev/null || true
|
||||||
|
/etc/init.d/btest disable 2>/dev/null || true
|
||||||
|
exit 0
|
||||||
|
EOF
|
||||||
|
chmod 755 "$WORKDIR/control/prerm"
|
||||||
|
|
||||||
|
# Conffiles
|
||||||
|
cat > "$WORKDIR/control/conffiles" << EOF
|
||||||
|
/etc/config/btest
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Build the .ipk (it's just a tar.gz of tar.gz's)
|
||||||
|
cd "$WORKDIR"
|
||||||
|
|
||||||
|
# Create data.tar.gz
|
||||||
|
(cd data && tar czf ../data.tar.gz .)
|
||||||
|
|
||||||
|
# Create control.tar.gz
|
||||||
|
(cd control && tar czf ../control.tar.gz .)
|
||||||
|
|
||||||
|
# Create debian-binary
|
||||||
|
echo "2.0" > debian-binary
|
||||||
|
|
||||||
|
# Package it all
|
||||||
|
tar czf "${PKG_NAME}_${VERSION}-1_${ARCH}.ipk" debian-binary control.tar.gz data.tar.gz
|
||||||
|
|
||||||
|
cd -
|
||||||
|
cp "$WORKDIR/${PKG_NAME}_${VERSION}-1_${ARCH}.ipk" "$OUTPUT_DIR/"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Package: $OUTPUT_DIR/${PKG_NAME}_${VERSION}-1_${ARCH}.ipk"
|
||||||
|
ls -lh "$OUTPUT_DIR/${PKG_NAME}_${VERSION}-1_${ARCH}.ipk"
|
||||||
|
echo ""
|
||||||
|
echo "Install on OpenWrt:"
|
||||||
|
echo " scp $OUTPUT_DIR/${PKG_NAME}_${VERSION}-1_${ARCH}.ipk root@router:/tmp/"
|
||||||
|
echo " ssh root@router 'opkg install /tmp/${PKG_NAME}_${VERSION}-1_${ARCH}.ipk'"
|
||||||
|
echo " ssh root@router '/etc/init.d/btest enable && /etc/init.d/btest start'"
|
||||||
7
deploy/openwrt/files/btest.config
Normal file
7
deploy/openwrt/files/btest.config
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
config server
|
||||||
|
option enabled '0'
|
||||||
|
option port '2000'
|
||||||
|
option auth_user ''
|
||||||
|
option auth_pass ''
|
||||||
|
option ecsrp5 '0'
|
||||||
|
option syslog ''
|
||||||
34
deploy/openwrt/files/btest.init
Executable file
34
deploy/openwrt/files/btest.init
Executable file
@@ -0,0 +1,34 @@
|
|||||||
|
#!/bin/sh /etc/rc.common
|
||||||
|
# btest-rs OpenWrt init script
|
||||||
|
|
||||||
|
START=90
|
||||||
|
STOP=10
|
||||||
|
|
||||||
|
USE_PROCD=1
|
||||||
|
|
||||||
|
start_service() {
|
||||||
|
local enabled port auth_user auth_pass ecsrp5 syslog
|
||||||
|
|
||||||
|
config_load btest
|
||||||
|
config_get_bool enabled server enabled 0
|
||||||
|
[ "$enabled" -eq 0 ] && return
|
||||||
|
|
||||||
|
config_get port server port 2000
|
||||||
|
config_get auth_user server auth_user ''
|
||||||
|
config_get auth_pass server auth_pass ''
|
||||||
|
config_get_bool ecsrp5 server ecsrp5 0
|
||||||
|
config_get syslog server syslog ''
|
||||||
|
|
||||||
|
procd_open_instance
|
||||||
|
procd_set_param command /usr/bin/btest -s -P "$port"
|
||||||
|
|
||||||
|
[ -n "$auth_user" ] && procd_append_param command -a "$auth_user"
|
||||||
|
[ -n "$auth_pass" ] && procd_append_param command -p "$auth_pass"
|
||||||
|
[ "$ecsrp5" -eq 1 ] && procd_append_param command --ecsrp5
|
||||||
|
[ -n "$syslog" ] && procd_append_param command --syslog "$syslog"
|
||||||
|
|
||||||
|
procd_set_param respawn
|
||||||
|
procd_set_param stdout 1
|
||||||
|
procd_set_param stderr 1
|
||||||
|
procd_close_instance
|
||||||
|
}
|
||||||
127
src/bin/client_only.rs
Normal file
127
src/bin/client_only.rs
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
//! btest-client: minimal bandwidth test client for embedded/OpenWrt systems.
|
||||||
|
//!
|
||||||
|
//! Stripped-down client that connects to MikroTik btest servers.
|
||||||
|
//! No server mode, no syslog, smaller binary footprint.
|
||||||
|
//!
|
||||||
|
//! Build: cargo build --profile release-small --bin btest-client
|
||||||
|
|
||||||
|
use clap::Parser;
|
||||||
|
use std::sync::atomic::Ordering;
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(name = "btest-client", about = "MikroTik Bandwidth Test client", version)]
|
||||||
|
struct Cli {
|
||||||
|
/// Server address to connect to
|
||||||
|
#[arg(short = 'c', long = "client", required = true)]
|
||||||
|
host: String,
|
||||||
|
|
||||||
|
/// Transmit data (upload)
|
||||||
|
#[arg(short = 't', long = "transmit")]
|
||||||
|
transmit: bool,
|
||||||
|
|
||||||
|
/// Receive data (download)
|
||||||
|
#[arg(short = 'r', long = "receive")]
|
||||||
|
receive: bool,
|
||||||
|
|
||||||
|
/// Use UDP
|
||||||
|
#[arg(short = 'u', long = "udp")]
|
||||||
|
udp: bool,
|
||||||
|
|
||||||
|
/// Bandwidth limit (e.g., 100M)
|
||||||
|
#[arg(short = 'b', long = "bandwidth")]
|
||||||
|
bandwidth: Option<String>,
|
||||||
|
|
||||||
|
/// Port
|
||||||
|
#[arg(short = 'P', long = "port", default_value_t = 2000)]
|
||||||
|
port: u16,
|
||||||
|
|
||||||
|
/// Username
|
||||||
|
#[arg(short = 'a', long = "authuser")]
|
||||||
|
auth_user: Option<String>,
|
||||||
|
|
||||||
|
/// Password
|
||||||
|
#[arg(short = 'p', long = "authpass")]
|
||||||
|
auth_pass: Option<String>,
|
||||||
|
|
||||||
|
/// NAT mode
|
||||||
|
#[arg(short = 'n', long = "nat")]
|
||||||
|
nat: bool,
|
||||||
|
|
||||||
|
/// Duration in seconds (0=unlimited)
|
||||||
|
#[arg(short = 'd', long = "duration", default_value_t = 0)]
|
||||||
|
duration: u64,
|
||||||
|
|
||||||
|
/// Verbose
|
||||||
|
#[arg(short = 'v', long = "verbose", action = clap::ArgAction::Count)]
|
||||||
|
verbose: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
let filter = match cli.verbose {
|
||||||
|
0 => "info",
|
||||||
|
1 => "debug",
|
||||||
|
_ => "trace",
|
||||||
|
};
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(
|
||||||
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(filter)),
|
||||||
|
)
|
||||||
|
.with_target(false)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
btest_rs::cpu::start_sampler();
|
||||||
|
|
||||||
|
if !cli.transmit && !cli.receive {
|
||||||
|
eprintln!("Error: specify -t (transmit) and/or -r (receive)");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let direction = match (cli.transmit, cli.receive) {
|
||||||
|
(true, false) => btest_rs::protocol::CMD_DIR_RX,
|
||||||
|
(false, true) => btest_rs::protocol::CMD_DIR_TX,
|
||||||
|
(true, true) => btest_rs::protocol::CMD_DIR_BOTH,
|
||||||
|
_ => unreachable!(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let bw = match &cli.bandwidth {
|
||||||
|
Some(b) => btest_rs::bandwidth::parse_bandwidth(b)?,
|
||||||
|
None => 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
let (tx_speed, rx_speed) = match direction {
|
||||||
|
btest_rs::protocol::CMD_DIR_TX => (bw, 0),
|
||||||
|
btest_rs::protocol::CMD_DIR_RX => (0, bw),
|
||||||
|
_ => (bw, bw),
|
||||||
|
};
|
||||||
|
|
||||||
|
let state = btest_rs::bandwidth::BandwidthState::new();
|
||||||
|
let state_clone = state.clone();
|
||||||
|
|
||||||
|
let host = cli.host.clone();
|
||||||
|
let client_fut = btest_rs::client::run_client(
|
||||||
|
&host, cli.port, direction, cli.udp,
|
||||||
|
tx_speed, rx_speed,
|
||||||
|
cli.auth_user, cli.auth_pass, cli.nat,
|
||||||
|
state_clone,
|
||||||
|
);
|
||||||
|
|
||||||
|
if cli.duration > 0 {
|
||||||
|
match tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(cli.duration),
|
||||||
|
client_fut,
|
||||||
|
).await {
|
||||||
|
Ok(r) => { let _ = r?; }
|
||||||
|
Err(_) => {
|
||||||
|
state.running.store(false, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let _ = client_fut.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
62
src/bin/server_only.rs
Normal file
62
src/bin/server_only.rs
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
//! btest-server: minimal bandwidth test server for embedded/OpenWrt systems.
|
||||||
|
//!
|
||||||
|
//! Stripped-down server that accepts MikroTik client connections.
|
||||||
|
//! No client mode, no syslog, no CSV, smaller binary footprint.
|
||||||
|
//!
|
||||||
|
//! Build: cargo build --profile release-small --bin btest-server
|
||||||
|
|
||||||
|
use clap::Parser;
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(name = "btest-server", about = "MikroTik Bandwidth Test server", version)]
|
||||||
|
struct Cli {
|
||||||
|
/// Port
|
||||||
|
#[arg(short = 'P', long = "port", default_value_t = 2000)]
|
||||||
|
port: u16,
|
||||||
|
|
||||||
|
/// IPv4 listen address
|
||||||
|
#[arg(long = "listen", default_value = "0.0.0.0")]
|
||||||
|
listen_addr: String,
|
||||||
|
|
||||||
|
/// Username
|
||||||
|
#[arg(short = 'a', long = "authuser")]
|
||||||
|
auth_user: Option<String>,
|
||||||
|
|
||||||
|
/// Password
|
||||||
|
#[arg(short = 'p', long = "authpass")]
|
||||||
|
auth_pass: Option<String>,
|
||||||
|
|
||||||
|
/// Use EC-SRP5 authentication
|
||||||
|
#[arg(long = "ecsrp5")]
|
||||||
|
ecsrp5: bool,
|
||||||
|
|
||||||
|
/// Verbose
|
||||||
|
#[arg(short = 'v', long = "verbose", action = clap::ArgAction::Count)]
|
||||||
|
verbose: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
let filter = match cli.verbose {
|
||||||
|
0 => "info",
|
||||||
|
1 => "debug",
|
||||||
|
_ => "trace",
|
||||||
|
};
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(
|
||||||
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(filter)),
|
||||||
|
)
|
||||||
|
.with_target(false)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
btest_rs::cpu::start_sampler();
|
||||||
|
|
||||||
|
let v4 = if cli.listen_addr.eq_ignore_ascii_case("none") { None } else { Some(cli.listen_addr) };
|
||||||
|
|
||||||
|
tracing::info!("btest-server starting on port {}", cli.port);
|
||||||
|
btest_rs::server::run_server(cli.port, cli.auth_user, cli.auth_pass, cli.ecsrp5, v4, None).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -366,22 +366,8 @@ 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();
|
||||||
@@ -647,18 +633,6 @@ 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,
|
||||||
@@ -666,17 +640,7 @@ 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 state = BandwidthState::new();
|
let server_udp_port = BTEST_UDP_PORT_START + offset;
|
||||||
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?;
|
||||||
@@ -743,6 +707,7 @@ async fn run_udp_test_inner(
|
|||||||
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();
|
||||||
|
|||||||
@@ -1,345 +0,0 @@
|
|||||||
//! 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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
//! 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,343 +0,0 @@
|
|||||||
//! 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(())
|
|
||||||
}
|
|
||||||
@@ -1,396 +0,0 @@
|
|||||||
//! 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,280 +0,0 @@
|
|||||||
//! 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))
|
|
||||||
}
|
|
||||||
@@ -1,621 +0,0 @@
|
|||||||
//! 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;
|
|
||||||
@@ -1,546 +0,0 @@
|
|||||||
//! 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))
|
|
||||||
}
|
|
||||||
@@ -1,387 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
<!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