4 Commits

Author SHA1 Message Date
Siavash Sameni
27c69d8982 Fix unused variable warning in test
Some checks failed
Build & Release / release (push) Has been cancelled
CI / test (push) Successful in 2m35s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 08:40:40 +04:00
Siavash Sameni
2cb8519c95 Suppress non_snake_case warning for Win32 FILETIME struct
Some checks failed
CI / test (push) Failing after 1m40s
Build & Release / release (push) Successful in 4m46s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 08:34:21 +04:00
Siavash Sameni
9ca124cb76 Fix CPU reporting: Android support, TCP remote CPU parsing
All checks were successful
CI / test (push) Successful in 2m33s
Build & Release / release (push) Successful in 5m11s
- Add target_os = "android" to CPU sampler (reads /proc/stat like Linux)
- Parse remote CPU from interleaved TCP status messages in BOTH mode
- Add dedicated status reader for TX-only mode (reads server's 12-byte
  status messages to get remote CPU and enable speed adaptation)
- Add 3 CPU integration tests: local CPU, TCP BOTH remote, TCP TX-only

Fixes: Android always showing cpu: 0%/0%, TCP remote CPU always 0%
on all platforms (btest-to-btest and btest-to-MikroTik).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 08:28:45 +04:00
Siavash Sameni
c06a4d0c9a Add public server links to README, fix dead_code warnings
All checks were successful
CI / test (push) Successful in 2m12s
- Add Free Public Servers section with US/EU endpoints and usage examples
- Add Server Pro section documenting the optional pro build
- Add Android/Termux to supported platforms and installation guide
- Gate pro-only public functions with #[cfg(feature = "pro")] to eliminate
  6 dead_code warnings in the standard build

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 19:57:18 +04:00
6 changed files with 170 additions and 3 deletions

View File

@@ -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**.

View File

@@ -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);
}

View File

@@ -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,11 +195,53 @@ 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 ---
async fn run_udp_test_client(

View File

@@ -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",

View File

@@ -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,

View File

@@ -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);
}