Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27c69d8982 | ||
|
|
2cb8519c95 | ||
|
|
9ca124cb76 | ||
|
|
c06a4d0c9a |
48
README.md
48
README.md
@@ -2,6 +2,25 @@
|
||||
|
||||
A Rust reimplementation of the [MikroTik Bandwidth Test (btest)](https://wiki.mikrotik.com/wiki/Manual:Tools/Bandwidth_Test) protocol. Both server and client modes, fully compatible with MikroTik RouterOS devices.
|
||||
|
||||
## Free Public Servers
|
||||
|
||||
Test your MikroTik link speed right now — no setup, no registration:
|
||||
|
||||
| Server | Location | Dashboard |
|
||||
|--------|----------|-----------|
|
||||
| `104.225.217.60` | US | [btest.home.kg](https://btest.home.kg) |
|
||||
| `188.245.59.196` | EU | [btest.mikata.ru](https://btest.mikata.ru) |
|
||||
|
||||
```
|
||||
/tool bandwidth-test address=104.225.217.60 user=btest password=btest protocol=tcp direction=both
|
||||
```
|
||||
|
||||
After the test, visit `https://btest.home.kg/dashboard/YOUR_IP` to see your results, throughput history, and quota usage. Per-IP limits: 2 GB daily / 8 GB weekly / 24 GB monthly.
|
||||
|
||||
> **Note:** TCP is recommended for remote testing. UDP bidirectional through NAT will only show one direction — this is a btest protocol limitation, not specific to btest-rs. See [KNOWN_ISSUES.md](KNOWN_ISSUES.md) for details.
|
||||
|
||||
Want to run your own public server? Build with `cargo build --release --features pro` — see [Server Pro](#server-pro) below.
|
||||
|
||||
## Features
|
||||
|
||||
- **Full protocol support** -- TCP and UDP data transfer, IPv4 and IPv6
|
||||
@@ -16,7 +35,7 @@ A Rust reimplementation of the [MikroTik Bandwidth Test (btest)](https://wiki.mi
|
||||
- **Quiet mode** -- suppress terminal output for scripted/automated use
|
||||
- **NAT traversal** -- probe packet to open firewall holes for UDP receive
|
||||
- **Single static binary** -- ~2 MB, zero runtime dependencies (musl build)
|
||||
- **Cross-platform** -- macOS, Linux (x86_64, ARM64), Docker
|
||||
- **Cross-platform** -- macOS, Linux (x86_64, ARM64, ARMv7), Windows, Android (Termux), Docker
|
||||
- **Async I/O** -- tokio-based, handles many concurrent connections efficiently
|
||||
|
||||
## Performance
|
||||
@@ -61,6 +80,10 @@ sudo mv btest /usr/local/bin/
|
||||
|
||||
# Windows
|
||||
# Download btest-windows-x86_64.zip from releases
|
||||
|
||||
# Android (Termux, no root needed)
|
||||
curl -L <release-url>/btest-android-aarch64.tar.gz | tar xz
|
||||
mv btest $PREFIX/bin/
|
||||
```
|
||||
|
||||
### Raspberry Pi
|
||||
@@ -267,6 +290,29 @@ scripts/test-mikrotik.sh <ip> # Test against MikroTik device
|
||||
scripts/test-docker.sh # Docker container test
|
||||
```
|
||||
|
||||
## Server Pro
|
||||
|
||||
An optional superset of the standard server with multi-user support, quotas, and a web dashboard. Build with `--features pro`:
|
||||
|
||||
```bash
|
||||
cargo build --release --features pro --bin btest-server-pro
|
||||
```
|
||||
|
||||
Features:
|
||||
- **SQLite user database** — add/remove users, per-user quotas
|
||||
- **Per-IP bandwidth quotas** — daily, weekly, monthly limits with inline byte budget enforcement
|
||||
- **Web dashboard** — session history, throughput stats, quota progress bars, JSON export
|
||||
- **TCP multi-connection** — handles MikroTik's default 20-connection mode
|
||||
- **MD5 auth against DB** — proper challenge-response verification
|
||||
|
||||
```bash
|
||||
# Create a user and start the server
|
||||
btest-server-pro --users-db users.db useradd btest btest
|
||||
btest-server-pro --users-db users.db --ip-daily 2147483648 --ip-weekly 8589934592 --web-port 8080
|
||||
```
|
||||
|
||||
The pro features are completely optional and don't affect the standard `btest` binary.
|
||||
|
||||
## Credits
|
||||
|
||||
- **[btest-opensource](https://github.com/samm-git/btest-opensource)** by [Alex Samorukov](https://github.com/samm-git) -- original C implementation and protocol reverse-engineering. Licensed under **MIT**.
|
||||
|
||||
@@ -73,6 +73,7 @@ impl BandwidthState {
|
||||
}
|
||||
|
||||
/// Set the byte budget (total bytes allowed for the entire test).
|
||||
#[cfg(feature = "pro")]
|
||||
pub fn set_budget(&self, budget: u64) {
|
||||
self.byte_budget.store(budget, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
|
||||
@@ -127,6 +127,12 @@ async fn run_tcp_test_client(stream: TcpStream, cmd: Command, state: Arc<Bandwid
|
||||
Some(tokio::spawn(async move {
|
||||
tcp_client_rx_loop(reader, state_rx).await
|
||||
}))
|
||||
} else if client_should_tx {
|
||||
// TX-only: still need to read the server's status messages to get remote CPU.
|
||||
// Don't count these bytes as RX data.
|
||||
Some(tokio::spawn(async move {
|
||||
tcp_client_status_reader(reader, state_rx).await
|
||||
}))
|
||||
} else {
|
||||
_reader_keepalive = Some(reader);
|
||||
None
|
||||
@@ -189,10 +195,52 @@ async fn tcp_client_rx_loop(
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => {
|
||||
state.rx_bytes.fetch_add(n as u64, Ordering::Relaxed);
|
||||
// Scan for interleaved 12-byte status messages from the server.
|
||||
// In BOTH mode, the server's TX loop injects status messages into the
|
||||
// data stream. Status starts with 0x07 (STATUS_MSG_TYPE) and byte 1
|
||||
// has the high bit set (0x80 | cpu%). Data packets are all zeros.
|
||||
if n >= STATUS_MSG_SIZE {
|
||||
for i in 0..=(n - STATUS_MSG_SIZE) {
|
||||
if buf[i] == STATUS_MSG_TYPE && buf[i + 1] >= 0x80 {
|
||||
let cpu = buf[i + 1] & 0x7F;
|
||||
state.remote_cpu.store(cpu.min(100), Ordering::Relaxed);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read only status messages from the server (TX-only mode).
|
||||
/// The server sends 12-byte status messages on the TCP connection even when
|
||||
/// the client is only transmitting. We need to read them to get remote CPU
|
||||
/// and to prevent the TCP receive buffer from filling up.
|
||||
async fn tcp_client_status_reader(
|
||||
mut reader: tokio::net::tcp::OwnedReadHalf,
|
||||
state: Arc<BandwidthState>,
|
||||
) {
|
||||
let mut buf = [0u8; STATUS_MSG_SIZE];
|
||||
while state.running.load(Ordering::Relaxed) {
|
||||
match reader.read_exact(&mut buf).await {
|
||||
Ok(_) => {
|
||||
if buf[0] == STATUS_MSG_TYPE && buf[1] >= 0x80 {
|
||||
let status = StatusMessage::deserialize(&buf);
|
||||
state.remote_cpu.store(status.cpu_load, Ordering::Relaxed);
|
||||
// Use server's bytes_received for TX speed adaptation
|
||||
if status.bytes_received > 0 {
|
||||
let new_speed =
|
||||
((status.bytes_received as u64 * 8 * 3) / 2) as u32;
|
||||
state.tx_speed.store(new_speed, Ordering::Relaxed);
|
||||
state.tx_speed_changed.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- UDP Test Client ---
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ pub fn get() -> u8 {
|
||||
|
||||
// --- Platform-specific implementation ---
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[cfg(any(target_os = "linux", target_os = "android"))]
|
||||
fn get_cpu_times() -> (u64, u64) {
|
||||
// Read /proc/stat: cpu user nice system idle iowait irq softirq steal
|
||||
if let Ok(content) = std::fs::read_to_string("/proc/stat") {
|
||||
@@ -97,6 +97,7 @@ fn get_cpu_times() -> (u64, u64) {
|
||||
fn get_cpu_times() -> (u64, u64) {
|
||||
#[repr(C)]
|
||||
#[derive(Default)]
|
||||
#[allow(non_snake_case)]
|
||||
struct FILETIME {
|
||||
dwLowDateTime: u32,
|
||||
dwHighDateTime: u32,
|
||||
@@ -165,6 +166,7 @@ fn get_cpu_times() -> (u64, u64) {
|
||||
|
||||
#[cfg(not(any(
|
||||
target_os = "linux",
|
||||
target_os = "android",
|
||||
target_os = "macos",
|
||||
target_os = "windows",
|
||||
target_os = "freebsd",
|
||||
@@ -193,6 +195,7 @@ mod tests {
|
||||
// On supported platforms, total should be > 0
|
||||
if cfg!(any(
|
||||
target_os = "linux",
|
||||
target_os = "android",
|
||||
target_os = "macos",
|
||||
target_os = "windows",
|
||||
target_os = "freebsd",
|
||||
|
||||
@@ -367,6 +367,7 @@ async fn handle_client(
|
||||
// --- TCP Test Server ---
|
||||
|
||||
/// Public TX task for multi-connection use by server_pro.
|
||||
#[cfg(feature = "pro")]
|
||||
pub async fn tcp_tx_task(
|
||||
writer: tokio::net::tcp::OwnedWriteHalf,
|
||||
tx_size: usize,
|
||||
@@ -377,6 +378,7 @@ pub async fn tcp_tx_task(
|
||||
}
|
||||
|
||||
/// Public RX task for multi-connection use by server_pro.
|
||||
#[cfg(feature = "pro")]
|
||||
pub async fn tcp_rx_task(
|
||||
reader: tokio::net::tcp::OwnedReadHalf,
|
||||
state: Arc<BandwidthState>,
|
||||
@@ -386,6 +388,7 @@ pub async fn tcp_rx_task(
|
||||
|
||||
/// Run a TCP bandwidth test on an already-authenticated stream.
|
||||
/// Public API for use by server_pro.
|
||||
#[cfg(feature = "pro")]
|
||||
pub async fn run_tcp_test(
|
||||
stream: TcpStream,
|
||||
cmd: Command,
|
||||
@@ -470,6 +473,7 @@ async fn run_tcp_test_inner(stream: TcpStream, cmd: Command, state: Arc<Bandwidt
|
||||
}
|
||||
|
||||
/// Public API for multi-connection TCP test with external state. Used by server_pro.
|
||||
#[cfg(feature = "pro")]
|
||||
pub async fn run_tcp_multiconn_test(
|
||||
streams: Vec<TcpStream>,
|
||||
cmd: Command,
|
||||
@@ -686,6 +690,7 @@ async fn tcp_status_sender(
|
||||
|
||||
/// Run a UDP bandwidth test on an already-authenticated stream.
|
||||
/// Public API for use by server_pro. Caller provides the UDP port offset.
|
||||
#[cfg(feature = "pro")]
|
||||
pub async fn run_udp_test(
|
||||
stream: &mut TcpStream,
|
||||
peer: SocketAddr,
|
||||
|
||||
@@ -235,7 +235,7 @@ async fn test_csv_created_client() {
|
||||
// Initialize CSV
|
||||
btest_rs::csv_output::init(&csv_path).unwrap();
|
||||
|
||||
let (tx, rx, lost, intervals) = run_client_test(
|
||||
let (tx, rx, lost, _intervals) = run_client_test(
|
||||
"127.0.0.1", port, false, true, false, None, None,
|
||||
).await;
|
||||
|
||||
@@ -336,3 +336,67 @@ async fn test_bandwidth_state_running_flag() {
|
||||
state.running.store(false, Ordering::SeqCst);
|
||||
assert!(!state.running.load(Ordering::Relaxed));
|
||||
}
|
||||
|
||||
// --- CPU Reporting Tests ---
|
||||
|
||||
/// Helper that returns the full BandwidthState (not just summary) so we can check remote_cpu.
|
||||
async fn run_client_with_state(
|
||||
host: &str, port: u16, transmit: bool, receive: bool, udp: bool,
|
||||
secs: u64,
|
||||
) -> std::sync::Arc<btest_rs::bandwidth::BandwidthState> {
|
||||
let direction = match (transmit, 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,
|
||||
_ => panic!("must specify direction"),
|
||||
};
|
||||
let state = btest_rs::bandwidth::BandwidthState::new();
|
||||
let state_clone = state.clone();
|
||||
let host = host.to_string();
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
btest_rs::client::run_client(
|
||||
&host, port, direction, udp,
|
||||
0, 0, None, None, false, state_clone,
|
||||
).await
|
||||
});
|
||||
|
||||
tokio::time::sleep(Duration::from_secs(secs)).await;
|
||||
state.running.store(false, Ordering::SeqCst);
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
handle.abort();
|
||||
|
||||
state
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_local_cpu_nonzero() {
|
||||
// CPU sampler should return > 0 on supported platforms after warming up
|
||||
btest_rs::cpu::start_sampler();
|
||||
std::thread::sleep(Duration::from_secs(2));
|
||||
let cpu = btest_rs::cpu::get();
|
||||
// On CI or idle machines, CPU may genuinely be 0, so just check it doesn't panic
|
||||
// and returns a value in range
|
||||
assert!(cpu <= 100, "CPU should be 0-100, got {}", cpu);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_tcp_remote_cpu_both() {
|
||||
let port = BASE_PORT + 20;
|
||||
start_server_noauth(port).await;
|
||||
let state = run_client_with_state("127.0.0.1", port, true, true, false, 3).await;
|
||||
let remote_cpu = state.remote_cpu.load(Ordering::Relaxed);
|
||||
// On loopback with bidirectional traffic, server CPU should be > 0
|
||||
// The status messages are interleaved in the TCP data stream
|
||||
assert!(remote_cpu > 0, "TCP BOTH: remote CPU should be > 0 on loopback, got {}", remote_cpu);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_tcp_remote_cpu_tx_only() {
|
||||
let port = BASE_PORT + 21;
|
||||
start_server_noauth(port).await;
|
||||
let state = run_client_with_state("127.0.0.1", port, true, false, false, 3).await;
|
||||
let remote_cpu = state.remote_cpu.load(Ordering::Relaxed);
|
||||
// TX-only: server sends status messages that the status reader should parse
|
||||
assert!(remote_cpu > 0, "TCP TX-only: remote CPU should be > 0 on loopback, got {}", remote_cpu);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user