Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0a48092ed | ||
|
|
27354108fc | ||
|
|
24f634170d | ||
|
|
10dd0c3835 | ||
|
|
5bb224cb3b | ||
|
|
68eb0c7f96 | ||
|
|
23db39a84e | ||
|
|
d19ad25a3c | ||
|
|
5b07a079fe | ||
|
|
949c4908ad | ||
|
|
751a9d5f13 | ||
|
|
ce01d514b2 | ||
|
|
7bc54a977c | ||
|
|
a925a7778d | ||
|
|
a28fc1dc08 | ||
|
|
29643e7589 | ||
|
|
0c14e6cf5b | ||
|
|
b8fa6d4580 | ||
|
|
6288fe9f25 | ||
|
|
50c0ba528d | ||
|
|
4e3b2939ca | ||
|
|
6ba57864a0 | ||
|
|
a1dbc6dc5a | ||
|
|
7be6a0d541 | ||
|
|
ba0a8f1b7c | ||
|
|
176cdae239 | ||
|
|
0385d2e745 | ||
|
|
7bbb7c9d9b | ||
|
|
2dec6cc007 | ||
|
|
f9289cca55 | ||
|
|
8b127d833f | ||
|
|
cdad23ffa0 | ||
|
|
51bc4ddf16 | ||
|
|
fa4fd63fb3 | ||
|
|
d8f3b9c189 | ||
|
|
9552cbef1a | ||
|
|
6c82228dd1 | ||
|
|
a87dd7510f | ||
|
|
b28c553e10 | ||
|
|
58274da859 | ||
|
|
8fe4e72bb3 | ||
|
|
9853d74c4a | ||
|
|
1659f10d62 | ||
|
|
3dfd0185e5 | ||
|
|
28e553bc5f | ||
|
|
4dddf21f2f | ||
|
|
1be3cb82dc | ||
|
|
f1f597d308 | ||
|
|
091222fbd4 |
@@ -1,3 +1,4 @@
|
||||
GITEA_USER=your_gitea_username
|
||||
GITEA_TOKEN=your_gitea_api_token_here
|
||||
GITEA_URL=https://git.manko.yoga
|
||||
REPO=manawenuz/btest-rs
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -3,3 +3,7 @@
|
||||
btest_original
|
||||
.claude/
|
||||
.env
|
||||
proto-test/venv/
|
||||
**/__pycache__/
|
||||
results.csv
|
||||
server_results.csv
|
||||
|
||||
3
.gitmodules
vendored
3
.gitmodules
vendored
@@ -1,3 +0,0 @@
|
||||
[submodule "btest-opensource"]
|
||||
path = btest-opensource
|
||||
url = https://github.com/samm-git/btest-opensource
|
||||
122
Cargo.lock
generated
122
Cargo.lock
generated
@@ -67,6 +67,12 @@ version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.11.0"
|
||||
@@ -82,15 +88,29 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "btest-rs"
|
||||
version = "0.1.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
"clap",
|
||||
"hostname",
|
||||
"md-5",
|
||||
"num-bigint",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
"rand",
|
||||
"sha2",
|
||||
"socket2 0.5.10",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
@@ -156,6 +176,21 @@ version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
@@ -166,14 +201,34 @@ dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
"block-buffer 0.10.4",
|
||||
"crypto-common 0.1.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.11.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c"
|
||||
dependencies = [
|
||||
"block-buffer 0.12.0",
|
||||
"const-oid",
|
||||
"crypto-common 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -213,6 +268,26 @@ version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hostname"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hybrid-array"
|
||||
version = "0.4.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a79f2aff40c18ab8615ddc5caa9eb5b96314aef18fe5823090f204ad988e813"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
@@ -262,7 +337,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"digest",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -291,6 +366,34 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
@@ -421,6 +524,17 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest 0.11.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sharded-slab"
|
||||
version = "0.1.7"
|
||||
|
||||
11
Cargo.toml
11
Cargo.toml
@@ -1,9 +1,9 @@
|
||||
[package]
|
||||
name = "btest-rs"
|
||||
version = "0.1.0"
|
||||
version = "0.6.0"
|
||||
edition = "2021"
|
||||
description = "MikroTik Bandwidth Test (btest) server and client — a Rust reimplementation"
|
||||
license = "MIT"
|
||||
description = "MikroTik Bandwidth Test (btest) server and client with EC-SRP5 auth — a Rust reimplementation"
|
||||
license = "MIT AND Apache-2.0"
|
||||
repository = "https://github.com/samm-git/btest-opensource"
|
||||
keywords = ["mikrotik", "bandwidth", "btest", "network", "benchmarking"]
|
||||
categories = ["command-line-utilities", "network-programming"]
|
||||
@@ -27,6 +27,11 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
rand = "0.8"
|
||||
socket2 = "0.5"
|
||||
anyhow = "1.0.102"
|
||||
num-bigint = "0.4.6"
|
||||
num-traits = "0.2.19"
|
||||
num-integer = "0.1.46"
|
||||
sha2 = "0.11.0"
|
||||
hostname = "0.4.2"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
|
||||
18
Dockerfile.static
Normal file
18
Dockerfile.static
Normal file
@@ -0,0 +1,18 @@
|
||||
# Minimal image from a pre-built static binary
|
||||
# Usage: docker build -f Dockerfile.static --build-arg BINARY=dist/btest .
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ARG BINARY=dist/btest
|
||||
COPY ${BINARY} /usr/local/bin/btest
|
||||
RUN chmod +x /usr/local/bin/btest
|
||||
|
||||
EXPOSE 2000/tcp
|
||||
EXPOSE 2001-2100/udp
|
||||
EXPOSE 2257-2356/udp
|
||||
|
||||
ENTRYPOINT ["btest"]
|
||||
CMD ["-s"]
|
||||
125
KNOWN_ISSUES.md
Normal file
125
KNOWN_ISSUES.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# Known Issues
|
||||
|
||||
This document tracks known limitations, bugs, and platform-specific issues in btest-rs. If you encounter an issue not listed here, please report it at: **https://git.manko.yoga/manawenuz/btest-rs/issues**
|
||||
|
||||
## IPv6 UDP on macOS (Server Mode)
|
||||
|
||||
**Severity:** High
|
||||
**Affects:** macOS only (server mode, UDP, IPv6)
|
||||
**Status:** Open
|
||||
|
||||
When running as a server on macOS and a MikroTik client connects over IPv6 UDP, the server's UDP transmit hits `ENOBUFS` (error 55 — "No buffer space available") repeatedly. This causes:
|
||||
|
||||
- Direction "receive" (server TX): intermittent packet bursts with gaps, MikroTik shows unstable or low speed
|
||||
- Direction "send" (server RX): works, but speed drops over time due to MikroTik's speed adaptation receiving irregular status feedback
|
||||
- Direction "both": TX side severely degraded
|
||||
|
||||
**Root cause:** macOS kernel returns `ENOBUFS` on IPv6 `send_to()` much more aggressively than IPv4 due to smaller interface output queues and per-packet NDP overhead. Connected sockets (`send()`) perform better than unconnected (`send_to()`), but still hit limits under high throughput.
|
||||
|
||||
**Workaround:** Use IPv4 for UDP tests on macOS, or deploy the server on Linux where IPv6 UDP works correctly.
|
||||
|
||||
**Not affected:**
|
||||
- IPv4 UDP (all directions, all platforms)
|
||||
- IPv6 TCP (all directions, all platforms)
|
||||
- Client mode over IPv6 (connecting TO a MikroTik server works fine at 600+ Mbps)
|
||||
|
||||
## IPv6 UDP — Not Tested on Linux
|
||||
|
||||
**Severity:** Unknown
|
||||
**Affects:** Linux server, IPv6, UDP
|
||||
**Status:** Untested
|
||||
|
||||
IPv6 UDP in server mode has not been thoroughly tested on Linux. The macOS ENOBUFS issue is kernel-specific and likely does not exist on Linux (which has much better IPv6 UDP buffer management). Testing and reports welcome.
|
||||
|
||||
## macOS UDP Send Buffer Saturation
|
||||
|
||||
**Severity:** Medium
|
||||
**Affects:** macOS (client and server, IPv4 and IPv6, UDP)
|
||||
**Status:** Mitigated
|
||||
|
||||
On macOS, when sending UDP at unlimited speed, the kernel buffer fills quickly and returns `ENOBUFS`. The adaptive backoff mechanism (200μs → 10ms) mitigates this, but the first few seconds of a test may show:
|
||||
|
||||
- Interval 1: high burst (40-300 Mbps depending on conditions)
|
||||
- Interval 2: 0 bps (buffer full, backoff in effect)
|
||||
- Interval 3+: gradually recovers to steady state
|
||||
|
||||
This causes the first 2-3 seconds of UDP tests to be unreliable on macOS. On Linux, this issue does not occur.
|
||||
|
||||
**Workaround:** Ignore the first few seconds of results, or use TCP mode which does not have this issue.
|
||||
|
||||
## Windows Binaries Not Tested
|
||||
|
||||
**Severity:** Unknown
|
||||
**Affects:** Windows x86_64
|
||||
**Status:** Untested
|
||||
|
||||
Windows binaries are cross-compiled from Linux using `gcc-mingw-w64` in CI. They have never been tested on actual Windows systems. Issues may include:
|
||||
|
||||
- Socket behavior differences (Winsock vs BSD sockets)
|
||||
- IPv6 dual-stack handling
|
||||
- Path separator issues in CSV output
|
||||
- Console output encoding
|
||||
|
||||
**Help wanted:** If you test on Windows, please report your findings.
|
||||
|
||||
## EC-SRP5 Server Authentication — Occasional Failure
|
||||
|
||||
**Severity:** Low
|
||||
**Affects:** Server mode with `--ecsrp5`
|
||||
**Status:** Mostly fixed
|
||||
|
||||
EC-SRP5 server authentication occasionally fails with "client proof mismatch". This was largely fixed by storing the correct gamma parity from key derivation, but edge cases may still exist with certain salt/password combinations due to the Curve25519 Weierstrass arithmetic.
|
||||
|
||||
**Workaround:** Retry the connection. If it fails consistently, restart the server (which regenerates the salt).
|
||||
|
||||
## MikroTik Speed Adaptation Staircase (Server RX, UDP)
|
||||
|
||||
**Severity:** Low
|
||||
**Affects:** Server mode, UDP, direction "send" (MikroTik sends to us)
|
||||
**Status:** MikroTik client behavior
|
||||
|
||||
When MikroTik connects as a client and sends data (direction "send"), the speed may gradually decrease in a staircase pattern over 30-60 seconds. This is caused by MikroTik's client-side speed adaptation algorithm, not by our server.
|
||||
|
||||
The original C btest-opensource server exhibits the same behavior. Single-connection mode (`connection-count=1`) provides the best results.
|
||||
|
||||
## TCP Multi-Connection Bandwidth Reporting
|
||||
|
||||
**Severity:** Low
|
||||
**Affects:** Server mode, TCP, `connection-count > 1`
|
||||
**Status:** Open
|
||||
|
||||
With TCP multi-connection, the server correctly handles all connections and data flows, but bandwidth is only measured on the primary connection's status loop. MikroTik may show lower-than-actual speeds because status messages are not distributed across all connections.
|
||||
|
||||
## Bandwidth Limit (`-b`) Not Fully Effective
|
||||
|
||||
**Severity:** Low
|
||||
**Affects:** Client mode, `-b` flag
|
||||
**Status:** Open
|
||||
|
||||
The `-b` bandwidth limit flag does not reliably cap speed. The `calc_send_interval` function computes the inter-packet delay correctly, but tokio's timer resolution and task scheduling can cause actual throughput to exceed the specified limit, especially for high bandwidth values.
|
||||
|
||||
---
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
Found a bug or unexpected behavior? Please report it:
|
||||
|
||||
- **Issue tracker:** https://git.manko.yoga/manawenuz/btest-rs/issues
|
||||
- **Include:** OS/platform, btest-rs version (`btest --version`), MikroTik RouterOS version, protocol (TCP/UDP), direction, connection count, and the full command line used.
|
||||
- **Packet captures:** If possible, attach a tcpdump/pcap capture. Use: `sudo tcpdump -i <interface> -w capture.pcap -s 200 'host <mikrotik_ip> and (port 2000 or portrange 2001-2356)'`
|
||||
- **Debug logs:** Run with `-vv` to get hex-level status exchange dumps.
|
||||
|
||||
## Platform Test Matrix
|
||||
|
||||
| Platform | TCP4 | UDP4 | TCP6 | UDP6 | Notes |
|
||||
|----------|------|------|------|------|-------|
|
||||
| macOS (ARM64) | Pass | Pass* | Pass | Fail** | *UDP send buffer saturation on first seconds |
|
||||
| macOS (x86_64) | Untested | Untested | Untested | Untested | |
|
||||
| Linux (x86_64) | Pass | Pass | Pass | Untested | Deployed on Ubuntu 24.04 |
|
||||
| Linux (aarch64) | Untested | Untested | Untested | Untested | RPi builds available |
|
||||
| Linux (armv7) | Untested | Untested | Untested | Untested | RPi builds available |
|
||||
| Windows (x86_64) | Untested | Untested | Untested | Untested | Cross-compiled, never tested |
|
||||
|
||||
**Pass** = verified against MikroTik RouterOS 7.x
|
||||
**Fail** = known issue documented above
|
||||
**Untested** = builds available but not verified
|
||||
13
LICENSE
13
LICENSE
@@ -3,7 +3,11 @@ MIT License
|
||||
Copyright (c) 2026 btest-rs contributors
|
||||
|
||||
Based on btest-opensource by Alex Samorukov (https://github.com/samm-git/btest-opensource)
|
||||
Original work Copyright (c) 2016 Alex Samorukov
|
||||
Original work Copyright (c) 2016 Alex Samorukov (MIT License)
|
||||
|
||||
EC-SRP5 authentication based on research by Margin Research
|
||||
(https://github.com/MarginResearch/mikrotik_authentication)
|
||||
Original work Copyright (c) 2022 Margin Research (Apache License 2.0)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
@@ -22,3 +26,10 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
---
|
||||
|
||||
NOTICE: This project includes code derived from works under the Apache License 2.0.
|
||||
The EC-SRP5 elliptic curve implementation is based on MarginResearch/mikrotik_authentication.
|
||||
See https://github.com/MarginResearch/mikrotik_authentication/blob/master/LICENSE
|
||||
for the full Apache 2.0 license text.
|
||||
|
||||
179
README.md
179
README.md
@@ -1,20 +1,23 @@
|
||||
# btest-rs
|
||||
|
||||
A Rust reimplementation of the [MikroTik Bandwidth Test (btest)](https://wiki.mikrotik.com/wiki/Manual:Tools/Bandwidth_Test) protocol. Both server and client modes, compatible with MikroTik RouterOS devices.
|
||||
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.
|
||||
|
||||
## Based on
|
||||
## Features
|
||||
|
||||
This project is a clean-room Rust reimplementation based on the protocol reverse-engineering work done by **Alex Samorukov** in [btest-opensource](https://github.com/samm-git/btest-opensource). The original C implementation and protocol documentation were invaluable in making this project possible. Full credit to Alex and all contributors to that project.
|
||||
|
||||
The original `btest-opensource` project is included as a git submodule for reference and protocol documentation.
|
||||
|
||||
## Why Rust?
|
||||
|
||||
- **Single static binary** - 2 MB, zero dependencies, runs anywhere
|
||||
- **Cross-platform** - macOS, Linux (x86_64, ARM64), Docker
|
||||
- **Async I/O** - tokio-based, handles many concurrent connections efficiently
|
||||
- **Memory safe** - no buffer overflows, no use-after-free, no data races
|
||||
- **Easy deployment** - `scp` one file, done. Or use the systemd installer.
|
||||
- **Full protocol support** -- TCP and UDP data transfer, IPv4 and IPv6
|
||||
- **EC-SRP5 authentication** -- modern RouterOS >= 6.43 Curve25519-based auth (server and client)
|
||||
- **MD5 authentication** -- legacy RouterOS < 6.43 challenge-response auth
|
||||
- **Multi-connection support** -- handles MikroTik's multi-connection UDP mode
|
||||
- **Bidirectional testing** -- simultaneous upload and download
|
||||
- **Syslog logging** -- send structured events (auth, test start/end) to a remote syslog server
|
||||
- **CSV output** -- append machine-readable test results to a CSV file
|
||||
- **CPU usage monitoring** -- local and remote CPU shown per interval, warning at >70%
|
||||
- **Timed tests** -- `--duration` flag to automatically stop after N seconds
|
||||
- **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
|
||||
- **Async I/O** -- tokio-based, handles many concurrent connections efficiently
|
||||
|
||||
## Performance
|
||||
|
||||
@@ -29,53 +32,65 @@ Tested over WiFi 6E (MikroTik RouterOS <-> macOS):
|
||||
| Client TCP bidirectional | TCP | **264/264 Mbps** |
|
||||
| Server bidirectional | UDP | **280/393 Mbps** |
|
||||
|
||||
On wired gigabit links, expect line-rate performance in both TCP and UDP modes.
|
||||
|
||||
## Installation
|
||||
|
||||
### Pre-built binary
|
||||
|
||||
```bash
|
||||
# Build for Linux x86_64 from macOS (requires Docker)
|
||||
scripts/build-linux.sh
|
||||
|
||||
# Copy to server
|
||||
scp dist/btest root@yourserver:/usr/local/bin/btest
|
||||
```
|
||||
|
||||
### From source
|
||||
|
||||
```bash
|
||||
cargo install --path .
|
||||
```
|
||||
|
||||
### Pre-built binary (Linux x86_64)
|
||||
|
||||
```bash
|
||||
# Cross-compile from macOS (requires Docker)
|
||||
scripts/build-linux.sh
|
||||
|
||||
# Copy to server
|
||||
scp dist/btest root@yourserver:/usr/local/bin/btest
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
docker compose up -d # Server on port 2000
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
See [docs/docker.md](docs/docker.md) for full Docker and deployment options.
|
||||
|
||||
### systemd service
|
||||
|
||||
```bash
|
||||
# On the target Linux server:
|
||||
sudo ./scripts/install-service.sh
|
||||
sudo ./scripts/install-service.sh --auth-user admin --auth-pass secret
|
||||
sudo ./scripts/install-service.sh --auth-user admin --auth-pass secret --port 2000
|
||||
```
|
||||
|
||||
## Usage
|
||||
The installer creates a dedicated `btest` system user, installs a hardened systemd unit, and enables the service.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Server mode
|
||||
|
||||
MikroTik devices connect to this server to run bandwidth tests.
|
||||
|
||||
```bash
|
||||
# Basic server (no auth)
|
||||
# No authentication
|
||||
btest -s
|
||||
|
||||
# With authentication
|
||||
# MD5 authentication (legacy RouterOS)
|
||||
btest -s -a admin -p password
|
||||
|
||||
# Custom port with verbose logging
|
||||
btest -s -P 2000 -v
|
||||
# EC-SRP5 authentication (RouterOS >= 6.43)
|
||||
btest -s -a admin -p password --ecsrp5
|
||||
|
||||
# Custom port, verbose logging
|
||||
btest -s -P 3000 -v
|
||||
|
||||
# With syslog and CSV logging
|
||||
btest -s -a admin -p password --syslog 192.168.1.1:514 --csv /var/log/btest.csv
|
||||
```
|
||||
|
||||
### Client mode
|
||||
@@ -89,24 +104,62 @@ btest -c 192.168.88.1 -r
|
||||
# TCP upload test
|
||||
btest -c 192.168.88.1 -t
|
||||
|
||||
# Bidirectional
|
||||
# Bidirectional TCP
|
||||
btest -c 192.168.88.1 -t -r
|
||||
|
||||
# UDP with bandwidth limit
|
||||
# UDP download with bandwidth limit
|
||||
btest -c 192.168.88.1 -r -u -b 100M
|
||||
|
||||
# With authentication
|
||||
btest -c 192.168.88.1 -r -a admin -p password
|
||||
|
||||
# Timed test (30 seconds), results to CSV
|
||||
btest -c 192.168.88.1 -r -d 30 --csv results.csv
|
||||
|
||||
# Quiet mode (no terminal output)
|
||||
btest -c 192.168.88.1 -r -d 10 --csv results.csv -q
|
||||
|
||||
# UDP through NAT
|
||||
btest -c 192.168.88.1 -r -u -n
|
||||
```
|
||||
|
||||
### Debug logging
|
||||
|
||||
```bash
|
||||
btest -s -v # info + debug
|
||||
btest -s -vv # info + debug + trace (hex dumps of status exchange)
|
||||
btest -s -v # debug messages
|
||||
btest -s -vv # trace messages (hex dumps of status exchange)
|
||||
btest -s -vvv # maximum verbosity
|
||||
```
|
||||
|
||||
## MikroTik Setup
|
||||
## CLI Reference
|
||||
|
||||
```
|
||||
Usage: btest [OPTIONS]
|
||||
|
||||
Options:
|
||||
-s, --server Run in server mode
|
||||
-c, --client <HOST> Run in client mode, connect to HOST
|
||||
-t, --transmit Client transmits data (upload test)
|
||||
-r, --receive Client receives data (download test)
|
||||
-u, --udp Use UDP instead of TCP
|
||||
-b, --bandwidth <BW> Target bandwidth limit (e.g., 100M, 1G, 500K)
|
||||
-P, --port <PORT> Listen/connect port [default: 2000]
|
||||
--listen <ADDR> IPv4 listen address [default: 0.0.0.0] (use "none" to disable)
|
||||
--listen6 [<ADDR>] Enable IPv6 listener [default: ::] (experimental)
|
||||
-a, --authuser <USER> Authentication username
|
||||
-p, --authpass <PASS> Authentication password
|
||||
--ecsrp5 Use EC-SRP5 authentication (RouterOS >= 6.43)
|
||||
-n, --nat NAT traversal mode (send UDP probe packet)
|
||||
-d, --duration <SECS> Test duration in seconds (client mode, 0=unlimited) [default: 0]
|
||||
--csv <FILE> Output results to CSV file (appends if file exists)
|
||||
-q, --quiet Suppress terminal output (use with --csv)
|
||||
--syslog <HOST:PORT> Send logs to remote syslog server (UDP, RFC 3164)
|
||||
-v, --verbose Increase log verbosity (-v, -vv, -vvv)
|
||||
-h, --help Show help
|
||||
-V, --version Show version
|
||||
```
|
||||
|
||||
## MikroTik Configuration
|
||||
|
||||
### Enable btest server on MikroTik (for client mode)
|
||||
|
||||
@@ -117,23 +170,54 @@ btest -s -vv # info + debug + trace (hex dumps of status exchange)
|
||||
### Run btest from MikroTik (connecting to our server)
|
||||
|
||||
```
|
||||
/tool/bandwidth-test address=<server-ip> direction=both protocol=udp user=admin password=password
|
||||
/tool/bandwidth-test address=<server-ip> direction=both protocol=udp \
|
||||
user=admin password=password
|
||||
```
|
||||
|
||||
## Protocol
|
||||
|
||||
The MikroTik btest protocol uses:
|
||||
- **TCP port 2000** for control (handshake, auth, status exchange)
|
||||
- **UDP ports 2001+** for data transfer
|
||||
- **MD5 challenge-response** authentication (RouterOS < 6.43)
|
||||
|
||||
- **TCP port 2000** for control (handshake, authentication, status exchange)
|
||||
- **UDP ports 2001+** for data transfer (server side)
|
||||
- **UDP ports 2257+** for data transfer (client side, offset +256)
|
||||
- **MD5 double-hash challenge-response** authentication (RouterOS < 6.43)
|
||||
- **EC-SRP5 Curve25519 Weierstrass** authentication (RouterOS >= 6.43)
|
||||
- **1-second status interval** with dynamic speed adjustment
|
||||
|
||||
See the [original protocol documentation](btest-opensource/README.md) for wire-format details.
|
||||
See [docs/protocol.md](docs/protocol.md) for the full wire-format specification.
|
||||
|
||||
## Known Limitations
|
||||
## Authentication
|
||||
|
||||
- **EC-SRP5 authentication** (RouterOS >= 6.43) is not yet supported for client mode. Server mode works fine with MD5 auth. Disable auth on the MikroTik btest server as a workaround.
|
||||
- **Multi-connection mode** (`Connection Count > 1` on MikroTik client) causes MikroTik's per-connection speed adaptation to throttle each stream independently, resulting in lower aggregate throughput. Use 1 connection for best results.
|
||||
Both legacy and modern MikroTik authentication schemes are supported:
|
||||
|
||||
| Scheme | RouterOS Version | Flag |
|
||||
|--------|-----------------|------|
|
||||
| None | Any | (no flags) |
|
||||
| MD5 challenge-response | < 6.43 | `-a USER -p PASS` |
|
||||
| EC-SRP5 (Curve25519) | >= 6.43 | `-a USER -p PASS --ecsrp5` |
|
||||
|
||||
In server mode, `--ecsrp5` advertises EC-SRP5 to connecting clients. Without it, MD5 is advertised. In client mode, the authentication type is auto-detected from the server's response.
|
||||
|
||||
## Known Issues
|
||||
|
||||
See [KNOWN_ISSUES.md](KNOWN_ISSUES.md) for the full list including:
|
||||
|
||||
- **IPv6 UDP on macOS** — server TX hits ENOBUFS, use IPv4 or deploy on Linux
|
||||
- **macOS UDP send buffer** — first 2-3 seconds unreliable on unlimited speed tests
|
||||
- **Windows binaries** — cross-compiled but untested
|
||||
- **IPv6 UDP on Linux** — untested, likely works fine
|
||||
|
||||
Contributions and bug reports welcome: https://git.manko.yoga/manawenuz/btest-rs/issues
|
||||
|
||||
## Documentation
|
||||
|
||||
- [User Guide](docs/user-guide.md) -- complete CLI reference with examples for every mode
|
||||
- [Architecture](docs/architecture.md) -- module structure, threading model, design decisions
|
||||
- [Protocol Specification](docs/protocol.md) -- wire format, authentication, status exchange
|
||||
- [Docker & Deployment](docs/docker.md) -- Docker, Docker Compose, systemd, firewall rules
|
||||
- [EC-SRP5 Research](docs/ecsrp5-research.md) -- reverse-engineering notes and cryptographic details
|
||||
- [Man Page](docs/man/btest.1) -- Unix manual page (install to `/usr/share/man/man1/`)
|
||||
|
||||
## Testing
|
||||
|
||||
@@ -146,11 +230,12 @@ scripts/test-docker.sh # Docker container test
|
||||
|
||||
## 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 that made this project possible. Licensed under MIT.
|
||||
- **MikroTik** - Creator of the bandwidth test protocol and RouterOS.
|
||||
- **[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**.
|
||||
- **[Margin Research](https://github.com/MarginResearch/mikrotik_authentication)** -- EC-SRP5 authentication reverse-engineering (Curve25519 Weierstrass, SRP key exchange). Licensed under **Apache 2.0**.
|
||||
- **MikroTik** -- creator of the bandwidth test protocol and RouterOS.
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see [LICENSE](LICENSE).
|
||||
MIT License -- see [LICENSE](LICENSE).
|
||||
|
||||
This project is derived from [btest-opensource](https://github.com/samm-git/btest-opensource) (MIT License, Copyright 2016 Alex Samorukov). The original license and copyright notice are preserved as required.
|
||||
This project is derived from [btest-opensource](https://github.com/samm-git/btest-opensource) (MIT License, Copyright 2016 Alex Samorukov). The EC-SRP5 implementation is based on research by [Margin Research](https://github.com/MarginResearch/mikrotik_authentication) (Apache License 2.0). Original license and copyright notices are preserved as required.
|
||||
|
||||
Submodule btest-opensource deleted from 5040a01267
96
deploy/syslog-ng-btest.conf
Normal file
96
deploy/syslog-ng-btest.conf
Normal file
@@ -0,0 +1,96 @@
|
||||
# btest-rs syslog configuration for syslog-ng
|
||||
# Add this to your syslog-ng.conf or include from conf.d/
|
||||
#
|
||||
# Copy to: /var/data/syslogng/config/conf.d/btest.conf
|
||||
# Or append to your main syslog-ng.conf
|
||||
#
|
||||
# Note: uses message-based matching (not program()) because
|
||||
# MikroTik sources use flags(no-parse) which skips program extraction.
|
||||
|
||||
# Filter for btest-rs messages
|
||||
filter f_btest {
|
||||
match("btest-rs:" value("MESSAGE"));
|
||||
};
|
||||
|
||||
# Filter subcategories
|
||||
filter f_btest_auth {
|
||||
match("btest-rs:" value("MESSAGE")) and (
|
||||
match("AUTH_SUCCESS" value("MESSAGE")) or
|
||||
match("AUTH_FAILURE" value("MESSAGE"))
|
||||
);
|
||||
};
|
||||
|
||||
filter f_btest_test {
|
||||
match("btest-rs:" value("MESSAGE")) and (
|
||||
match("TEST_START" value("MESSAGE")) or
|
||||
match("TEST_END" value("MESSAGE")) or
|
||||
match("TEST_RESULT" value("MESSAGE"))
|
||||
);
|
||||
};
|
||||
|
||||
# All btest logs
|
||||
destination d_btest_all {
|
||||
file(
|
||||
"/var/log/remote/btest/all.log"
|
||||
create_dirs(yes)
|
||||
dir_perm(0755)
|
||||
perm(0644)
|
||||
template(t_mikrotik_format)
|
||||
);
|
||||
};
|
||||
|
||||
# Auth events (successes + failures)
|
||||
destination d_btest_auth {
|
||||
file(
|
||||
"/var/log/remote/btest/auth.log"
|
||||
create_dirs(yes)
|
||||
dir_perm(0755)
|
||||
perm(0644)
|
||||
template(t_mikrotik_format)
|
||||
);
|
||||
};
|
||||
|
||||
# Test events (start/stop/results)
|
||||
destination d_btest_tests {
|
||||
file(
|
||||
"/var/log/remote/btest/tests.log"
|
||||
create_dirs(yes)
|
||||
dir_perm(0755)
|
||||
perm(0644)
|
||||
template(t_mikrotik_format)
|
||||
);
|
||||
};
|
||||
|
||||
# Per-day logs
|
||||
destination d_btest_daily {
|
||||
file(
|
||||
"/var/log/remote/btest/${YEAR}-${MONTH}-${DAY}.log"
|
||||
create_dirs(yes)
|
||||
dir_perm(0755)
|
||||
perm(0644)
|
||||
template(t_mikrotik_format)
|
||||
);
|
||||
};
|
||||
|
||||
# Log paths
|
||||
log {
|
||||
source(s_network_udp);
|
||||
source(s_network_tcp);
|
||||
filter(f_btest);
|
||||
destination(d_btest_all);
|
||||
destination(d_btest_daily);
|
||||
};
|
||||
|
||||
log {
|
||||
source(s_network_udp);
|
||||
source(s_network_tcp);
|
||||
filter(f_btest_auth);
|
||||
destination(d_btest_auth);
|
||||
};
|
||||
|
||||
log {
|
||||
source(s_network_udp);
|
||||
source(s_network_tcp);
|
||||
filter(f_btest_test);
|
||||
destination(d_btest_tests);
|
||||
};
|
||||
@@ -13,22 +13,31 @@ graph TB
|
||||
client["client.rs<br/>Client mode"]
|
||||
protocol["protocol.rs<br/>Wire protocol types"]
|
||||
auth["auth.rs<br/>MD5 authentication"]
|
||||
ecsrp5["ecsrp5.rs<br/>EC-SRP5 authentication<br/>(Curve25519 Weierstrass)"]
|
||||
bandwidth["bandwidth.rs<br/>Rate control & reporting"]
|
||||
csv_output["csv_output.rs<br/>CSV result logging"]
|
||||
syslog["syslog_logger.rs<br/>Remote syslog (RFC 3164)"]
|
||||
lib["lib.rs<br/>Public API for tests"]
|
||||
|
||||
main --> server
|
||||
main --> client
|
||||
main --> bandwidth
|
||||
main --> csv_output
|
||||
main --> syslog
|
||||
server --> protocol
|
||||
server --> auth
|
||||
server --> ecsrp5
|
||||
server --> bandwidth
|
||||
server --> syslog
|
||||
client --> protocol
|
||||
client --> auth
|
||||
client --> ecsrp5
|
||||
client --> bandwidth
|
||||
lib --> server
|
||||
lib --> client
|
||||
lib --> protocol
|
||||
lib --> auth
|
||||
lib --> ecsrp5
|
||||
lib --> bandwidth
|
||||
```
|
||||
|
||||
@@ -50,12 +59,20 @@ sequenceDiagram
|
||||
|
||||
alt No auth configured
|
||||
SRV->>TCP: AUTH_OK [01 00 00 00]
|
||||
else MD5 auth
|
||||
else MD5 auth (RouterOS < 6.43)
|
||||
SRV->>TCP: AUTH_REQUIRED [02 00 00 00]
|
||||
SRV->>TCP: Challenge [16 random bytes]
|
||||
MK->>TCP: Response [16 hash + 32 username]
|
||||
Note over SRV: Verify MD5(pass + MD5(pass + challenge))
|
||||
SRV->>TCP: AUTH_OK or AUTH_FAILED
|
||||
else EC-SRP5 auth (RouterOS >= 6.43, --ecsrp5 flag)
|
||||
SRV->>TCP: EC-SRP5 [03 00 00 00]
|
||||
MK->>TCP: [len][username\0][client_pubkey:32][parity:1]
|
||||
SRV->>TCP: [len][server_pubkey:32][parity:1][salt:16]
|
||||
MK->>TCP: [len][client_confirmation:32]
|
||||
SRV->>TCP: [len][server_confirmation:32]
|
||||
Note over SRV: Curve25519 Weierstrass EC-SRP5<br/>See docs/ecsrp5-research.md
|
||||
SRV->>TCP: AUTH_OK [01 00 00 00]
|
||||
end
|
||||
|
||||
alt TCP mode
|
||||
@@ -97,14 +114,18 @@ sequenceDiagram
|
||||
CLI->>TCP: Command [16 bytes]
|
||||
Note over CLI: direction bits tell server<br/>what to do (TX/RX/BOTH)
|
||||
|
||||
alt Auth response 01
|
||||
alt Auth response 01 (no auth)
|
||||
Note over CLI: No auth, proceed
|
||||
else Auth response 02 (MD5)
|
||||
MK->>TCP: Challenge
|
||||
CLI->>TCP: MD5 response
|
||||
MK->>TCP: Challenge [16 random bytes]
|
||||
CLI->>TCP: MD5 response [48 bytes]
|
||||
MK->>TCP: AUTH_OK
|
||||
else Auth response 03 (EC-SRP5)
|
||||
Note over CLI: Not supported yet
|
||||
CLI->>TCP: [len][username\0][client_pubkey:32][parity:1]
|
||||
MK->>TCP: [len][server_pubkey:32][parity:1][salt:16]
|
||||
CLI->>TCP: [len][client_confirmation:32]
|
||||
MK->>TCP: [len][server_confirmation:32]
|
||||
MK->>TCP: AUTH_OK
|
||||
end
|
||||
|
||||
Note over CLI,MK: Data transfer begins<br/>(TCP or UDP, same as server)
|
||||
@@ -148,56 +169,115 @@ graph TB
|
||||
## Key Design Decisions
|
||||
|
||||
### 1. Tokio async runtime
|
||||
|
||||
All I/O is async via tokio. Each client connection spawns independent tasks for TX, RX, and status exchange. This allows handling hundreds of concurrent connections on a single thread pool.
|
||||
|
||||
### 2. Lock-free shared state
|
||||
TX/RX threads and the status loop share bandwidth counters via `AtomicU64`. No mutexes needed — `swap(0)` atomically reads and resets counters each interval.
|
||||
|
||||
TX/RX threads and the status loop share bandwidth counters via `AtomicU64`. No mutexes needed -- `swap(0)` atomically reads and resets counters each interval.
|
||||
|
||||
### 3. Sequential status loop (matching C pselect)
|
||||
|
||||
The UDP status exchange uses a sequential timeout-read-then-send pattern rather than `tokio::select!`. This ensures our status messages are sent exactly every 1 second, preventing MikroTik's speed adaptation from seeing irregular feedback.
|
||||
|
||||
### 4. Direction bits from server perspective
|
||||
|
||||
The direction byte in the protocol means what the **server** should do:
|
||||
- `0x01` (CMD_DIR_RX) = server receives
|
||||
- `0x02` (CMD_DIR_TX) = server transmits
|
||||
- `0x03` (CMD_DIR_BOTH) = bidirectional
|
||||
|
||||
The client inverts before sending: client "transmit" → `CMD_DIR_RX` (telling server to receive).
|
||||
The client inverts before sending: client "transmit" sends `CMD_DIR_RX` (telling server to receive).
|
||||
|
||||
### 5. TCP socket half keepalive
|
||||
|
||||
When only one direction is active (e.g., TX only), the unused socket half is kept alive. Dropping `OwnedWriteHalf` sends a TCP FIN, which MikroTik interprets as disconnection.
|
||||
|
||||
### 6. Static musl binary
|
||||
Release builds use musl for a fully static binary with zero runtime dependencies. The binary is 2 MB and runs on any Linux.
|
||||
|
||||
Release builds use musl for a fully static binary with zero runtime dependencies. The binary is approximately 2 MB and runs on any Linux distribution.
|
||||
|
||||
### 7. EC-SRP5 with big integer arithmetic
|
||||
|
||||
The EC-SRP5 implementation uses `num-bigint` for Curve25519 Weierstrass-form elliptic curve arithmetic. MikroTik's authentication uses the Weierstrass form (not the more common Montgomery or Edwards forms), requiring direct field arithmetic over the prime `2^255 - 19`. The implementation includes point multiplication, `lift_x`, `redp1` (hash-to-curve), and Montgomery coordinate conversion.
|
||||
|
||||
### 8. Global singletons for syslog and CSV
|
||||
|
||||
The syslog and CSV modules use `Mutex<Option<...>>` global statics. This avoids threading state through every function call while remaining safe. Both modules are initialized once at startup and used from any async task via their public API functions.
|
||||
|
||||
### 9. Shared BandwidthState for client duration timeout
|
||||
|
||||
When running with `--duration`, the tokio timeout cancels the client future. To preserve stats accumulated during the test, `BandwidthState` is created in `main()` and passed as an `Arc` into `run_client()`. The state survives cancellation because `main()` holds a reference. The `record_interval()` method accumulates totals that `summary()` returns.
|
||||
|
||||
### 10. IPv6 socket handling
|
||||
|
||||
IPv6 requires special handling on macOS:
|
||||
- UDP sockets bind to `[::]` for IPv6 peers, `0.0.0.0` for IPv4
|
||||
- Socket send/receive buffers set to 4MB via `socket2` before wrapping with tokio
|
||||
- `SocketAddr::new()` used instead of string formatting (avoids `[addr]:port` parsing issues)
|
||||
- Connected sockets preferred for single-connection (avoids ENOBUFS on `send_to()`)
|
||||
- NDP probe packet sent before data blast to populate neighbor cache
|
||||
- Adaptive backoff on ENOBUFS (200μs→10ms, resets on success)
|
||||
|
||||
### 11. CPU usage monitoring
|
||||
|
||||
A background OS thread samples system CPU every 1 second via:
|
||||
- **macOS:** `host_statistics(HOST_CPU_LOAD_INFO)` — returns user/system/idle/nice ticks
|
||||
- **Linux:** `/proc/stat` — reads aggregate CPU line
|
||||
|
||||
The percentage is stored in a global `AtomicU8` and included in every status message at byte 1 using MikroTik's encoding: `0x80 | percentage`. On receive, the remote CPU is decoded with `byte & 0x7F` and capped at 100%. Both local and remote CPU are displayed per interval and logged to CSV/syslog.
|
||||
|
||||
## File Layout
|
||||
|
||||
```
|
||||
btest-rs/
|
||||
├── src/
|
||||
│ ├── main.rs # CLI entry point, argument parsing
|
||||
│ ├── lib.rs # Public API (used by integration tests)
|
||||
│ ├── protocol.rs # Wire format: Command, StatusMessage, constants
|
||||
│ ├── auth.rs # MD5 challenge-response authentication
|
||||
│ ├── server.rs # Server mode: listener, TCP/UDP handlers
|
||||
│ ├── client.rs # Client mode: connector, TCP/UDP handlers
|
||||
│ └── bandwidth.rs # Rate limiting, formatting, shared state
|
||||
│ ├── main.rs # CLI entry point, argument parsing (clap)
|
||||
│ ├── lib.rs # Public API (used by integration tests)
|
||||
│ ├── protocol.rs # Wire format: Command, StatusMessage, constants
|
||||
│ ├── auth.rs # MD5 challenge-response authentication
|
||||
│ ├── ecsrp5.rs # EC-SRP5 authentication (Curve25519 Weierstrass)
|
||||
│ ├── server.rs # Server mode: listener, TCP/UDP handlers
|
||||
│ ├── client.rs # Client mode: connector, TCP/UDP handlers
|
||||
│ ├── bandwidth.rs # Rate limiting, formatting, shared state
|
||||
│ ├── cpu.rs # CPU usage sampler (macOS + Linux)
|
||||
│ ├── csv_output.rs # CSV result logging (append-mode, auto-header)
|
||||
│ └── syslog_logger.rs # Remote syslog sender (RFC 3164 / BSD format)
|
||||
├── tests/
|
||||
│ └── integration_test.rs # End-to-end server/client tests
|
||||
├── scripts/
|
||||
│ ├── build-linux.sh # Cross-compile for x86_64 Linux
|
||||
│ ├── install-service.sh # systemd service installer
|
||||
│ ├── test-local.sh # Loopback self-test
|
||||
│ ├── test-mikrotik.sh # Test against MikroTik device
|
||||
│ └── test-docker.sh # Docker container test
|
||||
│ ├── build-linux.sh # Cross-compile for x86_64 Linux (musl)
|
||||
│ ├── build-macos-release.sh # macOS release build
|
||||
│ ├── install-service.sh # systemd service installer
|
||||
│ ├── push-docker.sh # Push Docker image to registry
|
||||
│ ├── test-local.sh # Loopback self-test
|
||||
│ ├── test-mikrotik.sh # Test against MikroTik device
|
||||
│ ├── test-docker.sh # Docker container test
|
||||
│ └── debug-capture.sh # Packet capture for debugging
|
||||
├── docs/
|
||||
│ ├── architecture.md # This file
|
||||
│ ├── protocol.md # Protocol specification
|
||||
│ ├── user-guide.md # Usage documentation
|
||||
│ └── docker.md # Docker & deployment guide
|
||||
├── Dockerfile # Production Docker image
|
||||
├── Dockerfile.cross # Cross-compilation for Linux x86_64
|
||||
├── docker-compose.yml # Docker Compose configuration
|
||||
├── Cargo.toml
|
||||
└── btest-opensource/ # Original C implementation (git submodule)
|
||||
│ ├── architecture.md # This file
|
||||
│ ├── protocol.md # Protocol specification
|
||||
│ ├── user-guide.md # Usage documentation
|
||||
│ ├── docker.md # Docker & deployment guide
|
||||
│ ├── ecsrp5-research.md # EC-SRP5 reverse-engineering notes
|
||||
│ └── man/
|
||||
│ └── btest.1 # Unix manual page (troff format)
|
||||
├── tests/
|
||||
│ ├── integration_test.rs # Basic server/client handshake tests
|
||||
│ ├── ecsrp5_test.rs # EC-SRP5 authentication tests
|
||||
│ └── full_integration_test.rs # Comprehensive: all protocols, IPv4/6, CSV, syslog
|
||||
├── deploy/
|
||||
│ └── syslog-ng-btest.conf # syslog-ng configuration for btest events
|
||||
├── proto-test/ # Python EC-SRP5 prototype (research branch)
|
||||
│ ├── btest_ecsrp5_client.py # Working Python btest EC-SRP5 client
|
||||
│ ├── btest_mitm.py # MITM proxy for protocol analysis
|
||||
│ └── elliptic_curves.py # Curve25519 Weierstrass (MarginResearch)
|
||||
├── KNOWN_ISSUES.md # Known bugs and platform limitations
|
||||
├── Dockerfile # Production Docker image (multi-stage)
|
||||
├── Dockerfile.cross # Cross-compilation for Linux x86_64
|
||||
├── docker-compose.yml # Docker Compose configuration
|
||||
├── Cargo.toml # Rust package manifest
|
||||
├── Cargo.lock # Dependency lock file
|
||||
├── LICENSE # MIT License
|
||||
└── btest-opensource/ # Original C implementation (git submodule)
|
||||
```
|
||||
|
||||
154
docs/docker.md
154
docs/docker.md
@@ -1,26 +1,42 @@
|
||||
# Docker & Deployment Guide
|
||||
# Docker and Deployment Guide
|
||||
|
||||
## Container Registry
|
||||
|
||||
Images are published to:
|
||||
|
||||
```
|
||||
git.manko.yoga/manawenuz/btest-rs
|
||||
```
|
||||
|
||||
## Quick Run (Ephemeral)
|
||||
## Quick Start
|
||||
|
||||
### Server (one-liner)
|
||||
### Docker Compose (recommended)
|
||||
|
||||
```bash
|
||||
# Server with no authentication
|
||||
docker compose up -d
|
||||
|
||||
# Server with authentication
|
||||
docker compose --profile auth up -d
|
||||
|
||||
# View logs
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
### One-liner server
|
||||
|
||||
```bash
|
||||
# Build and run server directly
|
||||
docker build -t btest-rs . && \
|
||||
docker run --rm -it \
|
||||
-p 2000:2000/tcp \
|
||||
-p 2001-2100:2001-2100/udp \
|
||||
-p 2257-2356:2257-2356/udp \
|
||||
btest-rs -s -v
|
||||
```
|
||||
|
||||
# With authentication
|
||||
### One-liner server with authentication
|
||||
|
||||
```bash
|
||||
docker run --rm -it \
|
||||
-p 2000:2000/tcp \
|
||||
-p 2001-2100:2001-2100/udp \
|
||||
@@ -28,7 +44,28 @@ docker run --rm -it \
|
||||
btest-rs -s -a admin -p password -v
|
||||
```
|
||||
|
||||
### Client (one-liner)
|
||||
### Server with EC-SRP5 authentication
|
||||
|
||||
```bash
|
||||
docker run --rm -it \
|
||||
-p 2000:2000/tcp \
|
||||
-p 2001-2100:2001-2100/udp \
|
||||
-p 2257-2356:2257-2356/udp \
|
||||
btest-rs -s -a admin -p password --ecsrp5 -v
|
||||
```
|
||||
|
||||
### Server with syslog and CSV
|
||||
|
||||
```bash
|
||||
docker run --rm -it \
|
||||
-p 2000:2000/tcp \
|
||||
-p 2001-2100:2001-2100/udp \
|
||||
-p 2257-2356:2257-2356/udp \
|
||||
-v /var/log/btest:/data \
|
||||
btest-rs -s -a admin -p password --syslog 192.168.1.1:514 --csv /data/results.csv -v
|
||||
```
|
||||
|
||||
### Client mode
|
||||
|
||||
```bash
|
||||
# TCP download test against MikroTik
|
||||
@@ -36,6 +73,14 @@ docker run --rm -it btest-rs -c 192.168.88.1 -r
|
||||
|
||||
# UDP bidirectional
|
||||
docker run --rm -it btest-rs -c 192.168.88.1 -t -r -u
|
||||
|
||||
# Timed test with CSV output
|
||||
docker run --rm -it \
|
||||
-v $(pwd):/data \
|
||||
btest-rs -c 192.168.88.1 -r -d 30 --csv /data/results.csv
|
||||
|
||||
# With authentication
|
||||
docker run --rm -it btest-rs -c 192.168.88.1 -r -a admin -p password
|
||||
```
|
||||
|
||||
### Using pre-built image from registry
|
||||
@@ -54,18 +99,24 @@ docker run --rm -it \
|
||||
|
||||
## Docker Compose
|
||||
|
||||
### Basic server
|
||||
The `docker-compose.yml` file provides two service profiles:
|
||||
|
||||
### Default profile (no auth)
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Server with authentication
|
||||
Starts a server on port 2000 with verbose logging and no authentication.
|
||||
|
||||
### Auth profile
|
||||
|
||||
```bash
|
||||
docker compose --profile auth up -d
|
||||
```
|
||||
|
||||
Starts an additional server on port 2010 with MD5 authentication (user: admin, password: password).
|
||||
|
||||
### docker-compose.yml
|
||||
|
||||
```yaml
|
||||
@@ -94,7 +145,23 @@ services:
|
||||
- auth
|
||||
```
|
||||
|
||||
## Building
|
||||
## Dockerfile
|
||||
|
||||
The production Dockerfile uses a multi-stage build:
|
||||
|
||||
1. **Build stage** -- Rust 1.86 slim image, compiles a release binary
|
||||
2. **Runtime stage** -- Debian Bookworm slim, copies only the binary
|
||||
|
||||
The resulting image is approximately 80 MB. The binary itself is about 2 MB.
|
||||
|
||||
Exposed ports:
|
||||
- `2000/tcp` -- control channel
|
||||
- `2001-2100/udp` -- server-side data ports
|
||||
- `2257-2356/udp` -- client-side data ports
|
||||
|
||||
Default entrypoint: `btest -s`
|
||||
|
||||
## Building Images
|
||||
|
||||
### Local build (native)
|
||||
|
||||
@@ -107,24 +174,23 @@ cargo build --release
|
||||
|
||||
```bash
|
||||
scripts/build-linux.sh
|
||||
# Binary at: dist/btest (static musl, 2 MB)
|
||||
# Binary at: dist/btest (static musl, ~2 MB)
|
||||
```
|
||||
|
||||
### Docker image build
|
||||
|
||||
```bash
|
||||
# Production image (for running)
|
||||
# Production image
|
||||
docker build -t btest-rs .
|
||||
|
||||
# With custom tag
|
||||
docker build -t git.manko.yoga/manawenuz/btest-rs:latest .
|
||||
docker build -t git.manko.yoga/manawenuz/btest-rs:0.1.0 .
|
||||
docker build -t git.manko.yoga/manawenuz/btest-rs:0.5.0 .
|
||||
```
|
||||
|
||||
### Multi-platform build
|
||||
|
||||
```bash
|
||||
# Build for both ARM64 and x86_64
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
-t git.manko.yoga/manawenuz/btest-rs:latest \
|
||||
@@ -143,13 +209,13 @@ docker push git.manko.yoga/manawenuz/btest-rs:latest
|
||||
|
||||
# Also tag with version
|
||||
docker tag git.manko.yoga/manawenuz/btest-rs:latest \
|
||||
git.manko.yoga/manawenuz/btest-rs:0.1.0
|
||||
docker push git.manko.yoga/manawenuz/btest-rs:0.1.0
|
||||
git.manko.yoga/manawenuz/btest-rs:0.5.0
|
||||
docker push git.manko.yoga/manawenuz/btest-rs:0.5.0
|
||||
```
|
||||
|
||||
## Deployment on Linux Server
|
||||
## Deployment Options
|
||||
|
||||
### Option 1: Docker
|
||||
### Option 1: Docker (single container)
|
||||
|
||||
```bash
|
||||
docker run -d --name btest-server \
|
||||
@@ -158,7 +224,7 @@ docker run -d --name btest-server \
|
||||
-p 2001-2100:2001-2100/udp \
|
||||
-p 2257-2356:2257-2356/udp \
|
||||
git.manko.yoga/manawenuz/btest-rs:latest \
|
||||
-s -a admin -p password -v
|
||||
-s -a admin -p password --ecsrp5 -v
|
||||
```
|
||||
|
||||
### Option 2: Static binary + systemd
|
||||
@@ -167,11 +233,28 @@ docker run -d --name btest-server \
|
||||
# Copy binary to server
|
||||
scp dist/btest root@server:/usr/local/bin/btest
|
||||
|
||||
# Copy and run installer
|
||||
# Run the installer
|
||||
scp scripts/install-service.sh root@server:/tmp/
|
||||
ssh root@server "bash /tmp/install-service.sh --auth-user admin --auth-pass password"
|
||||
```
|
||||
|
||||
The installer script:
|
||||
- Creates a dedicated `btest` system user
|
||||
- Installs a hardened systemd unit with security options (NoNewPrivileges, ProtectSystem, PrivateTmp)
|
||||
- Grants `CAP_NET_BIND_SERVICE` for binding to ports below 1024
|
||||
- Enables and starts the service
|
||||
- Supports `--auth-user`, `--auth-pass`, and `--port` options
|
||||
|
||||
Useful systemd commands after installation:
|
||||
|
||||
```bash
|
||||
systemctl status btest # Check status
|
||||
systemctl stop btest # Stop the service
|
||||
systemctl restart btest # Restart
|
||||
journalctl -u btest -f # Follow logs
|
||||
systemctl disable btest # Disable autostart
|
||||
```
|
||||
|
||||
### Option 3: Docker Compose on server
|
||||
|
||||
```bash
|
||||
@@ -183,9 +266,9 @@ ssh root@server "cd /opt/btest-rs && docker compose up -d"
|
||||
|
||||
| Port | Protocol | Purpose |
|
||||
|------|----------|---------|
|
||||
| 2000 | TCP | Control channel (handshake, auth, status) |
|
||||
| 2000 | TCP | Control channel (handshake, auth, status exchange) |
|
||||
| 2001-2100 | UDP | Server-side data ports |
|
||||
| 2257-2356 | UDP | Client-side data ports (2001+256) |
|
||||
| 2257-2356 | UDP | Client-side data ports (server_port + 256) |
|
||||
|
||||
### Firewall rules (iptables)
|
||||
|
||||
@@ -203,20 +286,35 @@ ufw allow 2001:2100/udp
|
||||
ufw allow 2257:2356/udp
|
||||
```
|
||||
|
||||
### Firewall rules (nftables)
|
||||
|
||||
```bash
|
||||
nft add rule inet filter input tcp dport 2000 accept
|
||||
nft add rule inet filter input udp dport 2001-2100 accept
|
||||
nft add rule inet filter input udp dport 2257-2356 accept
|
||||
```
|
||||
|
||||
## Health Check
|
||||
|
||||
```bash
|
||||
# Check if server is responding
|
||||
# Check if server is responding (TCP handshake)
|
||||
nc -zv <server-ip> 2000
|
||||
|
||||
# Check Docker container
|
||||
# Check Docker container status
|
||||
docker logs btest-server
|
||||
docker exec btest-server ps aux
|
||||
docker ps --filter name=btest-server
|
||||
|
||||
# Check systemd service
|
||||
systemctl status btest
|
||||
journalctl -u btest --since "5 minutes ago"
|
||||
```
|
||||
|
||||
## Resource Usage
|
||||
|
||||
- **Memory**: ~5 MB base, +1 MB per active connection
|
||||
- **CPU**: Minimal when idle, scales with bandwidth
|
||||
- **Binary size**: 2 MB (static musl build)
|
||||
- **Docker image**: ~80 MB (Debian slim + binary)
|
||||
| Resource | Value |
|
||||
|----------|-------|
|
||||
| Memory (idle) | ~5 MB |
|
||||
| Memory (per active connection) | +1 MB |
|
||||
| CPU | Minimal when idle, scales with bandwidth |
|
||||
| Binary size | ~2 MB (static musl build) |
|
||||
| Docker image | ~80 MB (Debian slim + binary) |
|
||||
|
||||
238
docs/ecsrp5-research.md
Normal file
238
docs/ecsrp5-research.md
Normal file
@@ -0,0 +1,238 @@
|
||||
# EC-SRP5 Authentication Research
|
||||
|
||||
## Summary
|
||||
|
||||
MikroTik RouterOS >= 6.43 uses EC-SRP5 (Elliptic Curve Secure Remote Password) for authentication. When the btest server has auth enabled, it responds with `03 00 00 00` instead of `02 00 00 00` (legacy MD5).
|
||||
|
||||
**Status: Fully reverse-engineered and verified.** Python prototype authenticates successfully against MikroTik RouterOS 7.x btest server.
|
||||
|
||||
## Discovery Process
|
||||
|
||||
### Step 1: Initial Capture
|
||||
|
||||
Connected our client to MikroTik btest server with auth enabled. Server responded with `03 00 00 00` and waited for the client to initiate.
|
||||
|
||||
### Step 2: Winbox EC-SRP5 Verification
|
||||
|
||||
Tested the EC-SRP5 crypto implementation (from [MarginResearch/mikrotik_authentication](https://github.com/MarginResearch/mikrotik_authentication)) against MikroTik's Winbox port (8291). **Authentication succeeded**, confirming the elliptic curve math is correct.
|
||||
|
||||
### Step 3: Framing Discovery via MITM
|
||||
|
||||
The Winbox `[len][0x06][payload]` framing was rejected by the btest port. To discover the correct framing, we built a MITM proxy (`proto-test/btest_mitm.py`) and routed a MikroTik client through it to the MikroTik server.
|
||||
|
||||
**Finding: btest uses `[len][payload]` — identical to Winbox but without the `0x06` handler byte.**
|
||||
|
||||
### Step 4: Successful Authentication
|
||||
|
||||
Updated the Python prototype to use `[len][payload]` framing. EC-SRP5 authentication against MikroTik's btest server succeeded and data transfer began.
|
||||
|
||||
## Protocol Specification
|
||||
|
||||
### Auth Trigger
|
||||
|
||||
After the standard btest handshake (HELLO + Command), the server responds:
|
||||
|
||||
```
|
||||
01 00 00 00 → No auth required
|
||||
02 00 00 00 → MD5 challenge-response (RouterOS < 6.43)
|
||||
03 00 00 00 → EC-SRP5 (RouterOS >= 6.43)
|
||||
```
|
||||
|
||||
### EC-SRP5 Handshake (4 messages after `03 00 00 00`)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Client
|
||||
participant S as Server
|
||||
|
||||
Note over S: Server sent 03 00 00 00
|
||||
|
||||
C->>S: MSG1: [len][username\0][client_pubkey:32][parity:1]
|
||||
Note over C: len = 1 byte, total = len + 1 bytes
|
||||
|
||||
S->>C: MSG2: [len][server_pubkey:32][parity:1][salt:16]
|
||||
Note over S: len = 49 (0x31)
|
||||
|
||||
C->>S: MSG3: [len][client_confirmation:32]
|
||||
Note over C: len = 32 (0x20)
|
||||
|
||||
S->>C: MSG4: [len][server_confirmation:32]
|
||||
Note over S: len = 32 (0x20)
|
||||
|
||||
Note over S: Then continues with normal btest flow:
|
||||
S->>C: AUTH_OK [01 00 00 00]
|
||||
S->>C: UDP port [2 bytes BE] (if UDP mode)
|
||||
```
|
||||
|
||||
### Framing Comparison
|
||||
|
||||
| Protocol | Message framing |
|
||||
|----------|----------------|
|
||||
| Winbox (port 8291) | `[len:1][0x06][payload]` |
|
||||
| **btest (port 2000)** | **`[len:1][payload]`** |
|
||||
| MAC Telnet (UDP 20561) | Control packets with magic bytes |
|
||||
|
||||
The `0x06` handler byte in Winbox identifies the message as an auth message. Btest omits it since the auth context is implicit after `03 00 00 00`.
|
||||
|
||||
### Captured Exchange (from MITM)
|
||||
|
||||
```
|
||||
CLIENT → SERVER (40 bytes):
|
||||
27 61 6e 74 61 72 00 38 8a 37 36 52 6a 32 e9 87 'antar.8.76Rj2..
|
||||
4e 92 f8 c3 aa a1 18 da cd 71 b6 ab 76 fd 72 aa N........q..v.r.
|
||||
c3 f6 6a 43 9b c8 a1 01 ..jC....
|
||||
|
||||
Decoded:
|
||||
len=0x27 (39 bytes payload)
|
||||
username="antar\0"
|
||||
pubkey=388a373652...c8a1 (32 bytes)
|
||||
parity=0x01
|
||||
|
||||
SERVER → CLIENT (50 bytes):
|
||||
31 6c c9 e3 1a 79 43 4a 40 51 de fd 55 cc 8d 6d 1l...yCJ@Q..U..m
|
||||
3c ec cd 73 19 1f a6 83 15 94 62 52 97 fe 5d 89 <..s......bR..].
|
||||
1a 00 3c ec 65 b8 34 28 0a 16 c5 48 0d 7b 50 00 ..<.e.4(...H.{P.
|
||||
e3 80 ..
|
||||
|
||||
Decoded:
|
||||
len=0x31 (49 bytes payload)
|
||||
server_pubkey=6cc9e31a...5d891a (32 bytes)
|
||||
parity=0x00
|
||||
salt=3cec65b834280a16c5480d7b5000e380 (16 bytes)
|
||||
|
||||
CLIENT → SERVER (33 bytes):
|
||||
20 9b 1f 74 ec 40 31 2c ...
|
||||
|
||||
Decoded:
|
||||
len=0x20 (32 bytes payload)
|
||||
client_cc=9b1f74ec... (32 bytes, SHA256 proof)
|
||||
|
||||
SERVER → CLIENT (33 bytes):
|
||||
20 7d 59 b3 2e 28 6e 52 ...
|
||||
|
||||
Decoded:
|
||||
len=0x20 (32 bytes payload)
|
||||
server_cc=7d59b32e... (32 bytes, SHA256 proof)
|
||||
|
||||
POST-AUTH:
|
||||
01 00 00 00 07 fa
|
||||
|
||||
Decoded:
|
||||
AUTH_OK=01000000
|
||||
UDP_port=0x07fa (2042)
|
||||
```
|
||||
|
||||
## Cryptographic Details
|
||||
|
||||
### Elliptic Curve: Curve25519 in Weierstrass Form
|
||||
|
||||
```
|
||||
p = 2^255 - 19
|
||||
r = curve order (same as Ed25519)
|
||||
Montgomery A = 486662
|
||||
|
||||
Weierstrass conversion:
|
||||
a = 0x2aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa984914a144
|
||||
b = 0x7b425ed097b425ed097b425ed097b425ed097b425ed097b4260b5e9c7710c864
|
||||
|
||||
Generator: lift_x(9) in Montgomery, converted to Weierstrass
|
||||
Cofactor: 8
|
||||
```
|
||||
|
||||
All EC math is in Weierstrass form. Public keys are transmitted as Montgomery x-coordinates (32 bytes big-endian) plus a 1-byte y-parity flag.
|
||||
|
||||
### Key Derivation
|
||||
|
||||
```
|
||||
inner = SHA256(username + ":" + password)
|
||||
validator_priv (i) = SHA256(salt || inner)
|
||||
validator_pub (x_gamma) = i * G
|
||||
```
|
||||
|
||||
### Shared Secret Computation
|
||||
|
||||
**Client side (ECPESVDP-SRP-A):**
|
||||
```
|
||||
v = redp1(x_gamma, parity=1) # hash-to-curve of validator pubkey
|
||||
w_b = lift_x(server_pubkey) + v # undo verifier blinding
|
||||
j = SHA256(client_pubkey || server_pubkey)
|
||||
scalar = (i * j + s_a) mod r # combined scalar
|
||||
Z = scalar * w_b # shared secret point
|
||||
z = to_montgomery(Z).x # Montgomery x-coordinate
|
||||
```
|
||||
|
||||
**Server side (ECPESVDP-SRP-B):**
|
||||
```
|
||||
gamma = redp1(x_gamma, parity=0)
|
||||
w_a = lift_x(client_pubkey)
|
||||
Z = s_b * (w_a + j * gamma) # where j = SHA256(x_w_a || x_w_b)
|
||||
z = to_montgomery(Z).x
|
||||
```
|
||||
|
||||
### Confirmation Codes
|
||||
|
||||
```
|
||||
client_cc = SHA256(j || z)
|
||||
server_cc = SHA256(j || client_cc || z)
|
||||
```
|
||||
|
||||
Both sides verify the peer's confirmation code to ensure the shared secret matches.
|
||||
|
||||
### redp1 (Hash-to-Curve)
|
||||
|
||||
```python
|
||||
def redp1(x_bytes, parity):
|
||||
x = SHA256(x_bytes)
|
||||
while True:
|
||||
x2 = SHA256(x)
|
||||
point = lift_x(int(x2), parity)
|
||||
if point is valid:
|
||||
return point
|
||||
x = (int(x) + 1).to_bytes(32)
|
||||
```
|
||||
|
||||
## Implementation Plan for Rust
|
||||
|
||||
### Required Crates
|
||||
|
||||
| Crate | Purpose |
|
||||
|-------|---------|
|
||||
| `num-bigint` + `num-traits` | Big integer arithmetic for field operations |
|
||||
| `sha2` | SHA-256 |
|
||||
| `ecdsa` or custom | Curve25519 Weierstrass point operations |
|
||||
|
||||
**Note:** `curve25519-dalek` operates in Montgomery/Edwards form, not Weierstrass. We need Weierstrass arithmetic for compatibility with MikroTik's implementation. Options:
|
||||
1. Use `num-bigint` for direct field arithmetic (like the Python `ecdsa` library)
|
||||
2. Use the `p256` crate's infrastructure with custom curve parameters
|
||||
3. Port the Python `WCurve` class directly using big integers
|
||||
|
||||
### Implementation Steps
|
||||
|
||||
1. **Port `WCurve`** — Weierstrass curve with Curve25519 parameters, point multiplication, `lift_x`, `redp1`, Montgomery conversion
|
||||
2. **Port EC-SRP5 client** — generate keypair, compute shared secret, confirmation codes
|
||||
3. **Port EC-SRP5 server** — verify client proof, generate server proof (for our server mode)
|
||||
4. **Integrate into `auth.rs`** — handle `03 00 00 00` response with btest-specific `[len][payload]` framing
|
||||
5. **Server registration** — derive salt + validator from username/password for server-side verification
|
||||
|
||||
### Server-Side Specifics
|
||||
|
||||
When our server receives a client with EC-SRP5 support, we need to:
|
||||
1. Store `salt` and `x_gamma` (validator public key) per user — derived from username + password at startup
|
||||
2. Generate ephemeral server keypair
|
||||
3. Compute password-entangled public key: `W_b = s_b * G + redp1(x_gamma, 0)`
|
||||
4. Verify client's confirmation code
|
||||
5. Send server confirmation code
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `proto-test/elliptic_curves.py` | Curve25519 Weierstrass implementation |
|
||||
| `proto-test/btest_ecsrp5_client.py` | Working Python btest EC-SRP5 client |
|
||||
| `proto-test/btest_mitm.py` | MITM proxy for protocol analysis |
|
||||
|
||||
## Credits
|
||||
|
||||
- **[MarginResearch](https://github.com/MarginResearch/mikrotik_authentication)** — Reverse-engineered MikroTik's EC-SRP5 for Winbox/MAC Telnet
|
||||
- **[Margin Research blog](https://margin.re/2022/02/mikrotik-authentication-revealed/)** — Detailed write-up of MikroTik authentication
|
||||
- **btest framing discovery** — MITM analysis showing btest uses `[len][payload]` (no `0x06` handler byte)
|
||||
365
docs/man/btest.1
Normal file
365
docs/man/btest.1
Normal file
@@ -0,0 +1,365 @@
|
||||
.\" btest-rs manual page
|
||||
.\" Generated for btest-rs v0.6.0
|
||||
.TH BTEST 1 "2026-03-31" "btest-rs 0.5.0" "User Commands"
|
||||
.SH NAME
|
||||
btest \- MikroTik Bandwidth Test server and client
|
||||
.SH SYNOPSIS
|
||||
.B btest
|
||||
.B \-s
|
||||
.RI [ OPTIONS ]
|
||||
.br
|
||||
.B btest
|
||||
.B \-c
|
||||
.I HOST
|
||||
.RB { \-t | \-r }
|
||||
.RI [ OPTIONS ]
|
||||
.SH DESCRIPTION
|
||||
.B btest
|
||||
is a Rust reimplementation of the MikroTik Bandwidth Test (btest) protocol.
|
||||
It can operate as a server (accepting connections from MikroTik RouterOS
|
||||
devices or other btest clients) or as a client (connecting to a MikroTik
|
||||
device's built-in bandwidth test server).
|
||||
.PP
|
||||
The server listens on TCP port 2000 by default. MikroTik devices connect
|
||||
to this port for handshake, authentication, and status exchange. UDP data
|
||||
transfer uses ports 2001 and above.
|
||||
.PP
|
||||
Both MD5 challenge-response (RouterOS < 6.43) and EC-SRP5 Curve25519
|
||||
(RouterOS >= 6.43) authentication are supported.
|
||||
.SH OPTIONS
|
||||
.SS "Mode Selection"
|
||||
.TP
|
||||
.BR \-s ", " \-\-server
|
||||
Run in server mode. Listen for incoming connections from MikroTik devices
|
||||
or other btest clients. Conflicts with
|
||||
.BR \-c .
|
||||
.TP
|
||||
.BI \-c " HOST" "\fR, \fP" \-\-client " HOST"
|
||||
Run in client mode, connecting to the specified
|
||||
.IR HOST .
|
||||
The host can be an IPv4 address, IPv6 address, or hostname. Conflicts with
|
||||
.BR \-s .
|
||||
.SS "Test Direction (client mode)"
|
||||
.TP
|
||||
.BR \-t ", " \-\-transmit
|
||||
Client transmits data to the server (upload test). Can be combined with
|
||||
.B \-r
|
||||
for bidirectional testing.
|
||||
.TP
|
||||
.BR \-r ", " \-\-receive
|
||||
Client receives data from the server (download test). Can be combined with
|
||||
.B \-t
|
||||
for bidirectional testing.
|
||||
.SS "Protocol and Transfer"
|
||||
.TP
|
||||
.BR \-u ", " \-\-udp
|
||||
Use UDP instead of TCP for data transfer. UDP uses separate data ports
|
||||
(2001+ server side, 2257+ client side) and exchanges status messages
|
||||
over the TCP control channel every second.
|
||||
.TP
|
||||
.BI \-b " BW" "\fR, \fP" \-\-bandwidth " BW"
|
||||
Target bandwidth limit for the test. Accepts suffixes:
|
||||
.B K
|
||||
(kilobits/sec),
|
||||
.B M
|
||||
(megabits/sec),
|
||||
.B G
|
||||
(gigabits/sec). Examples:
|
||||
.BR 100M ", " 1G ", " 500K .
|
||||
Default is 0 (unlimited).
|
||||
.TP
|
||||
.BI \-P " PORT" "\fR, \fP" \-\-port " PORT"
|
||||
TCP port to listen on in server mode or connect to in client mode.
|
||||
Default: 2000.
|
||||
.SS "Network Binding (server mode)"
|
||||
.TP
|
||||
.BI \-\-listen " ADDR"
|
||||
IPv4 address to bind the server listener to. Use
|
||||
.B none
|
||||
to disable IPv4 listening entirely (useful with
|
||||
.B \-\-listen6
|
||||
for IPv6-only mode). Default: 0.0.0.0.
|
||||
.TP
|
||||
.BI \-\-listen6 " \fR[\fPADDR\fR]\fP"
|
||||
Enable the IPv6 listener. If no address is given, binds to
|
||||
.BR :: .
|
||||
Experimental: TCP over IPv6 works fully on all platforms. UDP over IPv6
|
||||
has issues on macOS due to kernel ENOBUFS limitations. On Linux, IPv6 UDP
|
||||
works correctly.
|
||||
.SS "Authentication"
|
||||
.TP
|
||||
.BI \-a " USER" "\fR, \fP" \-\-authuser " USER"
|
||||
Authentication username. In server mode, connecting clients must provide
|
||||
this username. In client mode, this username is sent to the server.
|
||||
.TP
|
||||
.BI \-p " PASS" "\fR, \fP" \-\-authpass " PASS"
|
||||
Authentication password. In server mode, connecting clients must provide
|
||||
a matching password. In client mode, this password is used to authenticate
|
||||
with the server.
|
||||
.TP
|
||||
.B \-\-ecsrp5
|
||||
Use EC-SRP5 authentication (Curve25519 Weierstrass). In server mode, this
|
||||
causes the server to advertise EC-SRP5 instead of MD5 to connecting clients.
|
||||
Required for RouterOS >= 6.43 devices. In client mode, the authentication
|
||||
type is auto-detected from the server's response and this flag is not needed.
|
||||
.SS "Test Control"
|
||||
.TP
|
||||
.BI \-d " SECS" "\fR, \fP" \-\-duration " SECS"
|
||||
Test duration in seconds (client mode only). The client exits cleanly after
|
||||
the specified number of seconds. A value of 0 means unlimited (run until
|
||||
interrupted with Ctrl-C). Default: 0.
|
||||
.TP
|
||||
.BR \-n ", " \-\-nat
|
||||
NAT traversal mode. Sends an empty UDP probe packet to the server before
|
||||
starting the receive thread, opening a hole in NAT firewalls. Only relevant
|
||||
for UDP receive tests when the client is behind NAT.
|
||||
.SS "Logging and Output"
|
||||
.TP
|
||||
.BI \-\-csv " FILE"
|
||||
Output test results to a CSV file. Appends a row for each completed test.
|
||||
Creates the file with a header row if it does not exist. Columns:
|
||||
timestamp, host, port, protocol, direction, duration_s, tx_avg_mbps,
|
||||
rx_avg_mbps, tx_bytes, rx_bytes, lost_packets, auth_type.
|
||||
.TP
|
||||
.BR \-q ", " \-\-quiet
|
||||
Suppress per-second bandwidth output to the terminal. Useful in combination
|
||||
with
|
||||
.B \-\-csv
|
||||
for machine-readable-only output, or when running as a background service.
|
||||
.TP
|
||||
.BI \-\-syslog " HOST:PORT"
|
||||
Send structured log events to a remote syslog server via UDP. Uses RFC 3164
|
||||
(BSD syslog) format with facility local0. Events include AUTH_SUCCESS,
|
||||
AUTH_FAILURE, TEST_START, and TEST_END with detailed metadata.
|
||||
Example:
|
||||
.BR \-\-syslog\ 192.168.1.1:514 .
|
||||
.TP
|
||||
.BR \-v ", " \-\-verbose
|
||||
Increase log verbosity. Can be repeated for more detail:
|
||||
.RS
|
||||
.TP
|
||||
.B \-v
|
||||
Debug messages (connection lifecycle, authentication steps).
|
||||
.TP
|
||||
.B \-vv
|
||||
Trace messages (hex dumps of protocol exchange).
|
||||
.TP
|
||||
.B \-vvv
|
||||
Maximum verbosity.
|
||||
.RE
|
||||
.TP
|
||||
.BR \-h ", " \-\-help
|
||||
Print help information and exit.
|
||||
.TP
|
||||
.BR \-V ", " \-\-version
|
||||
Print version information and exit.
|
||||
.SH EXAMPLES
|
||||
.SS "Server Mode"
|
||||
Start a basic server with no authentication:
|
||||
.PP
|
||||
.RS
|
||||
.nf
|
||||
btest -s
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
Server with MD5 authentication:
|
||||
.PP
|
||||
.RS
|
||||
.nf
|
||||
btest -s -a admin -p password
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
Server with EC-SRP5 authentication (RouterOS >= 6.43):
|
||||
.PP
|
||||
.RS
|
||||
.nf
|
||||
btest -s -a admin -p password --ecsrp5
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
Server with syslog and CSV logging:
|
||||
.PP
|
||||
.RS
|
||||
.nf
|
||||
btest -s -a admin -p password --syslog 10.0.0.1:514 --csv /var/log/btest.csv
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
Server listening on IPv4 and IPv6:
|
||||
.PP
|
||||
.RS
|
||||
.nf
|
||||
btest -s --listen6
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
Server on a custom port with debug output:
|
||||
.PP
|
||||
.RS
|
||||
.nf
|
||||
btest -s -P 3000 -v
|
||||
.fi
|
||||
.RE
|
||||
.SS "Client Mode"
|
||||
TCP download test:
|
||||
.PP
|
||||
.RS
|
||||
.nf
|
||||
btest -c 192.168.88.1 -r
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
TCP upload test:
|
||||
.PP
|
||||
.RS
|
||||
.nf
|
||||
btest -c 192.168.88.1 -t
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
Bidirectional TCP test:
|
||||
.PP
|
||||
.RS
|
||||
.nf
|
||||
btest -c 192.168.88.1 -t -r
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
UDP download test:
|
||||
.PP
|
||||
.RS
|
||||
.nf
|
||||
btest -c 192.168.88.1 -r -u
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
UDP bidirectional with bandwidth limit:
|
||||
.PP
|
||||
.RS
|
||||
.nf
|
||||
btest -c 192.168.88.1 -t -r -u -b 100M
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
Timed test (30 seconds) with CSV output:
|
||||
.PP
|
||||
.RS
|
||||
.nf
|
||||
btest -c 192.168.88.1 -r -d 30 --csv results.csv
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
Quiet mode with CSV only:
|
||||
.PP
|
||||
.RS
|
||||
.nf
|
||||
btest -c 192.168.88.1 -r -d 60 --csv results.csv -q
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
With authentication:
|
||||
.PP
|
||||
.RS
|
||||
.nf
|
||||
btest -c 192.168.88.1 -r -a admin -p password
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
UDP receive through NAT:
|
||||
.PP
|
||||
.RS
|
||||
.nf
|
||||
btest -c 192.168.88.1 -r -u -n
|
||||
.fi
|
||||
.RE
|
||||
.SH PORTS
|
||||
.TP
|
||||
.B 2000/tcp
|
||||
Control channel. Used for handshake, authentication, and status exchange.
|
||||
.TP
|
||||
.B 2001-2100/udp
|
||||
Server-side UDP data ports. Each connection uses the next available port
|
||||
starting from 2001.
|
||||
.TP
|
||||
.B 2257-2356/udp
|
||||
Client-side UDP data ports. Offset from server port by 256.
|
||||
.SH EXIT STATUS
|
||||
.TP
|
||||
.B 0
|
||||
Success. The test completed normally or the duration expired.
|
||||
.TP
|
||||
.B 1
|
||||
Error. Failed to connect, authentication failed, or invalid arguments.
|
||||
.SH ENVIRONMENT
|
||||
.TP
|
||||
.B RUST_LOG
|
||||
Override the log filter. When set, takes precedence over the
|
||||
.B \-v
|
||||
flag. Example:
|
||||
.BR RUST_LOG=trace .
|
||||
.SH FILES
|
||||
.TP
|
||||
.I /usr/local/bin/btest
|
||||
Default installation path for the binary.
|
||||
.TP
|
||||
.I /etc/systemd/system/btest.service
|
||||
systemd unit file created by the install-service.sh script.
|
||||
.SH AUTHENTICATION
|
||||
.B btest
|
||||
supports two authentication schemes:
|
||||
.TP
|
||||
.B MD5 (legacy)
|
||||
Double MD5 challenge-response. Compatible with RouterOS versions before 6.43.
|
||||
The server sends a 16-byte random challenge. The client responds with
|
||||
MD5(password + MD5(password + challenge)) and the username.
|
||||
.TP
|
||||
.B EC-SRP5 (modern)
|
||||
Elliptic Curve Secure Remote Password using Curve25519 in Weierstrass form.
|
||||
Used by RouterOS >= 6.43. Provides zero-knowledge password proof. Enable on
|
||||
the server with
|
||||
.BR \-\-ecsrp5 .
|
||||
Clients auto-detect the authentication type.
|
||||
.SH MIKROTIK CONFIGURATION
|
||||
Enable the bandwidth test server on MikroTik for client mode:
|
||||
.PP
|
||||
.RS
|
||||
.nf
|
||||
/tool/bandwidth-server set enabled=yes
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
Run a test from MikroTik connecting to a btest-rs server:
|
||||
.PP
|
||||
.RS
|
||||
.nf
|
||||
/tool/bandwidth-test address=<server-ip> direction=both \\
|
||||
protocol=udp user=admin password=password
|
||||
.fi
|
||||
.RE
|
||||
.SH SEE ALSO
|
||||
.BR iperf3 (1),
|
||||
.BR netperf (1)
|
||||
.PP
|
||||
Project documentation:
|
||||
.I https://github.com/samm-git/btest-opensource
|
||||
.SH CREDITS
|
||||
.B btest-opensource
|
||||
by Alex Samorukov \(em original C implementation and protocol
|
||||
reverse-engineering (MIT License).
|
||||
.PP
|
||||
.B Margin Research
|
||||
\(em EC-SRP5 authentication reverse-engineering for MikroTik RouterOS
|
||||
(Apache License 2.0).
|
||||
.PP
|
||||
.B MikroTik
|
||||
\(em creator of the bandwidth test protocol and RouterOS.
|
||||
.SH LICENSE
|
||||
MIT License. See the LICENSE file in the source distribution.
|
||||
.PP
|
||||
This project is derived from btest-opensource (MIT License, Copyright 2016
|
||||
Alex Samorukov). The EC-SRP5 implementation is based on research by Margin
|
||||
Research (Apache License 2.0).
|
||||
.SH AUTHORS
|
||||
btest-rs contributors.
|
||||
227
docs/protocol.md
227
docs/protocol.md
@@ -1,6 +1,6 @@
|
||||
# MikroTik Bandwidth Test Protocol Specification
|
||||
|
||||
This document describes the MikroTik btest wire protocol as reverse-engineered from RouterOS traffic captures. Based on the work of [Alex Samorukov](https://github.com/samm-git/btest-opensource).
|
||||
This document describes the MikroTik btest wire protocol as reverse-engineered from RouterOS traffic captures. Based on the work of [Alex Samorukov](https://github.com/samm-git/btest-opensource) and [Margin Research](https://github.com/MarginResearch/mikrotik_authentication).
|
||||
|
||||
## Connection Setup
|
||||
|
||||
@@ -24,7 +24,11 @@ sequenceDiagram
|
||||
S->>C: OK [01 00 00 00] or FAILED [00 00 00 00]
|
||||
else EC-SRP5 authentication (RouterOS >= 6.43)
|
||||
S->>C: EC_SRP5 [03 00 00 00]
|
||||
Note over C,S: Not yet implemented
|
||||
C->>S: MSG1 [len][username\0][client_pubkey:32][parity:1]
|
||||
S->>C: MSG2 [len][server_pubkey:32][parity:1][salt:16]
|
||||
C->>S: MSG3 [len][client_confirmation:32]
|
||||
S->>C: MSG4 [len][server_confirmation:32]
|
||||
S->>C: OK [01 00 00 00]
|
||||
end
|
||||
|
||||
Note over C,S: Data transfer begins
|
||||
@@ -32,11 +36,11 @@ sequenceDiagram
|
||||
|
||||
## Command Structure (16 bytes)
|
||||
|
||||
Sent by client after receiving HELLO.
|
||||
Sent by the client after receiving HELLO.
|
||||
|
||||
```
|
||||
Offset Size Type Field Description
|
||||
────── ──── ──── ───── ───────────
|
||||
------ ---- ---- ----- -----------
|
||||
0 1 uint8 protocol 0x00=UDP, 0x01=TCP
|
||||
1 1 uint8 direction Bit flags (server perspective)
|
||||
2 1 uint8 random_data 0x00=random, 0x01=zeros
|
||||
@@ -58,8 +62,8 @@ Direction bits describe what the **server** should do:
|
||||
| 0x03 | DIR_BOTH | Both directions | Both directions |
|
||||
|
||||
**Important**: The client inverts when constructing the command:
|
||||
- Client selects "transmit" → sends `0x01` (server should receive)
|
||||
- Client selects "receive" → sends `0x02` (server should transmit)
|
||||
- Client selects "transmit" -> sends `0x01` (server should receive)
|
||||
- Client selects "receive" -> sends `0x02` (server should transmit)
|
||||
|
||||
### Default TX Sizes
|
||||
|
||||
@@ -124,6 +128,184 @@ Challenge: ad32d6f94d28161625f2f390bb895637 (hex)
|
||||
Expected: 3c968565bc0314f281a6da1571cf7255 (hex)
|
||||
```
|
||||
|
||||
## EC-SRP5 Authentication
|
||||
|
||||
EC-SRP5 (Elliptic Curve Secure Remote Password) is used by RouterOS >= 6.43. It provides zero-knowledge password proof using Curve25519 in Weierstrass form.
|
||||
|
||||
### Auth Trigger
|
||||
|
||||
After the standard btest handshake (HELLO + Command), the server responds with one of:
|
||||
|
||||
```
|
||||
01 00 00 00 -> No auth required
|
||||
02 00 00 00 -> MD5 challenge-response (RouterOS < 6.43)
|
||||
03 00 00 00 -> EC-SRP5 (RouterOS >= 6.43)
|
||||
```
|
||||
|
||||
### Message Framing
|
||||
|
||||
Unlike Winbox (port 8291) which uses `[len:1][0x06][payload]`, the btest protocol uses a simpler framing:
|
||||
|
||||
```
|
||||
[len:1][payload]
|
||||
```
|
||||
|
||||
The `0x06` handler byte is omitted because the authentication context is implicit after receiving `03 00 00 00`.
|
||||
|
||||
| Protocol | Message framing |
|
||||
|----------|----------------|
|
||||
| Winbox (port 8291) | `[len:1][0x06][payload]` |
|
||||
| **btest (port 2000)** | **`[len:1][payload]`** |
|
||||
|
||||
### EC-SRP5 Handshake (4 messages)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Client
|
||||
participant S as Server
|
||||
|
||||
Note over S: Server sent 03 00 00 00
|
||||
|
||||
C->>S: MSG1: [len][username\0][client_pubkey:32][parity:1]
|
||||
Note over C: len = username_len + 1 + 32 + 1
|
||||
|
||||
S->>C: MSG2: [len][server_pubkey:32][parity:1][salt:16]
|
||||
Note over S: len = 49 (0x31)
|
||||
|
||||
C->>S: MSG3: [len][client_confirmation:32]
|
||||
Note over C: len = 32 (0x20)
|
||||
|
||||
S->>C: MSG4: [len][server_confirmation:32]
|
||||
Note over S: len = 32 (0x20)
|
||||
|
||||
Note over S: Then continues with normal btest flow:
|
||||
S->>C: AUTH_OK [01 00 00 00]
|
||||
S->>C: UDP port [2 bytes BE] (if UDP mode)
|
||||
```
|
||||
|
||||
### Elliptic Curve: Curve25519 in Weierstrass Form
|
||||
|
||||
MikroTik's EC-SRP5 uses Curve25519 parameters but operates entirely in Weierstrass form, not the more common Montgomery or Edwards representations.
|
||||
|
||||
```
|
||||
Prime field: p = 2^255 - 19
|
||||
Curve order: r = 2^252 + 27742317777372353535851937790883648493
|
||||
Montgomery A: 486662
|
||||
|
||||
Weierstrass parameters (converted from Montgomery):
|
||||
a = 0x2aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa984914a144
|
||||
b = 0x7b425ed097b425ed097b425ed097b425ed097b425ed097b4260b5e9c7710c864
|
||||
|
||||
Generator: lift_x(9) in Montgomery, converted to Weierstrass
|
||||
Cofactor: 8
|
||||
```
|
||||
|
||||
Public keys are transmitted as Montgomery x-coordinates (32 bytes big-endian) plus a 1-byte y-parity flag.
|
||||
|
||||
### Key Derivation
|
||||
|
||||
```
|
||||
inner = SHA256(username + ":" + password)
|
||||
salt = 16 random bytes (generated by server)
|
||||
validator_priv (i) = SHA256(salt || inner)
|
||||
validator_pub (x_gamma) = i * G
|
||||
```
|
||||
|
||||
The server stores `salt` and `x_gamma` (the validator public key) for each user. In btest-rs, these are derived from the username and password at startup.
|
||||
|
||||
### Shared Secret Computation
|
||||
|
||||
**Client side (ECPESVDP-SRP-A):**
|
||||
```
|
||||
v = redp1(x_gamma, parity=1) # hash-to-curve of validator pubkey
|
||||
w_b = lift_x(server_pubkey) + v # undo verifier blinding
|
||||
j = SHA256(client_pubkey || server_pubkey)
|
||||
scalar = (i * j + client_secret) mod r # combined scalar
|
||||
Z = scalar * w_b # shared secret point
|
||||
z = to_montgomery(Z).x # Montgomery x-coordinate
|
||||
```
|
||||
|
||||
**Server side (ECPESVDP-SRP-B):**
|
||||
```
|
||||
gamma = redp1(x_gamma, parity=0)
|
||||
w_a = lift_x(client_pubkey)
|
||||
j = SHA256(client_pubkey || server_pubkey)
|
||||
Z = server_secret * (w_a + j * gamma) # shared secret point
|
||||
z = to_montgomery(Z).x
|
||||
```
|
||||
|
||||
### Confirmation Codes
|
||||
|
||||
```
|
||||
client_cc = SHA256(j || z)
|
||||
server_cc = SHA256(j || client_cc || z)
|
||||
```
|
||||
|
||||
Both sides verify the peer's confirmation code to ensure the shared secret matches. If either code is wrong, authentication fails.
|
||||
|
||||
### redp1 (Hash-to-Curve)
|
||||
|
||||
```
|
||||
def redp1(x_bytes, parity):
|
||||
x = SHA256(x_bytes)
|
||||
while True:
|
||||
x2 = SHA256(x)
|
||||
point = lift_x(int(x2), parity)
|
||||
if point is valid:
|
||||
return point
|
||||
x = (int(x) + 1).to_bytes(32)
|
||||
```
|
||||
|
||||
This deterministically maps a byte string to a valid curve point by repeatedly hashing until a valid x-coordinate is found.
|
||||
|
||||
### Captured Exchange (from MITM analysis)
|
||||
|
||||
```
|
||||
CLIENT -> SERVER (40 bytes):
|
||||
27 61 6e 74 61 72 00 38 8a 37 36 52 6a 32 e9 87
|
||||
4e 92 f8 c3 aa a1 18 da cd 71 b6 ab 76 fd 72 aa
|
||||
c3 f6 6a 43 9b c8 a1 01
|
||||
|
||||
Decoded:
|
||||
len=0x27 (39 bytes payload)
|
||||
username="antar\0"
|
||||
pubkey=388a373652...c8a1 (32 bytes)
|
||||
parity=0x01
|
||||
|
||||
SERVER -> CLIENT (50 bytes):
|
||||
31 6c c9 e3 1a 79 43 4a 40 51 de fd 55 cc 8d 6d
|
||||
3c ec cd 73 19 1f a6 83 15 94 62 52 97 fe 5d 89
|
||||
1a 00 3c ec 65 b8 34 28 0a 16 c5 48 0d 7b 50 00
|
||||
e3 80
|
||||
|
||||
Decoded:
|
||||
len=0x31 (49 bytes payload)
|
||||
server_pubkey=6cc9e31a...5d891a (32 bytes)
|
||||
parity=0x00
|
||||
salt=3cec65b834280a16c5480d7b5000e380 (16 bytes)
|
||||
|
||||
CLIENT -> SERVER (33 bytes):
|
||||
20 9b 1f 74 ec 40 31 2c ...
|
||||
|
||||
Decoded:
|
||||
len=0x20 (32 bytes payload)
|
||||
client_cc=9b1f74ec... (32 bytes, SHA256 proof)
|
||||
|
||||
SERVER -> CLIENT (33 bytes):
|
||||
20 7d 59 b3 2e 28 6e 52 ...
|
||||
|
||||
Decoded:
|
||||
len=0x20 (32 bytes payload)
|
||||
server_cc=7d59b32e... (32 bytes, SHA256 proof)
|
||||
|
||||
POST-AUTH:
|
||||
01 00 00 00 07 fa
|
||||
|
||||
Decoded:
|
||||
AUTH_OK=01000000
|
||||
UDP_port=0x07fa (2042)
|
||||
```
|
||||
|
||||
## TCP Data Transfer
|
||||
|
||||
After handshake, data flows on the **same TCP connection** used for control.
|
||||
@@ -163,7 +345,7 @@ graph LR
|
||||
|
||||
```
|
||||
Offset Size Type Field
|
||||
────── ──── ──── ─────
|
||||
------ ---- ---- -----
|
||||
0-3 4 uint32 BE sequence_number
|
||||
4+ var bytes payload (zeros or random)
|
||||
```
|
||||
@@ -176,7 +358,7 @@ Exchanged every 1 second over the **TCP control channel** during UDP tests.
|
||||
|
||||
```
|
||||
Offset Size Type Field Byte Order
|
||||
────── ──── ──── ───── ──────────
|
||||
------ ---- ---- ----- ----------
|
||||
0 1 uint8 msg_type Always 0x07
|
||||
1-4 4 uint32 BE seq_number Big-endian
|
||||
5-7 3 bytes padding Always 00 00 00
|
||||
@@ -208,11 +390,11 @@ sequenceDiagram
|
||||
|
||||
```
|
||||
Server sends: 07 00 00 00 01 00 00 00 C0 2D B4 02
|
||||
── ─────────── ──────── ───────────
|
||||
-- ---------- -------- -----------
|
||||
type seq=1 padding bytes=45,362,624
|
||||
|
||||
Client sends: 07 D9 00 00 01 00 00 00 00 00 00 00
|
||||
── ─────────── ──────── ───────────
|
||||
-- ---------- -------- -----------
|
||||
type seq padding bytes=0
|
||||
```
|
||||
|
||||
@@ -237,7 +419,7 @@ graph TD
|
||||
For a target speed in bits/sec and packet size in bytes:
|
||||
|
||||
```
|
||||
interval_ns = (1,000,000,000 × packet_size × 8) / target_speed_bps
|
||||
interval_ns = (1,000,000,000 * packet_size * 8) / target_speed_bps
|
||||
```
|
||||
|
||||
**Special case**: If interval > 500ms, clamp to exactly 1 second. This replicates a MikroTik behavior where very slow speeds get normalized to 1 packet/second.
|
||||
@@ -249,16 +431,19 @@ When `-n` / `--nat` flag is set, the client sends an empty UDP packet before sta
|
||||
## Protocol Constants
|
||||
|
||||
```
|
||||
BTEST_PORT = 2000 TCP control port
|
||||
BTEST_UDP_PORT_START = 2001 First UDP data port
|
||||
BTEST_PORT_CLIENT_OFFSET = 256 Client UDP port offset
|
||||
BTEST_PORT = 2000 TCP control port
|
||||
BTEST_UDP_PORT_START = 2001 First UDP data port
|
||||
BTEST_PORT_CLIENT_OFFSET = 256 Client UDP port offset
|
||||
|
||||
HELLO = [01 00 00 00]
|
||||
AUTH_OK = [01 00 00 00]
|
||||
AUTH_REQUIRED = [02 00 00 00]
|
||||
AUTH_EC_SRP5 = [03 00 00 00]
|
||||
AUTH_FAILED = [00 00 00 00]
|
||||
HELLO = [01 00 00 00]
|
||||
AUTH_OK = [01 00 00 00]
|
||||
AUTH_REQUIRED = [02 00 00 00]
|
||||
AUTH_EC_SRP5 = [03 00 00 00]
|
||||
AUTH_FAILED = [00 00 00 00]
|
||||
|
||||
STATUS_MSG_TYPE = 0x07
|
||||
STATUS_MSG_SIZE = 12 bytes
|
||||
STATUS_MSG_TYPE = 0x07
|
||||
STATUS_MSG_SIZE = 12 bytes
|
||||
|
||||
DEFAULT_TCP_TX_SIZE = 32768 (0x8000)
|
||||
DEFAULT_UDP_TX_SIZE = 1500 (0x05DC)
|
||||
```
|
||||
|
||||
@@ -14,21 +14,29 @@ btest -c 192.168.88.1 -r
|
||||
|
||||
Run btest-rs as a server and let MikroTik devices connect for bandwidth testing.
|
||||
|
||||
### Basic Server
|
||||
### Basic Server (No Authentication)
|
||||
|
||||
```bash
|
||||
btest -s
|
||||
```
|
||||
|
||||
Listens on TCP port 2000 (default). Any MikroTik device can connect without authentication.
|
||||
Listens on all IPv4 interfaces, TCP port 2000. Any MikroTik device can connect without credentials.
|
||||
|
||||
### Server with Authentication
|
||||
### Server with MD5 Authentication
|
||||
|
||||
```bash
|
||||
btest -s -a admin -p mysecretpassword
|
||||
```
|
||||
|
||||
MikroTik devices must provide matching credentials. Uses MD5 challenge-response authentication.
|
||||
Requires connecting devices to provide matching credentials. Uses MD5 double-hash challenge-response authentication, compatible with RouterOS versions before 6.43.
|
||||
|
||||
### Server with EC-SRP5 Authentication
|
||||
|
||||
```bash
|
||||
btest -s -a admin -p mysecretpassword --ecsrp5
|
||||
```
|
||||
|
||||
Advertises EC-SRP5 (Curve25519 Weierstrass) authentication to connecting clients. Required for RouterOS >= 6.43 devices that use the modern authentication protocol.
|
||||
|
||||
### Custom Port
|
||||
|
||||
@@ -36,26 +44,90 @@ MikroTik devices must provide matching credentials. Uses MD5 challenge-response
|
||||
btest -s -P 3000
|
||||
```
|
||||
|
||||
### Custom Listen Address
|
||||
|
||||
```bash
|
||||
# Listen only on a specific interface
|
||||
btest -s --listen 10.0.0.1
|
||||
|
||||
# Disable IPv4, listen only on IPv6
|
||||
btest -s --listen none --listen6
|
||||
|
||||
# Listen on both IPv4 and IPv6
|
||||
btest -s --listen6
|
||||
```
|
||||
|
||||
### IPv6 Listener (Experimental)
|
||||
|
||||
```bash
|
||||
# IPv6 on default address (::)
|
||||
btest -s --listen6
|
||||
|
||||
# IPv6 on a specific address
|
||||
btest -s --listen6 fd00::1
|
||||
```
|
||||
|
||||
TCP over IPv6 works fully on all platforms. UDP over IPv6 has issues on macOS due to kernel ENOBUFS limitations with `send_to()`. On Linux, IPv6 UDP works correctly.
|
||||
|
||||
### Syslog Integration
|
||||
|
||||
```bash
|
||||
btest -s --syslog 192.168.1.1:514
|
||||
```
|
||||
|
||||
Sends structured log events to a remote syslog server via UDP (RFC 3164 / BSD syslog format, facility local0). Events include:
|
||||
|
||||
- `AUTH_SUCCESS` -- successful authentication with peer address, username, and auth type
|
||||
- `AUTH_FAILURE` -- failed authentication with peer address, username, auth type, and reason
|
||||
- `TEST_START` -- test initiated with peer address, protocol, direction, and connection count
|
||||
- `TEST_END` -- test completed with peer address, protocol, direction, duration, average speeds, bytes transferred, and lost packets
|
||||
|
||||
### CSV Output
|
||||
|
||||
```bash
|
||||
btest -s --csv /var/log/btest-results.csv
|
||||
```
|
||||
|
||||
Appends a row for each completed test to the specified CSV file. Creates the file with headers if it does not exist. CSV columns:
|
||||
|
||||
```
|
||||
timestamp,host,port,protocol,direction,duration_s,tx_avg_mbps,rx_avg_mbps,tx_bytes,rx_bytes,lost_packets,auth_type
|
||||
```
|
||||
|
||||
### Quiet Mode
|
||||
|
||||
```bash
|
||||
btest -s --csv /var/log/btest.csv -q
|
||||
```
|
||||
|
||||
Suppresses per-second terminal output. Useful when running as a background service with CSV or syslog logging only.
|
||||
|
||||
### Verbose/Debug Output
|
||||
|
||||
```bash
|
||||
btest -s -v # Show connection info and debug messages
|
||||
btest -s -vv # Show hex dumps of status exchange (for debugging)
|
||||
btest -s -v # Debug messages (connection lifecycle, auth steps)
|
||||
btest -s -vv # Trace messages (hex dumps of status exchange)
|
||||
btest -s -vvv # Maximum verbosity
|
||||
```
|
||||
|
||||
### MikroTik Configuration (connecting to our server)
|
||||
### Combined Example
|
||||
|
||||
```bash
|
||||
btest -s -a admin -p secret --ecsrp5 --syslog 10.0.0.1:514 --csv /var/log/btest.csv -v
|
||||
```
|
||||
|
||||
This runs a server with EC-SRP5 authentication, sends events to syslog, logs results to CSV, and prints debug output to the terminal.
|
||||
|
||||
### MikroTik Configuration (Connecting to Our Server)
|
||||
|
||||
On the MikroTik device (WinBox or CLI):
|
||||
|
||||
```
|
||||
# CLI
|
||||
/tool/bandwidth-test address=<server-ip> direction=both protocol=udp user=admin password=mysecretpassword
|
||||
|
||||
# For best results, use 1 connection
|
||||
/tool/bandwidth-test address=<server-ip> direction=both protocol=udp connection-count=1
|
||||
/tool/bandwidth-test address=<server-ip> direction=both protocol=udp \
|
||||
user=admin password=mysecretpassword
|
||||
```
|
||||
|
||||
Or via WinBox: **Tools → Bandwidth Test**, enter server address, credentials, and click Start.
|
||||
Or via WinBox: **Tools > Bandwidth Test**, enter the server address and credentials, and click Start.
|
||||
|
||||
## Client Mode
|
||||
|
||||
@@ -63,28 +135,27 @@ Connect to a MikroTik device's built-in bandwidth test server.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Enable btest server on MikroTik:
|
||||
Enable the btest server on the MikroTik device:
|
||||
|
||||
```
|
||||
/tool/bandwidth-server set enabled=yes
|
||||
```
|
||||
|
||||
**Note**: If the MikroTik uses RouterOS >= 6.43 with authentication enabled, you'll need to either disable auth or use credentials. EC-SRP5 auth is not yet supported; MD5 auth works on older RouterOS versions.
|
||||
|
||||
### Download Test (receive)
|
||||
### Download Test (Receive)
|
||||
|
||||
```bash
|
||||
btest -c 192.168.88.1 -r
|
||||
```
|
||||
|
||||
Measures download speed from MikroTik to your machine.
|
||||
Measures download speed from the MikroTik device to your machine. The server transmits, the client receives.
|
||||
|
||||
### Upload Test (transmit)
|
||||
### Upload Test (Transmit)
|
||||
|
||||
```bash
|
||||
btest -c 192.168.88.1 -t
|
||||
```
|
||||
|
||||
Measures upload speed from your machine to MikroTik.
|
||||
Measures upload speed from your machine to the MikroTik device. The client transmits, the server receives.
|
||||
|
||||
### Bidirectional Test
|
||||
|
||||
@@ -102,6 +173,8 @@ btest -c 192.168.88.1 -t -u # UDP upload
|
||||
btest -c 192.168.88.1 -t -r -u # UDP bidirectional
|
||||
```
|
||||
|
||||
UDP mode uses separate data ports (2001+ on the server side, 2257+ on the client side) and exchanges status messages every second over the TCP control channel.
|
||||
|
||||
### Bandwidth Limiting
|
||||
|
||||
```bash
|
||||
@@ -110,15 +183,7 @@ btest -c 192.168.88.1 -t -b 1G # Limit to 1 Gbps
|
||||
btest -c 192.168.88.1 -r -b 500K # Limit to 500 Kbps
|
||||
```
|
||||
|
||||
### NAT Traversal
|
||||
|
||||
If you're behind NAT and need to receive UDP data:
|
||||
|
||||
```bash
|
||||
btest -c 192.168.88.1 -r -u -n
|
||||
```
|
||||
|
||||
The `-n` flag sends a probe packet to open the NAT firewall hole.
|
||||
Suffixes: `K` (kilobits/sec), `M` (megabits/sec), `G` (gigabits/sec). Values are in bits per second.
|
||||
|
||||
### With Authentication
|
||||
|
||||
@@ -126,61 +191,187 @@ The `-n` flag sends a probe packet to open the NAT firewall hole.
|
||||
btest -c 192.168.88.1 -r -a admin -p password
|
||||
```
|
||||
|
||||
The client auto-detects the authentication type (MD5 or EC-SRP5) from the server's response and handles it accordingly.
|
||||
|
||||
### NAT Traversal
|
||||
|
||||
```bash
|
||||
btest -c 192.168.88.1 -r -u -n
|
||||
```
|
||||
|
||||
The `-n` flag sends an empty UDP probe packet before starting the receive thread. This opens a hole in NAT firewalls so the server's UDP data packets can reach the client.
|
||||
|
||||
### Timed Tests
|
||||
|
||||
```bash
|
||||
btest -c 192.168.88.1 -r -d 30 # Run for 30 seconds, then stop
|
||||
btest -c 192.168.88.1 -t -r -d 60 # 60-second bidirectional test
|
||||
```
|
||||
|
||||
The default duration is 0 (unlimited). When the duration expires, the client exits cleanly.
|
||||
|
||||
### CSV Output (Client Mode)
|
||||
|
||||
```bash
|
||||
btest -c 192.168.88.1 -r -d 30 --csv results.csv
|
||||
```
|
||||
|
||||
Appends a summary row after the test completes with the host, port, protocol, direction, duration, and auth type.
|
||||
|
||||
### Quiet Mode (Client)
|
||||
|
||||
```bash
|
||||
btest -c 192.168.88.1 -r -d 10 --csv results.csv -q
|
||||
```
|
||||
|
||||
Suppresses per-second bandwidth output to the terminal. Useful for scripted or automated testing where only the CSV file matters.
|
||||
|
||||
### Custom Port
|
||||
|
||||
```bash
|
||||
btest -c 192.168.88.1 -r -P 3000
|
||||
```
|
||||
|
||||
## Reading the Output
|
||||
|
||||
```
|
||||
[ 1] TX 264.50 Mbps (33062912 bytes)
|
||||
[ 2] TX 263.98 Mbps (32997376 bytes)
|
||||
[ 2] RX 263.98 Mbps (32997012 bytes)
|
||||
[ 3] RX 430.51 Mbps (53813376 bytes) lost: 5
|
||||
[ 1] TX 264.50 Mbps (33062912 bytes) cpu: 12%/0%
|
||||
[ 2] TX 263.98 Mbps (32997376 bytes) cpu: 15%/33%
|
||||
[ 2] RX 263.98 Mbps (32997012 bytes) cpu: 15%/33%
|
||||
[ 3] RX 430.51 Mbps (53813376 bytes) lost: 5 cpu: 18%/45%
|
||||
[ 4] RX 450.00 Mbps (56250000 bytes) cpu: 72%/85% !
|
||||
```
|
||||
|
||||
| Field | Meaning |
|
||||
|-------|---------|
|
||||
| `[ N]` | Interval number (1 per second) |
|
||||
| `TX` | Data we sent (upload) |
|
||||
| `RX` | Data we received (download) |
|
||||
| `TX` | Data sent (upload from your perspective) |
|
||||
| `RX` | Data received (download from your perspective) |
|
||||
| `Mbps` | Megabits per second |
|
||||
| `bytes` | Raw bytes transferred in this interval |
|
||||
| `lost: N` | UDP packets lost (UDP mode only) |
|
||||
| `lost: N` | UDP packets lost in this interval (UDP mode only) |
|
||||
| `cpu: L%/R%` | Local CPU / Remote CPU usage percentage |
|
||||
| `!` | Warning: CPU usage exceeds 70% on either side |
|
||||
|
||||
## CLI Reference
|
||||
## Complete CLI Reference
|
||||
|
||||
```
|
||||
btest-rs — MikroTik Bandwidth Test server & client in Rust
|
||||
btest-rs -- MikroTik Bandwidth Test server & client in Rust
|
||||
|
||||
Usage: btest [OPTIONS]
|
||||
|
||||
Options:
|
||||
-s, --server Run in server mode
|
||||
-c, --client <HOST> Run in client mode, connect to HOST
|
||||
-t, --transmit Client: upload test
|
||||
-r, --receive Client: download test
|
||||
-u, --udp Use UDP instead of TCP
|
||||
-b, --bandwidth <BW> Bandwidth limit (e.g., 100M, 1G, 500K)
|
||||
-P, --port <PORT> Port number [default: 2000]
|
||||
-a, --authuser <USER> Authentication username
|
||||
-p, --authpass <PASS> Authentication password
|
||||
-n, --nat NAT traversal mode
|
||||
-v, --verbose Increase log verbosity (-v, -vv)
|
||||
-h, --help Show help
|
||||
-V, --version Show version
|
||||
-s, --server
|
||||
Run in server mode. Listens for incoming connections from MikroTik
|
||||
devices or other btest clients. Conflicts with -c.
|
||||
|
||||
-c, --client <HOST>
|
||||
Run in client mode, connecting to the specified host. The host can be
|
||||
an IPv4 address, IPv6 address, or hostname. Conflicts with -s.
|
||||
|
||||
-t, --transmit
|
||||
Client transmits data (upload test). Tells the server to receive.
|
||||
Can be combined with -r for bidirectional testing.
|
||||
|
||||
-r, --receive
|
||||
Client receives data (download test). Tells the server to transmit.
|
||||
Can be combined with -t for bidirectional testing.
|
||||
|
||||
-u, --udp
|
||||
Use UDP instead of TCP for the data transfer. UDP uses separate data
|
||||
ports (2001+ server side, 2257+ client side) and exchanges status
|
||||
messages over the TCP control channel every second.
|
||||
|
||||
-b, --bandwidth <BW>
|
||||
Target bandwidth limit for the test. Accepts suffixes: K (kilobits),
|
||||
M (megabits), G (gigabits). Examples: 100M, 1G, 500K. Default is 0
|
||||
(unlimited).
|
||||
|
||||
-P, --port <PORT>
|
||||
TCP port to listen on (server mode) or connect to (client mode).
|
||||
[default: 2000]
|
||||
|
||||
--listen <ADDR>
|
||||
IPv4 address to bind the server listener to. Use "none" to disable
|
||||
IPv4 listening entirely (useful with --listen6 for IPv6-only mode).
|
||||
[default: 0.0.0.0]
|
||||
|
||||
--listen6 [<ADDR>]
|
||||
Enable the IPv6 listener. If no address is given, binds to [::].
|
||||
Experimental: TCP over IPv6 works fully on all platforms. UDP over
|
||||
IPv6 has issues on macOS due to kernel ENOBUFS limitations.
|
||||
|
||||
-a, --authuser <USER>
|
||||
Authentication username. In server mode, clients must provide this
|
||||
username. In client mode, this is sent to the server.
|
||||
|
||||
-p, --authpass <PASS>
|
||||
Authentication password. In server mode, clients must provide a
|
||||
matching password. In client mode, this is used to authenticate.
|
||||
|
||||
--ecsrp5
|
||||
Use EC-SRP5 authentication (Curve25519 Weierstrass). In server mode,
|
||||
this advertises EC-SRP5 instead of MD5 to connecting clients.
|
||||
Required for RouterOS >= 6.43. In client mode, auth type is
|
||||
auto-detected and this flag is not needed.
|
||||
|
||||
-n, --nat
|
||||
NAT traversal mode. Sends an empty UDP probe packet to the server
|
||||
before starting the receive thread, opening a hole in NAT firewalls.
|
||||
Only relevant for UDP receive tests behind NAT.
|
||||
|
||||
-d, --duration <SECS>
|
||||
Test duration in seconds (client mode only). The client exits cleanly
|
||||
after the specified time. A value of 0 means unlimited (run until
|
||||
interrupted with Ctrl-C). [default: 0]
|
||||
|
||||
--csv <FILE>
|
||||
Output test results to a CSV file. Appends a row per completed test.
|
||||
Creates the file with a header row if it does not exist. Columns:
|
||||
timestamp, host, port, protocol, direction, duration_s, tx_avg_mbps,
|
||||
rx_avg_mbps, tx_bytes, rx_bytes, lost_packets, auth_type.
|
||||
|
||||
-q, --quiet
|
||||
Suppress per-second bandwidth output to the terminal. Useful in
|
||||
combination with --csv for machine-readable-only output, or when
|
||||
running as a background service.
|
||||
|
||||
--syslog <HOST:PORT>
|
||||
Send structured log events to a remote syslog server via UDP. Uses
|
||||
RFC 3164 (BSD syslog) format with facility local0. Events include
|
||||
AUTH_SUCCESS, AUTH_FAILURE, TEST_START, and TEST_END with detailed
|
||||
metadata. Example: --syslog 192.168.1.1:514
|
||||
|
||||
-v, --verbose...
|
||||
Increase log verbosity. Can be repeated:
|
||||
-v debug messages (connection lifecycle, auth steps)
|
||||
-vv trace messages (hex dumps of protocol exchange)
|
||||
-vvv maximum verbosity
|
||||
|
||||
-h, --help
|
||||
Print help information
|
||||
|
||||
-V, --version
|
||||
Print version information
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
- **Use 1 connection** when MikroTik connects to your server. Multi-connection mode causes MikroTik's per-connection speed adaptation to throttle.
|
||||
- **TCP mode** generally gives more stable results than UDP due to TCP flow control.
|
||||
- **UDP mode** is better for measuring raw link capacity without TCP overhead.
|
||||
- **First interval** may show higher or lower numbers as the connection stabilizes. Look at intervals 3+ for steady-state throughput.
|
||||
- **WiFi testing**: bidirectional tests on WiFi will show lower per-direction speeds because WiFi is half-duplex at the MAC layer.
|
||||
- **Bandwidth limiting** applies to the direction you specify. In bidirectional mode with `-b 100M`, both directions are limited to 100 Mbps each.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Problem | Solution |
|
||||
|---------|----------|
|
||||
| `EC-SRP5 authentication not supported` | Disable auth on MikroTik btest server, or use older RouterOS |
|
||||
| `Connection refused` | Check port 2000 is open, firewall allows it |
|
||||
| Server shows 0 RX | Check MikroTik is actually sending (direction setting) |
|
||||
| Speed drops over time (server mode) | MikroTik client behavior — use 1 connection, or use our client mode instead |
|
||||
| UDP `lost` packets high | Network congestion or MTU issues, try reducing bandwidth with `-b` |
|
||||
| Connection refused | Check that port 2000 is open and the server is running |
|
||||
| Auth failure with EC-SRP5 | Ensure `--ecsrp5` is set on the server if the MikroTik client uses RouterOS >= 6.43 |
|
||||
| Auth failure with MD5 | Verify username and password match exactly (case-sensitive) |
|
||||
| Server shows 0 RX | Check that the MikroTik direction setting includes sending to the server |
|
||||
| Very low UDP speed | Network congestion or MTU issues; try reducing bandwidth with `-b` |
|
||||
| IPv6 UDP fails on macOS | Known macOS kernel limitation; use Linux for IPv6 UDP tests |
|
||||
| Syslog messages not arriving | Verify the syslog server address and port, and check firewall rules for UDP 514 |
|
||||
| CSV file not created | Check write permissions on the directory; the file is created on first use |
|
||||
|
||||
145
proto-test/btest_mitm_full.py
Normal file
145
proto-test/btest_mitm_full.py
Normal file
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Full MITM proxy for btest - forwards TCP control + UDP data.
|
||||
Captures and logs ALL traffic between MikroTik client and MikroTik server.
|
||||
|
||||
Usage:
|
||||
python3 btest_mitm_full.py --target 172.16.81.1
|
||||
|
||||
Then on MikroTik:
|
||||
/tool/bandwidth-test address=<this_mac_ip> direction=receive protocol=tcp \
|
||||
user=antar password=antar connection-count=1
|
||||
"""
|
||||
import socket
|
||||
import select
|
||||
import sys
|
||||
import argparse
|
||||
import time
|
||||
import threading
|
||||
import struct
|
||||
|
||||
|
||||
def ts():
|
||||
return time.strftime("%H:%M:%S", time.localtime()) + f".{int(time.time()*1000)%1000:03d}"
|
||||
|
||||
|
||||
def hexline(data, offset=0, max_bytes=16):
|
||||
chunk = data[offset:offset+max_bytes]
|
||||
hex_part = " ".join(f"{b:02x}" for b in chunk)
|
||||
ascii_part = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk)
|
||||
return f" {offset:04x} {hex_part:<48s} {ascii_part}"
|
||||
|
||||
|
||||
def log_data(direction, data, conn_id=""):
|
||||
label = f"[{ts()}] {direction}"
|
||||
if conn_id:
|
||||
label += f" [{conn_id}]"
|
||||
label += f" ({len(data)} bytes)"
|
||||
print(label)
|
||||
# Show first 4 lines of hex
|
||||
for i in range(0, min(len(data), 64), 16):
|
||||
print(hexline(data, i))
|
||||
if len(data) > 64:
|
||||
print(f" ... ({len(data)} total)")
|
||||
|
||||
# Try to annotate
|
||||
if len(data) == 4:
|
||||
val = data.hex()
|
||||
annotations = {
|
||||
"01000000": "HELLO / AUTH_OK",
|
||||
"02000000": "AUTH_REQUIRED (MD5)",
|
||||
"03000000": "AUTH_REQUIRED (EC-SRP5)",
|
||||
"00000000": "AUTH_FAILED",
|
||||
}
|
||||
if val in annotations:
|
||||
print(f" >>> {annotations[val]}")
|
||||
|
||||
if len(data) == 12 and data[0] == 0x07:
|
||||
# Status message
|
||||
seq = int.from_bytes(data[1:5], "big")
|
||||
recv_bytes = int.from_bytes(data[8:12], "little")
|
||||
mbps = recv_bytes * 8 / 1_000_000
|
||||
print(f" >>> STATUS: seq={seq} bytes_received={recv_bytes} ({mbps:.2f} Mbps)")
|
||||
|
||||
if len(data) == 16:
|
||||
proto = "UDP" if data[0] == 0 else "TCP"
|
||||
dirs = {1: "RX", 2: "TX", 3: "BOTH"}
|
||||
d = dirs.get(data[1], f"0x{data[1]:02x}")
|
||||
conn = data[3]
|
||||
print(f" >>> COMMAND: proto={proto} dir={d} conn_count={conn}")
|
||||
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def proxy_tcp(client_sock, target_host, target_port, conn_id):
|
||||
"""Proxy a single TCP connection."""
|
||||
try:
|
||||
server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
server_sock.settimeout(30)
|
||||
server_sock.connect((target_host, target_port))
|
||||
server_sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||||
except Exception as e:
|
||||
print(f"[{conn_id}] Failed to connect to target: {e}")
|
||||
client_sock.close()
|
||||
return
|
||||
|
||||
try:
|
||||
while True:
|
||||
readable, _, _ = select.select([client_sock, server_sock], [], [], 30)
|
||||
if not readable:
|
||||
break
|
||||
|
||||
for sock in readable:
|
||||
if sock is server_sock:
|
||||
data = server_sock.recv(65536)
|
||||
if not data:
|
||||
return
|
||||
log_data("SERVER→CLIENT", data, conn_id)
|
||||
client_sock.sendall(data)
|
||||
elif sock is client_sock:
|
||||
data = client_sock.recv(65536)
|
||||
if not data:
|
||||
return
|
||||
log_data("CLIENT→SERVER", data, conn_id)
|
||||
server_sock.sendall(data)
|
||||
except Exception as e:
|
||||
print(f"[{conn_id}] Error: {e}")
|
||||
finally:
|
||||
client_sock.close()
|
||||
server_sock.close()
|
||||
print(f"[{conn_id}] Closed")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="btest full MITM proxy")
|
||||
parser.add_argument("-t", "--target", required=True, help="Target MikroTik IP")
|
||||
parser.add_argument("-l", "--listen", type=int, default=2000, help="Listen port")
|
||||
args = parser.parse_args()
|
||||
|
||||
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
listener.bind(("0.0.0.0", args.listen))
|
||||
listener.listen(50)
|
||||
|
||||
print(f"MITM proxy: 0.0.0.0:{args.listen} → {args.target}:2000")
|
||||
print(f"Point MikroTik btest client at this machine")
|
||||
print()
|
||||
|
||||
conn_num = 0
|
||||
while True:
|
||||
client_sock, client_addr = listener.accept()
|
||||
client_sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||||
conn_num += 1
|
||||
conn_id = f"TCP-{conn_num} {client_addr[0]}:{client_addr[1]}"
|
||||
print(f"\n{'='*60}")
|
||||
print(f"[{ts()}] New connection: {conn_id}")
|
||||
t = threading.Thread(
|
||||
target=proxy_tcp,
|
||||
args=(client_sock, args.target, 2000, conn_id),
|
||||
daemon=True,
|
||||
)
|
||||
t.start()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
75
scripts/debug-capture.sh
Executable file
75
scripts/debug-capture.sh
Executable file
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env bash
|
||||
# Capture btest traffic for debugging multi-connection issues.
|
||||
#
|
||||
# Usage:
|
||||
# # Terminal 1: Start capture
|
||||
# sudo ./scripts/debug-capture.sh capture <interface> [mikrotik_ip]
|
||||
#
|
||||
# # Terminal 2: Run server or client
|
||||
# ./target/release/btest -s -a admin -p password -vv
|
||||
#
|
||||
# # Terminal 1: Stop with Ctrl+C, then analyze
|
||||
# ./scripts/debug-capture.sh analyze
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
CMD="${1:?Usage: $0 <capture|analyze> [interface] [mikrotik_ip]}"
|
||||
|
||||
PCAP_FILE="dist/btest-debug.pcap"
|
||||
mkdir -p dist
|
||||
|
||||
case "$CMD" in
|
||||
capture)
|
||||
IFACE="${2:?Specify interface (e.g., en0, eth0)}"
|
||||
MK_IP="${3:-}"
|
||||
|
||||
FILTER="port 2000 or portrange 2001-2100 or portrange 2257-2356"
|
||||
if [[ -n "$MK_IP" ]]; then
|
||||
FILTER="host $MK_IP and ($FILTER)"
|
||||
fi
|
||||
|
||||
echo "Capturing btest traffic on $IFACE..."
|
||||
echo "Filter: $FILTER"
|
||||
echo "Output: $PCAP_FILE"
|
||||
echo "Press Ctrl+C to stop"
|
||||
echo ""
|
||||
tcpdump -i "$IFACE" -w "$PCAP_FILE" -s 128 "$FILTER"
|
||||
;;
|
||||
|
||||
analyze)
|
||||
if [[ ! -f "$PCAP_FILE" ]]; then
|
||||
echo "No capture file found at $PCAP_FILE"
|
||||
echo "Run: sudo $0 capture <interface> first"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== TCP Control Channel (port 2000) ==="
|
||||
echo ""
|
||||
echo "--- Connection summary ---"
|
||||
tcpdump -r "$PCAP_FILE" -n 'tcp port 2000 and (tcp[tcpflags] & tcp-syn != 0)' 2>/dev/null | head -20
|
||||
echo ""
|
||||
|
||||
echo "--- All TCP control data (first 64 bytes of payload) ---"
|
||||
tcpdump -r "$PCAP_FILE" -n -X 'tcp port 2000 and tcp[tcpflags] & tcp-push != 0' 2>/dev/null | head -100
|
||||
echo ""
|
||||
|
||||
echo "=== UDP Data Ports ==="
|
||||
echo ""
|
||||
echo "--- UDP port usage ---"
|
||||
tcpdump -r "$PCAP_FILE" -n 'udp' 2>/dev/null | awk '{print $3, $5}' | sort | uniq -c | sort -rn | head -20
|
||||
echo ""
|
||||
|
||||
echo "--- Timing of first packets per connection ---"
|
||||
tcpdump -r "$PCAP_FILE" -n -tt 'tcp port 2000 and (tcp[tcpflags] & tcp-syn != 0)' 2>/dev/null | head -20
|
||||
echo ""
|
||||
|
||||
echo "Full capture at: $PCAP_FILE"
|
||||
echo "Open in Wireshark: wireshark $PCAP_FILE"
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "Usage: $0 <capture|analyze> [interface] [mikrotik_ip]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -1,6 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build and push Docker image to Gitea registry.
|
||||
# Run on a machine with Docker (your Mac).
|
||||
# Build and push multi-arch Docker images to Gitea registry.
|
||||
#
|
||||
# Prerequisites:
|
||||
# - dist/btest-linux-x86_64.tar.gz (from CI release or scripts/build-linux.sh)
|
||||
# - Native macOS binary (built automatically)
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/push-docker.sh v0.1.0
|
||||
@@ -16,31 +19,86 @@ if [[ -f .env ]]; then
|
||||
fi
|
||||
|
||||
TAG="${1:?Usage: $0 <tag> (e.g. v0.1.0)}"
|
||||
REGISTRY="${GITEA_URL:-https://git.manko.yoga}"
|
||||
REGISTRY_HOST="${REGISTRY#https://}"
|
||||
REGISTRY_HOST="${GITEA_URL:-https://git.manko.yoga}"
|
||||
REGISTRY_HOST="${REGISTRY_HOST#https://}"
|
||||
REGISTRY_HOST="${REGISTRY_HOST#http://}"
|
||||
IMAGE="${REGISTRY_HOST}/manawenuz/btest-rs"
|
||||
|
||||
echo "=== Building Docker image for ${IMAGE}:${TAG} ==="
|
||||
# NOTE: Run 'docker login git.manko.yoga' manually first if not authenticated
|
||||
|
||||
# Build the image
|
||||
docker build -t "${IMAGE}:${TAG}" -t "${IMAGE}:latest" .
|
||||
mkdir -p dist/docker-amd64 dist/docker-arm64
|
||||
|
||||
echo ""
|
||||
echo "=== Pushing to ${REGISTRY_HOST} ==="
|
||||
|
||||
# Login if needed (uses GITEA_TOKEN from .env)
|
||||
if [[ -n "${GITEA_TOKEN:-}" ]]; then
|
||||
echo "${GITEA_TOKEN}" | docker login "${REGISTRY_HOST}" -u token --password-stdin
|
||||
# --- x86_64 binary ---
|
||||
if [[ ! -f dist/docker-amd64/btest ]]; then
|
||||
if [[ -f dist/btest-linux-x86_64.tar.gz ]]; then
|
||||
echo "Extracting x86_64 binary from tarball..."
|
||||
tar xzf dist/btest-linux-x86_64.tar.gz -C dist/docker-amd64/
|
||||
else
|
||||
echo "No x86_64 binary found. Downloading from release ${TAG}..."
|
||||
GITEA_URL_FULL="https://${REGISTRY_HOST}"
|
||||
RELEASE_URL=$(curl -sf \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${GITEA_URL_FULL}/api/v1/repos/manawenuz/btest-rs/releases/tags/${TAG}" \
|
||||
| jq -r '.assets[] | select(.name=="btest-linux-x86_64.tar.gz") | .browser_download_url')
|
||||
if [[ -n "$RELEASE_URL" ]]; then
|
||||
curl -sL "$RELEASE_URL" | tar xz -C dist/docker-amd64/
|
||||
else
|
||||
echo "Error: Cannot find x86_64 binary. Run CI first or scripts/build-linux.sh"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
docker push "${IMAGE}:${TAG}"
|
||||
docker push "${IMAGE}:latest"
|
||||
# --- arm64 binary (native build on Apple Silicon) ---
|
||||
if [[ ! -f dist/docker-arm64/btest ]]; then
|
||||
echo "Building native arm64 binary..."
|
||||
cargo build --release
|
||||
cp target/release/btest dist/docker-arm64/btest
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Done! Images pushed:"
|
||||
echo " ${IMAGE}:${TAG}"
|
||||
echo " ${IMAGE}:latest"
|
||||
echo "=== Building amd64 image ==="
|
||||
docker build --platform linux/amd64 -f Dockerfile.static \
|
||||
--build-arg BINARY=dist/docker-amd64/btest \
|
||||
-t "${IMAGE}:${TAG}-amd64" .
|
||||
|
||||
echo ""
|
||||
echo "=== Building arm64 image ==="
|
||||
docker build --platform linux/arm64 -f Dockerfile.static \
|
||||
--build-arg BINARY=dist/docker-arm64/btest \
|
||||
-t "${IMAGE}:${TAG}-arm64" .
|
||||
|
||||
echo ""
|
||||
echo "=== Pushing ==="
|
||||
docker push "${IMAGE}:${TAG}-amd64"
|
||||
docker push "${IMAGE}:${TAG}-arm64"
|
||||
|
||||
# Create and push multi-arch manifest
|
||||
echo ""
|
||||
echo "=== Creating multi-arch manifest ==="
|
||||
docker manifest create "${IMAGE}:${TAG}" \
|
||||
"${IMAGE}:${TAG}-amd64" \
|
||||
"${IMAGE}:${TAG}-arm64" 2>/dev/null || \
|
||||
docker manifest create --amend "${IMAGE}:${TAG}" \
|
||||
"${IMAGE}:${TAG}-amd64" \
|
||||
"${IMAGE}:${TAG}-arm64"
|
||||
|
||||
docker manifest push "${IMAGE}:${TAG}"
|
||||
|
||||
# Tag as latest
|
||||
docker manifest create "${IMAGE}:latest" \
|
||||
"${IMAGE}:${TAG}-amd64" \
|
||||
"${IMAGE}:${TAG}-arm64" 2>/dev/null || \
|
||||
docker manifest create --amend "${IMAGE}:latest" \
|
||||
"${IMAGE}:${TAG}-amd64" \
|
||||
"${IMAGE}:${TAG}-arm64"
|
||||
|
||||
docker manifest push "${IMAGE}:latest"
|
||||
|
||||
echo ""
|
||||
echo "Done! Multi-arch images pushed:"
|
||||
echo " ${IMAGE}:${TAG} (amd64 + arm64)"
|
||||
echo " ${IMAGE}:latest (amd64 + arm64)"
|
||||
echo ""
|
||||
echo "Run with:"
|
||||
echo " docker run --rm -p 2000:2000 -p 2001-2100:2001-2100/udp ${IMAGE}:${TAG} -s -v"
|
||||
|
||||
16
src/auth.rs
16
src/auth.rs
@@ -26,34 +26,33 @@ pub fn compute_auth_hash(password: &str, challenge: &[u8; 16]) -> [u8; 16] {
|
||||
}
|
||||
|
||||
/// Server-side: send auth challenge and verify response.
|
||||
/// `ok_response` is the 4-byte reply on success (normally AUTH_OK = [01,00,00,00]).
|
||||
/// For TCP multi-connection, pass [01,HI,LO,00] with a session token.
|
||||
/// Returns Ok(()) if auth succeeds or no auth is configured.
|
||||
pub async fn server_authenticate<S: AsyncReadExt + AsyncWriteExt + Unpin>(
|
||||
stream: &mut S,
|
||||
username: Option<&str>,
|
||||
password: Option<&str>,
|
||||
ok_response: &[u8; 4],
|
||||
) -> Result<()> {
|
||||
match (username, password) {
|
||||
(None, None) => {
|
||||
// No auth required
|
||||
stream.write_all(&AUTH_OK).await?;
|
||||
stream.write_all(ok_response).await?;
|
||||
stream.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
(_, Some(pass)) => {
|
||||
// Send auth challenge
|
||||
stream.write_all(&AUTH_REQUIRED).await?;
|
||||
let challenge = generate_challenge();
|
||||
stream.write_all(&challenge).await?;
|
||||
stream.flush().await?;
|
||||
|
||||
// Receive response: 16 bytes hash + 32 bytes username
|
||||
let mut response = [0u8; 48];
|
||||
stream.read_exact(&mut response).await?;
|
||||
|
||||
let received_hash = &response[0..16];
|
||||
let received_user = &response[16..48];
|
||||
|
||||
// Extract username (null-terminated)
|
||||
let user_end = received_user
|
||||
.iter()
|
||||
.position(|&b| b == 0)
|
||||
@@ -61,7 +60,6 @@ pub async fn server_authenticate<S: AsyncReadExt + AsyncWriteExt + Unpin>(
|
||||
let received_username = std::str::from_utf8(&received_user[..user_end])
|
||||
.unwrap_or("");
|
||||
|
||||
// Verify username if configured
|
||||
if let Some(expected_user) = username {
|
||||
if received_username != expected_user {
|
||||
tracing::warn!("Auth failed: username mismatch (got '{}')", received_username);
|
||||
@@ -71,7 +69,6 @@ pub async fn server_authenticate<S: AsyncReadExt + AsyncWriteExt + Unpin>(
|
||||
}
|
||||
}
|
||||
|
||||
// Verify hash
|
||||
let expected_hash = compute_auth_hash(pass, &challenge);
|
||||
if received_hash != expected_hash {
|
||||
tracing::warn!("Auth failed: hash mismatch for user '{}'", received_username);
|
||||
@@ -81,13 +78,12 @@ pub async fn server_authenticate<S: AsyncReadExt + AsyncWriteExt + Unpin>(
|
||||
}
|
||||
|
||||
tracing::info!("Auth successful for user '{}'", received_username);
|
||||
stream.write_all(&AUTH_OK).await?;
|
||||
stream.write_all(ok_response).await?;
|
||||
stream.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
(Some(_), None) => {
|
||||
// Username but no password - treat as no auth
|
||||
stream.write_all(&AUTH_OK).await?;
|
||||
stream.write_all(ok_response).await?;
|
||||
stream.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU32, AtomicU64};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -13,6 +13,13 @@ pub struct BandwidthState {
|
||||
pub rx_packets: AtomicU64,
|
||||
pub rx_lost_packets: AtomicU64,
|
||||
pub last_udp_seq: AtomicU32,
|
||||
/// Cumulative totals (never reset by swap)
|
||||
pub total_tx_bytes: AtomicU64,
|
||||
pub total_rx_bytes: AtomicU64,
|
||||
pub total_lost_packets: AtomicU64,
|
||||
pub intervals: AtomicU32,
|
||||
/// Remote peer's CPU usage (received via status messages)
|
||||
pub remote_cpu: AtomicU8,
|
||||
}
|
||||
|
||||
impl BandwidthState {
|
||||
@@ -26,8 +33,33 @@ impl BandwidthState {
|
||||
rx_packets: AtomicU64::new(0),
|
||||
rx_lost_packets: AtomicU64::new(0),
|
||||
last_udp_seq: AtomicU32::new(0),
|
||||
total_tx_bytes: AtomicU64::new(0),
|
||||
total_rx_bytes: AtomicU64::new(0),
|
||||
total_lost_packets: AtomicU64::new(0),
|
||||
intervals: AtomicU32::new(0),
|
||||
remote_cpu: AtomicU8::new(0),
|
||||
})
|
||||
}
|
||||
|
||||
/// Record an interval's stats into cumulative totals.
|
||||
pub fn record_interval(&self, tx: u64, rx: u64, lost: u64) {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
self.total_tx_bytes.fetch_add(tx, Relaxed);
|
||||
self.total_rx_bytes.fetch_add(rx, Relaxed);
|
||||
self.total_lost_packets.fetch_add(lost, Relaxed);
|
||||
self.intervals.fetch_add(1, Relaxed);
|
||||
}
|
||||
|
||||
/// Get summary for syslog reporting.
|
||||
pub fn summary(&self) -> (u64, u64, u64, u32) {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
(
|
||||
self.total_tx_bytes.load(Relaxed),
|
||||
self.total_rx_bytes.load(Relaxed),
|
||||
self.total_lost_packets.load(Relaxed),
|
||||
self.intervals.load(Relaxed),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate the sleep interval between packets to achieve target bandwidth.
|
||||
@@ -94,6 +126,22 @@ pub fn print_status(
|
||||
elapsed: Duration,
|
||||
lost_packets: Option<u64>,
|
||||
) {
|
||||
print_status_with_cpu(interval_num, direction, bytes, elapsed, lost_packets, None, None);
|
||||
}
|
||||
|
||||
pub fn print_status_with_cpu(
|
||||
interval_num: u32,
|
||||
direction: &str,
|
||||
bytes: u64,
|
||||
elapsed: Duration,
|
||||
lost_packets: Option<u64>,
|
||||
local_cpu: Option<u8>,
|
||||
remote_cpu: Option<u8>,
|
||||
) {
|
||||
if crate::csv_output::is_quiet() {
|
||||
return;
|
||||
}
|
||||
|
||||
let secs = elapsed.as_secs_f64();
|
||||
let bits = bytes as f64 * 8.0;
|
||||
let bw = if secs > 0.0 { bits / secs } else { 0.0 };
|
||||
@@ -103,13 +151,26 @@ pub fn print_status(
|
||||
_ => String::new(),
|
||||
};
|
||||
|
||||
let cpu_str = match (local_cpu, remote_cpu) {
|
||||
(Some(l), Some(r)) => {
|
||||
let warn = if l > 70 || r > 70 { " !" } else { "" };
|
||||
format!(" cpu: {}%/{}%{}", l, r, warn)
|
||||
}
|
||||
(Some(l), None) => {
|
||||
let warn = if l > 70 { " !" } else { "" };
|
||||
format!(" cpu: {}%{}", l, warn)
|
||||
}
|
||||
_ => String::new(),
|
||||
};
|
||||
|
||||
println!(
|
||||
"[{:4}] {:>3} {} ({} bytes){}",
|
||||
"[{:4}] {:>3} {} ({} bytes){}{}",
|
||||
interval_num,
|
||||
direction,
|
||||
format_bandwidth(bw),
|
||||
bytes,
|
||||
loss_str,
|
||||
cpu_str,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
114
src/client.rs
114
src/client.rs
@@ -20,6 +20,7 @@ pub async fn run_client(
|
||||
auth_user: Option<String>,
|
||||
auth_pass: Option<String>,
|
||||
nat_mode: bool,
|
||||
shared_state: Arc<BandwidthState>,
|
||||
) -> Result<()> {
|
||||
let addr = format!("{}:{}", host, port);
|
||||
tracing::info!("Connecting to {}...", addr);
|
||||
@@ -37,29 +38,45 @@ pub async fn run_client(
|
||||
send_command(&mut stream, &cmd).await?;
|
||||
|
||||
let resp = recv_response(&mut stream).await?;
|
||||
match (auth_user.as_deref(), auth_pass.as_deref()) {
|
||||
(Some(user), Some(pass)) => {
|
||||
auth::client_authenticate(&mut stream, resp, user, pass).await?;
|
||||
}
|
||||
_ => {
|
||||
if resp == AUTH_REQUIRED {
|
||||
if resp == AUTH_OK {
|
||||
// No auth required
|
||||
} else if resp == AUTH_REQUIRED {
|
||||
// MD5 auth
|
||||
match (auth_user.as_deref(), auth_pass.as_deref()) {
|
||||
(Some(user), Some(pass)) => {
|
||||
auth::client_authenticate(&mut stream, resp, user, pass).await?;
|
||||
}
|
||||
_ => {
|
||||
return Err(BtestError::Protocol(
|
||||
"Server requires authentication but no credentials provided".into(),
|
||||
"Server requires authentication but no credentials provided (-a/-p)".into(),
|
||||
));
|
||||
}
|
||||
if resp == [0x03, 0x00, 0x00, 0x00] {
|
||||
}
|
||||
} else if resp == [0x03, 0x00, 0x00, 0x00] {
|
||||
// EC-SRP5 auth (RouterOS >= 6.43)
|
||||
match (auth_user.as_deref(), auth_pass.as_deref()) {
|
||||
(Some(user), Some(pass)) => {
|
||||
crate::ecsrp5::client_authenticate(&mut stream, user, pass).await?;
|
||||
// After EC-SRP5, server sends AUTH_OK
|
||||
let post_auth = recv_response(&mut stream).await?;
|
||||
if post_auth != AUTH_OK {
|
||||
return Err(BtestError::Protocol(format!(
|
||||
"Unexpected post-EC-SRP5 response: {:02x?}",
|
||||
post_auth
|
||||
)));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
return Err(BtestError::Protocol(
|
||||
"Server requires EC-SRP5 authentication (RouterOS >= 6.43) which is not yet supported. \
|
||||
Try disabling authentication on the MikroTik btest server, or provide -a/-p credentials".into(),
|
||||
"Server requires EC-SRP5 authentication. Provide credentials with -a/-p".into(),
|
||||
));
|
||||
}
|
||||
if resp != AUTH_OK {
|
||||
return Err(BtestError::Protocol(format!(
|
||||
"Unexpected server response: {:02x?}",
|
||||
resp
|
||||
)));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return Err(BtestError::Protocol(format!(
|
||||
"Unexpected server response: {:02x?}",
|
||||
resp
|
||||
)));
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
@@ -74,16 +91,15 @@ pub async fn run_client(
|
||||
);
|
||||
|
||||
if use_udp {
|
||||
run_udp_test_client(&mut stream, host, &cmd, nat_mode).await
|
||||
run_udp_test_client(&mut stream, host, &cmd, nat_mode, shared_state).await
|
||||
} else {
|
||||
run_tcp_test_client(stream, cmd).await
|
||||
run_tcp_test_client(stream, cmd, shared_state).await
|
||||
}
|
||||
}
|
||||
|
||||
// --- TCP Test Client ---
|
||||
|
||||
async fn run_tcp_test_client(stream: TcpStream, cmd: Command) -> Result<()> {
|
||||
let state = BandwidthState::new();
|
||||
async fn run_tcp_test_client(stream: TcpStream, cmd: Command, state: Arc<BandwidthState>) -> Result<()> {
|
||||
let tx_size = cmd.tx_size as usize;
|
||||
let client_should_tx = cmd.client_tx();
|
||||
let client_should_rx = cmd.client_rx();
|
||||
@@ -132,8 +148,7 @@ async fn tcp_client_tx_loop(
|
||||
) {
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
let mut packet = vec![0u8; tx_size];
|
||||
packet[0] = STATUS_MSG_TYPE;
|
||||
let packet = vec![0u8; tx_size]; // TCP data is all zeros
|
||||
let mut interval = bandwidth::calc_send_interval(tx_speed, tx_size as u16);
|
||||
let mut next_send = Instant::now();
|
||||
|
||||
@@ -187,6 +202,7 @@ async fn run_udp_test_client(
|
||||
host: &str,
|
||||
cmd: &Command,
|
||||
nat_mode: bool,
|
||||
state: Arc<BandwidthState>,
|
||||
) -> Result<()> {
|
||||
let mut port_buf = [0u8; 2];
|
||||
stream.read_exact(&mut port_buf).await?;
|
||||
@@ -198,9 +214,19 @@ async fn run_udp_test_client(
|
||||
server_udp_port, client_udp_port,
|
||||
);
|
||||
|
||||
let udp = UdpSocket::bind(format!("0.0.0.0:{}", client_udp_port)).await?;
|
||||
let server_udp_addr: SocketAddr =
|
||||
format!("{}:{}", host, server_udp_port).parse().unwrap();
|
||||
// Detect IPv6 from the host address
|
||||
let is_ipv6 = host.contains(':');
|
||||
let bind_addr: SocketAddr = if is_ipv6 {
|
||||
format!("[::]:{}", client_udp_port).parse().unwrap()
|
||||
} else {
|
||||
format!("0.0.0.0:{}", client_udp_port).parse().unwrap()
|
||||
};
|
||||
let udp = UdpSocket::bind(bind_addr).await?;
|
||||
let server_udp_addr = if is_ipv6 {
|
||||
SocketAddr::new(host.parse().unwrap(), server_udp_port)
|
||||
} else {
|
||||
format!("{}:{}", host, server_udp_port).parse().unwrap()
|
||||
};
|
||||
udp.connect(server_udp_addr).await?;
|
||||
|
||||
if nat_mode {
|
||||
@@ -208,7 +234,6 @@ async fn run_udp_test_client(
|
||||
udp.send(&[]).await?;
|
||||
}
|
||||
|
||||
let state = BandwidthState::new();
|
||||
let tx_size = cmd.tx_size as usize;
|
||||
let client_should_tx = cmd.client_tx();
|
||||
let client_should_rx = cmd.client_rx();
|
||||
@@ -264,13 +289,19 @@ async fn udp_client_tx_loop(
|
||||
state.tx_bytes.fetch_add(n as u64, Ordering::Relaxed);
|
||||
consecutive_errors = 0;
|
||||
}
|
||||
Err(_) => {
|
||||
Err(e) => {
|
||||
consecutive_errors += 1;
|
||||
if consecutive_errors > 1000 {
|
||||
if consecutive_errors == 1 {
|
||||
tracing::debug!("UDP TX send error: {} (target)", e);
|
||||
}
|
||||
if consecutive_errors > 50000 {
|
||||
tracing::warn!("UDP TX: too many consecutive send errors, stopping");
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_micros(200)).await;
|
||||
let backoff = Duration::from_micros(
|
||||
(200 + consecutive_errors.min(5000) as u64 * 10).min(10000)
|
||||
);
|
||||
tokio::time::sleep(backoff).await;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -347,14 +378,17 @@ async fn client_status_loop(cmd: &Command, state: &BandwidthState) {
|
||||
|
||||
seq += 1;
|
||||
|
||||
if cmd.client_tx() {
|
||||
let tx = state.tx_bytes.swap(0, Ordering::Relaxed);
|
||||
bandwidth::print_status(seq, "TX", tx, Duration::from_secs(1), None);
|
||||
}
|
||||
let tx = if cmd.client_tx() { state.tx_bytes.swap(0, Ordering::Relaxed) } else { 0 };
|
||||
let rx = if cmd.client_rx() { state.rx_bytes.swap(0, Ordering::Relaxed) } else { 0 };
|
||||
state.record_interval(tx, rx, 0);
|
||||
|
||||
let local_cpu = crate::cpu::get();
|
||||
let remote_cpu = state.remote_cpu.load(Ordering::Relaxed);
|
||||
if cmd.client_tx() {
|
||||
bandwidth::print_status_with_cpu(seq, "TX", tx, Duration::from_secs(1), None, Some(local_cpu), Some(remote_cpu));
|
||||
}
|
||||
if cmd.client_rx() {
|
||||
let rx = state.rx_bytes.swap(0, Ordering::Relaxed);
|
||||
bandwidth::print_status(seq, "RX", rx, Duration::from_secs(1), None);
|
||||
bandwidth::print_status_with_cpu(seq, "RX", rx, Duration::from_secs(1), None, Some(local_cpu), Some(remote_cpu));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -388,6 +422,7 @@ async fn udp_client_status_loop(
|
||||
match tokio::time::timeout(wait_time, reader.read_exact(&mut status_buf)).await {
|
||||
Ok(Ok(_)) => {
|
||||
let server_status = StatusMessage::deserialize(&status_buf);
|
||||
state.remote_cpu.store(server_status.cpu_load, Ordering::Relaxed);
|
||||
|
||||
if server_status.bytes_received > 0 && cmd.client_tx() {
|
||||
let new_speed =
|
||||
@@ -419,8 +454,9 @@ async fn udp_client_status_loop(
|
||||
let rx_bytes = state.rx_bytes.swap(0, Ordering::Relaxed);
|
||||
let tx_bytes = state.tx_bytes.swap(0, Ordering::Relaxed);
|
||||
let lost = state.rx_lost_packets.swap(0, Ordering::Relaxed);
|
||||
state.record_interval(tx_bytes, rx_bytes, lost);
|
||||
|
||||
let status = StatusMessage {
|
||||
let status = StatusMessage { cpu_load: crate::cpu::get(),
|
||||
seq,
|
||||
bytes_received: rx_bytes as u32,
|
||||
};
|
||||
@@ -430,11 +466,13 @@ async fn udp_client_status_loop(
|
||||
}
|
||||
let _ = writer.flush().await;
|
||||
|
||||
let local_cpu = crate::cpu::get();
|
||||
let remote_cpu = state.remote_cpu.load(Ordering::Relaxed);
|
||||
if cmd.client_tx() {
|
||||
bandwidth::print_status(seq, "TX", tx_bytes, Duration::from_secs(1), None);
|
||||
bandwidth::print_status_with_cpu(seq, "TX", tx_bytes, Duration::from_secs(1), None, Some(local_cpu), Some(remote_cpu));
|
||||
}
|
||||
if cmd.client_rx() {
|
||||
bandwidth::print_status(seq, "RX", rx_bytes, Duration::from_secs(1), Some(lost));
|
||||
bandwidth::print_status_with_cpu(seq, "RX", rx_bytes, Duration::from_secs(1), Some(lost), Some(local_cpu), Some(remote_cpu));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
132
src/cpu.rs
Normal file
132
src/cpu.rs
Normal file
@@ -0,0 +1,132 @@
|
||||
//! Lightweight CPU usage measurement.
|
||||
//!
|
||||
//! Returns the system-wide CPU usage as a percentage (0-100).
|
||||
//! Works on macOS and Linux without external dependencies.
|
||||
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
static CURRENT_CPU: AtomicU8 = AtomicU8::new(0);
|
||||
|
||||
/// Start a background thread that samples CPU usage every second.
|
||||
pub fn start_sampler() {
|
||||
std::thread::spawn(|| {
|
||||
let mut prev = get_cpu_times();
|
||||
loop {
|
||||
std::thread::sleep(Duration::from_secs(1));
|
||||
let curr = get_cpu_times();
|
||||
let usage = compute_usage(&prev, &curr);
|
||||
CURRENT_CPU.store(usage, Ordering::Relaxed);
|
||||
prev = curr;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Get the current CPU usage percentage (0-100).
|
||||
pub fn get() -> u8 {
|
||||
CURRENT_CPU.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
// --- Platform-specific implementation ---
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
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") {
|
||||
if let Some(line) = content.lines().next() {
|
||||
let parts: Vec<u64> = line
|
||||
.split_whitespace()
|
||||
.skip(1) // skip "cpu"
|
||||
.filter_map(|s| s.parse().ok())
|
||||
.collect();
|
||||
if parts.len() >= 4 {
|
||||
let idle = parts[3];
|
||||
let total: u64 = parts.iter().sum();
|
||||
return (total, idle);
|
||||
}
|
||||
}
|
||||
}
|
||||
(0, 0)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn get_cpu_times() -> (u64, u64) {
|
||||
// Use host_statistics to get CPU ticks
|
||||
use std::mem::MaybeUninit;
|
||||
|
||||
extern "C" {
|
||||
fn mach_host_self() -> u32;
|
||||
fn host_statistics(
|
||||
host: u32,
|
||||
flavor: i32,
|
||||
info: *mut i32,
|
||||
count: *mut u32,
|
||||
) -> i32;
|
||||
}
|
||||
|
||||
const HOST_CPU_LOAD_INFO: i32 = 3;
|
||||
const CPU_STATE_MAX: usize = 4;
|
||||
|
||||
unsafe {
|
||||
let host = mach_host_self();
|
||||
let mut info = MaybeUninit::<[u32; CPU_STATE_MAX]>::uninit();
|
||||
let mut count: u32 = CPU_STATE_MAX as u32;
|
||||
|
||||
let ret = host_statistics(
|
||||
host,
|
||||
HOST_CPU_LOAD_INFO,
|
||||
info.as_mut_ptr() as *mut i32,
|
||||
&mut count,
|
||||
);
|
||||
|
||||
if ret == 0 {
|
||||
let ticks = info.assume_init();
|
||||
// ticks: [user, system, idle, nice]
|
||||
let user = ticks[0] as u64;
|
||||
let system = ticks[1] as u64;
|
||||
let idle = ticks[2] as u64;
|
||||
let nice = ticks[3] as u64;
|
||||
let total = user + system + idle + nice;
|
||||
return (total, idle);
|
||||
}
|
||||
}
|
||||
(0, 0)
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||
fn get_cpu_times() -> (u64, u64) {
|
||||
(0, 0) // Unsupported platform
|
||||
}
|
||||
|
||||
fn compute_usage(prev: &(u64, u64), curr: &(u64, u64)) -> u8 {
|
||||
let total_diff = curr.0.saturating_sub(prev.0);
|
||||
let idle_diff = curr.1.saturating_sub(prev.1);
|
||||
if total_diff == 0 {
|
||||
return 0;
|
||||
}
|
||||
let busy = total_diff - idle_diff;
|
||||
((busy * 100) / total_diff).min(100) as u8
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_cpu_times_returns_nonzero() {
|
||||
let (total, idle) = get_cpu_times();
|
||||
// On supported platforms, total should be > 0
|
||||
if cfg!(any(target_os = "linux", target_os = "macos")) {
|
||||
assert!(total > 0, "CPU total ticks should be > 0");
|
||||
assert!(idle <= total, "idle should be <= total");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_usage() {
|
||||
assert_eq!(compute_usage(&(0, 0), &(100, 20)), 80);
|
||||
assert_eq!(compute_usage(&(0, 0), &(100, 100)), 0);
|
||||
assert_eq!(compute_usage(&(0, 0), &(100, 0)), 100);
|
||||
assert_eq!(compute_usage(&(0, 0), &(0, 0)), 0);
|
||||
}
|
||||
}
|
||||
86
src/csv_output.rs
Normal file
86
src/csv_output.rs
Normal file
@@ -0,0 +1,86 @@
|
||||
//! CSV output for machine-readable test results.
|
||||
//!
|
||||
//! Appends a row per test to the specified CSV file.
|
||||
//! Creates the file with headers if it doesn't exist.
|
||||
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
use std::time::SystemTime;
|
||||
|
||||
static CSV_FILE: Mutex<Option<String>> = Mutex::new(None);
|
||||
static QUIET: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
|
||||
const HEADER: &str = "timestamp,host,port,protocol,direction,duration_s,tx_avg_mbps,rx_avg_mbps,tx_bytes,rx_bytes,lost_packets,local_cpu_pct,remote_cpu_pct,auth_type";
|
||||
|
||||
/// Initialize CSV output. Creates file with headers if needed.
|
||||
pub fn init(path: &str) -> std::io::Result<()> {
|
||||
let needs_header = !Path::new(path).exists() || std::fs::metadata(path)?.len() == 0;
|
||||
|
||||
if needs_header {
|
||||
let mut f = OpenOptions::new().create(true).write(true).open(path)?;
|
||||
writeln!(f, "{}", HEADER)?;
|
||||
}
|
||||
|
||||
*CSV_FILE.lock().unwrap() = Some(path.to_string());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_quiet(q: bool) {
|
||||
QUIET.store(q, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn is_quiet() -> bool {
|
||||
QUIET.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Write a test result row to the CSV file.
|
||||
pub fn write_result(
|
||||
host: &str,
|
||||
port: u16,
|
||||
protocol: &str,
|
||||
direction: &str,
|
||||
duration_secs: u64,
|
||||
tx_bytes: u64,
|
||||
rx_bytes: u64,
|
||||
lost_packets: u64,
|
||||
local_cpu: u8,
|
||||
remote_cpu: u8,
|
||||
auth_type: &str,
|
||||
) {
|
||||
let guard = CSV_FILE.lock().unwrap();
|
||||
if let Some(ref path) = *guard {
|
||||
let tx_mbps = if duration_secs > 0 {
|
||||
tx_bytes as f64 * 8.0 / duration_secs as f64 / 1_000_000.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let rx_mbps = if duration_secs > 0 {
|
||||
rx_bytes as f64 * 8.0 / duration_secs as f64 / 1_000_000.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let now = SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
|
||||
let row = format!(
|
||||
"{},{},{},{},{},{},{:.2},{:.2},{},{},{},{},{},{}",
|
||||
now, host, port, protocol, direction, duration_secs,
|
||||
tx_mbps, rx_mbps, tx_bytes, rx_bytes, lost_packets,
|
||||
local_cpu, remote_cpu, auth_type,
|
||||
);
|
||||
|
||||
if let Ok(mut f) = OpenOptions::new().append(true).open(path) {
|
||||
let _ = writeln!(f, "{}", row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if CSV output is enabled.
|
||||
pub fn is_enabled() -> bool {
|
||||
CSV_FILE.lock().unwrap().is_some()
|
||||
}
|
||||
659
src/ecsrp5.rs
Normal file
659
src/ecsrp5.rs
Normal file
@@ -0,0 +1,659 @@
|
||||
//! EC-SRP5 authentication for MikroTik RouterOS >= 6.43.
|
||||
//!
|
||||
//! Implements the Curve25519-Weierstrass EC-SRP5 protocol used by MikroTik btest.
|
||||
//! Based on research by Margin Research (Apache-2.0 License):
|
||||
//! https://github.com/MarginResearch/mikrotik_authentication
|
||||
//!
|
||||
//! btest framing: `[len:1][payload]` (no 0x06 handler byte, unlike Winbox).
|
||||
|
||||
use num_bigint::BigUint;
|
||||
use num_integer::Integer;
|
||||
use num_traits::{One, Zero};
|
||||
use sha2::{Digest, Sha256};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
use crate::protocol::{BtestError, Result};
|
||||
|
||||
// --- Curve25519 parameters in Weierstrass form ---
|
||||
|
||||
fn p() -> BigUint {
|
||||
BigUint::parse_bytes(
|
||||
b"7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffed",
|
||||
16,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn curve_order() -> BigUint {
|
||||
BigUint::parse_bytes(
|
||||
b"1000000000000000000000000000000014def9dea2f79cd65812631a5cf5d3ed",
|
||||
16,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn weierstrass_a() -> BigUint {
|
||||
BigUint::parse_bytes(
|
||||
b"2aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa984914a144",
|
||||
16,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
const MONT_A: u64 = 486662;
|
||||
|
||||
// --- Modular arithmetic ---
|
||||
|
||||
fn modinv(a: &BigUint, modulus: &BigUint) -> BigUint {
|
||||
// Fermat's little theorem: a^(p-2) mod p
|
||||
let exp = modulus - BigUint::from(2u32);
|
||||
a.modpow(&exp, modulus)
|
||||
}
|
||||
|
||||
fn legendre_symbol(a: &BigUint, p_val: &BigUint) -> i32 {
|
||||
let exp = (p_val - BigUint::one()) / BigUint::from(2u32);
|
||||
let l = a.modpow(&exp, p_val);
|
||||
if l == p_val - BigUint::one() {
|
||||
-1
|
||||
} else if l == BigUint::zero() {
|
||||
0
|
||||
} else {
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
fn prime_mod_sqrt(a: &BigUint, p_val: &BigUint) -> Option<(BigUint, BigUint)> {
|
||||
let a = a % p_val;
|
||||
if a.is_zero() {
|
||||
return Some((BigUint::zero(), BigUint::zero()));
|
||||
}
|
||||
if legendre_symbol(&a, p_val) != 1 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// For p ≡ 5 (mod 8) — which is Curve25519's case — use Atkin's algorithm
|
||||
// This is more reliable than Tonelli-Shanks for this specific case
|
||||
let p_mod_8 = p_val % BigUint::from(8u32);
|
||||
if p_mod_8 == BigUint::from(5u32) {
|
||||
// v = (2a)^((p-5)/8) mod p
|
||||
let exp = (p_val - BigUint::from(5u32)) / BigUint::from(8u32);
|
||||
let two_a = (BigUint::from(2u32) * &a) % p_val;
|
||||
let v = two_a.modpow(&exp, p_val);
|
||||
// i = 2 * a * v^2 mod p
|
||||
let i_val = (BigUint::from(2u32) * &a % p_val * &v % p_val * &v) % p_val;
|
||||
// x = a * v * (i - 1) mod p
|
||||
let i_minus_1 = if i_val >= BigUint::one() {
|
||||
(&i_val - BigUint::one()) % p_val
|
||||
} else {
|
||||
(p_val - BigUint::one() + &i_val) % p_val
|
||||
};
|
||||
let x = (&a * &v % p_val * &i_minus_1) % p_val;
|
||||
// Verify: x^2 ≡ a (mod p)
|
||||
let check = (&x * &x) % p_val;
|
||||
if check == a {
|
||||
let other = p_val - &x;
|
||||
return Some((x, other));
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
||||
if p_mod_8 == BigUint::from(3u32) || p_mod_8 == BigUint::from(7u32) {
|
||||
let exp = (p_val + BigUint::one()) / BigUint::from(4u32);
|
||||
let x = a.modpow(&exp, p_val);
|
||||
let other = p_val - &x;
|
||||
return Some((x, other));
|
||||
}
|
||||
|
||||
// General Tonelli-Shanks for other primes
|
||||
let mut q = p_val - BigUint::one();
|
||||
let mut s = 0u32;
|
||||
while q.is_even() {
|
||||
s += 1;
|
||||
q >>= 1;
|
||||
}
|
||||
|
||||
let mut z = BigUint::from(2u32);
|
||||
while legendre_symbol(&z, p_val) != -1 {
|
||||
z += BigUint::one();
|
||||
}
|
||||
let mut c = z.modpow(&q, p_val);
|
||||
let mut x = a.modpow(&((&q + BigUint::one()) / BigUint::from(2u32)), p_val);
|
||||
let mut t = a.modpow(&q, p_val);
|
||||
let mut m = s;
|
||||
|
||||
while t != BigUint::one() {
|
||||
let mut i = 1u32;
|
||||
let mut tmp = (&t * &t) % p_val;
|
||||
while tmp != BigUint::one() {
|
||||
tmp = (&tmp * &tmp) % p_val;
|
||||
i += 1;
|
||||
}
|
||||
let b = c.modpow(&BigUint::from(1u32 << (m - i - 1)), p_val);
|
||||
x = (&x * &b) % p_val;
|
||||
t = ((&t * &b % p_val) * &b) % p_val;
|
||||
c = (&b * &b) % p_val;
|
||||
m = i;
|
||||
}
|
||||
|
||||
let other = p_val - &x;
|
||||
Some((x, other))
|
||||
}
|
||||
|
||||
// --- Weierstrass curve point ---
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Point {
|
||||
x: BigUint,
|
||||
y: BigUint,
|
||||
infinity: bool,
|
||||
}
|
||||
|
||||
impl Point {
|
||||
fn infinity() -> Self {
|
||||
Self {
|
||||
x: BigUint::zero(),
|
||||
y: BigUint::zero(),
|
||||
infinity: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn new(x: BigUint, y: BigUint) -> Self {
|
||||
Self {
|
||||
x,
|
||||
y,
|
||||
infinity: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn add(&self, other: &Point) -> Point {
|
||||
let p_val = p();
|
||||
if self.infinity {
|
||||
return other.clone();
|
||||
}
|
||||
if other.infinity {
|
||||
return self.clone();
|
||||
}
|
||||
if self.x == other.x && self.y != other.y {
|
||||
return Point::infinity();
|
||||
}
|
||||
|
||||
let lam = if self.x == other.x && self.y == other.y {
|
||||
// Point doubling
|
||||
let three_x_sq = (BigUint::from(3u32) * &self.x * &self.x + &weierstrass_a()) % &p_val;
|
||||
let two_y = (BigUint::from(2u32) * &self.y) % &p_val;
|
||||
(three_x_sq * modinv(&two_y, &p_val)) % &p_val
|
||||
} else {
|
||||
// Point addition
|
||||
let dy = if other.y >= self.y {
|
||||
(&other.y - &self.y) % &p_val
|
||||
} else {
|
||||
(&p_val - (&self.y - &other.y) % &p_val) % &p_val
|
||||
};
|
||||
let dx = if other.x >= self.x {
|
||||
(&other.x - &self.x) % &p_val
|
||||
} else {
|
||||
(&p_val - (&self.x - &other.x) % &p_val) % &p_val
|
||||
};
|
||||
(dy * modinv(&dx, &p_val)) % &p_val
|
||||
};
|
||||
|
||||
let x3 = {
|
||||
let lam_sq = (&lam * &lam) % &p_val;
|
||||
let sum_x = (&self.x + &other.x) % &p_val;
|
||||
if lam_sq >= sum_x {
|
||||
(lam_sq - sum_x) % &p_val
|
||||
} else {
|
||||
(&p_val - (sum_x - lam_sq) % &p_val) % &p_val
|
||||
}
|
||||
};
|
||||
let y3 = {
|
||||
let dx = if self.x >= x3 {
|
||||
(&self.x - &x3) % &p_val
|
||||
} else {
|
||||
(&p_val - (&x3 - &self.x) % &p_val) % &p_val
|
||||
};
|
||||
let prod = (&lam * dx) % &p_val;
|
||||
if prod >= self.y {
|
||||
(prod - &self.y) % &p_val
|
||||
} else {
|
||||
(&p_val - (&self.y - prod) % &p_val) % &p_val
|
||||
}
|
||||
};
|
||||
|
||||
Point::new(x3, y3)
|
||||
}
|
||||
|
||||
fn scalar_mul(&self, scalar: &BigUint) -> Point {
|
||||
let mut result = Point::infinity();
|
||||
let mut base = self.clone();
|
||||
let mut k = scalar.clone();
|
||||
|
||||
while !k.is_zero() {
|
||||
if &k & &BigUint::one() == BigUint::one() {
|
||||
result = result.add(&base);
|
||||
}
|
||||
base = base.add(&base);
|
||||
k >>= 1;
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
// --- WCurve: Curve25519 in Weierstrass form ---
|
||||
|
||||
struct WCurve {
|
||||
g: Point,
|
||||
conversion_from_m: BigUint,
|
||||
conversion_to_m: BigUint,
|
||||
}
|
||||
|
||||
impl WCurve {
|
||||
fn new() -> Self {
|
||||
let p_val = p();
|
||||
let mont_a = BigUint::from(MONT_A);
|
||||
let three_inv = modinv(&BigUint::from(3u32), &p_val);
|
||||
let conversion_from_m = (&mont_a * &three_inv) % &p_val;
|
||||
let conversion_to_m = (&p_val - &conversion_from_m) % &p_val;
|
||||
|
||||
let mut curve = WCurve {
|
||||
g: Point::infinity(),
|
||||
conversion_from_m,
|
||||
conversion_to_m,
|
||||
};
|
||||
curve.g = curve.lift_x(&BigUint::from(9u32), false);
|
||||
curve
|
||||
}
|
||||
|
||||
fn to_montgomery(&self, pt: &Point) -> ([u8; 32], u8) {
|
||||
let p_val = p();
|
||||
let x = (&pt.x + &self.conversion_to_m) % &p_val;
|
||||
let parity = if pt.y.bit(0) { 1u8 } else { 0u8 };
|
||||
let mut bytes = [0u8; 32];
|
||||
let x_bytes = x.to_bytes_be();
|
||||
let start = 32 - x_bytes.len().min(32);
|
||||
bytes[start..].copy_from_slice(&x_bytes[..x_bytes.len().min(32)]);
|
||||
(bytes, parity)
|
||||
}
|
||||
|
||||
fn lift_x(&self, x_mont: &BigUint, parity: bool) -> Point {
|
||||
let p_val = p();
|
||||
let x = x_mont % &p_val;
|
||||
// y^2 = x^3 + Ax^2 + x (Montgomery)
|
||||
let y_squared = (&x * &x * &x + BigUint::from(MONT_A) * &x * &x + &x) % &p_val;
|
||||
// Convert x to Weierstrass
|
||||
let x_w = (&x + &self.conversion_from_m) % &p_val;
|
||||
|
||||
if let Some((y1, y2)) = prime_mod_sqrt(&y_squared, &p_val) {
|
||||
let pt1 = Point::new(x_w.clone(), y1);
|
||||
let pt2 = Point::new(x_w, y2);
|
||||
if parity {
|
||||
if pt1.y.bit(0) { pt1 } else { pt2 }
|
||||
} else {
|
||||
if !pt1.y.bit(0) { pt1 } else { pt2 }
|
||||
}
|
||||
} else {
|
||||
Point::infinity()
|
||||
}
|
||||
}
|
||||
|
||||
fn gen_public_key(&self, priv_key: &[u8; 32]) -> ([u8; 32], u8) {
|
||||
let scalar = BigUint::from_bytes_be(priv_key);
|
||||
let pt = self.g.scalar_mul(&scalar);
|
||||
self.to_montgomery(&pt)
|
||||
}
|
||||
|
||||
fn redp1(&self, x_bytes: &[u8; 32], parity: bool) -> Point {
|
||||
let mut x = sha256_bytes(x_bytes);
|
||||
loop {
|
||||
let x2 = sha256_bytes(&x);
|
||||
let x_int = BigUint::from_bytes_be(&x2);
|
||||
let pt = self.lift_x(&x_int, parity);
|
||||
if !pt.infinity {
|
||||
return pt;
|
||||
}
|
||||
let mut val = BigUint::from_bytes_be(&x);
|
||||
val += BigUint::one();
|
||||
x = bigint_to_32bytes(&val);
|
||||
}
|
||||
}
|
||||
|
||||
fn gen_password_validator_priv(
|
||||
&self,
|
||||
username: &str,
|
||||
password: &str,
|
||||
salt: &[u8; 16],
|
||||
) -> [u8; 32] {
|
||||
let inner = sha256_bytes(&format!("{}:{}", username, password).as_bytes().to_vec());
|
||||
let mut input = Vec::with_capacity(16 + 32);
|
||||
input.extend_from_slice(salt);
|
||||
input.extend_from_slice(&inner);
|
||||
sha256_bytes(&input)
|
||||
}
|
||||
}
|
||||
|
||||
fn sha256_bytes(data: &[u8]) -> [u8; 32] {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(data);
|
||||
let result = hasher.finalize();
|
||||
let mut out = [0u8; 32];
|
||||
out.copy_from_slice(&result);
|
||||
out
|
||||
}
|
||||
|
||||
fn bigint_to_32bytes(val: &BigUint) -> [u8; 32] {
|
||||
let bytes = val.to_bytes_be();
|
||||
let mut out = [0u8; 32];
|
||||
let start = 32usize.saturating_sub(bytes.len());
|
||||
let copy_len = bytes.len().min(32);
|
||||
out[start..start + copy_len].copy_from_slice(&bytes[bytes.len() - copy_len..]);
|
||||
out
|
||||
}
|
||||
|
||||
// --- EC-SRP5 Client Authentication ---
|
||||
|
||||
/// Perform EC-SRP5 authentication as a client.
|
||||
/// Called after receiving `03 00 00 00` from the server.
|
||||
pub async fn client_authenticate<S: AsyncReadExt + AsyncWriteExt + Unpin>(
|
||||
stream: &mut S,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<()> {
|
||||
tracing::info!("Starting EC-SRP5 authentication");
|
||||
let w = WCurve::new();
|
||||
|
||||
// Generate client ephemeral keypair
|
||||
let s_a: [u8; 32] = rand::random();
|
||||
let (x_w_a, x_w_a_parity) = w.gen_public_key(&s_a);
|
||||
|
||||
// MSG1: [len][username\0][pubkey:32][parity:1]
|
||||
let mut payload = Vec::new();
|
||||
payload.extend_from_slice(username.as_bytes());
|
||||
payload.push(0x00);
|
||||
payload.extend_from_slice(&x_w_a);
|
||||
payload.push(x_w_a_parity);
|
||||
let mut msg1 = vec![payload.len() as u8];
|
||||
msg1.extend_from_slice(&payload);
|
||||
stream.write_all(&msg1).await?;
|
||||
stream.flush().await?;
|
||||
tracing::debug!("EC-SRP5: sent client pubkey ({} bytes)", msg1.len());
|
||||
|
||||
// MSG2: [len][server_pubkey:32][parity:1][salt:16]
|
||||
let mut resp_header = [0u8; 1];
|
||||
stream.read_exact(&mut resp_header).await?;
|
||||
let resp_len = resp_header[0] as usize;
|
||||
let mut resp_data = vec![0u8; resp_len];
|
||||
stream.read_exact(&mut resp_data).await?;
|
||||
|
||||
if resp_data.len() < 49 {
|
||||
return Err(BtestError::Protocol(format!(
|
||||
"EC-SRP5: server challenge too short ({} bytes)",
|
||||
resp_data.len()
|
||||
)));
|
||||
}
|
||||
|
||||
let mut x_w_b = [0u8; 32];
|
||||
x_w_b.copy_from_slice(&resp_data[0..32]);
|
||||
let x_w_b_parity = resp_data[32] != 0;
|
||||
let mut salt = [0u8; 16];
|
||||
salt.copy_from_slice(&resp_data[33..49]);
|
||||
|
||||
tracing::debug!("EC-SRP5: received server challenge (salt={})", hex::encode(&salt));
|
||||
|
||||
// Compute shared secret
|
||||
let i = w.gen_password_validator_priv(username, password, &salt);
|
||||
let (x_gamma, _) = w.gen_public_key(&i);
|
||||
let v = w.redp1(&x_gamma, true);
|
||||
|
||||
let w_b_point = w.lift_x(&BigUint::from_bytes_be(&x_w_b), x_w_b_parity);
|
||||
let w_b_unblinded = w_b_point.add(&v);
|
||||
|
||||
let mut j_input = Vec::with_capacity(64);
|
||||
j_input.extend_from_slice(&x_w_a);
|
||||
j_input.extend_from_slice(&x_w_b);
|
||||
let j = sha256_bytes(&j_input);
|
||||
|
||||
let i_int = BigUint::from_bytes_be(&i);
|
||||
let j_int = BigUint::from_bytes_be(&j);
|
||||
let s_a_int = BigUint::from_bytes_be(&s_a);
|
||||
let order = curve_order();
|
||||
let scalar = ((&i_int * &j_int) + &s_a_int) % ℴ
|
||||
|
||||
let z_point = w_b_unblinded.scalar_mul(&scalar);
|
||||
let (z, _) = w.to_montgomery(&z_point);
|
||||
|
||||
// MSG3: [len][client_cc:32]
|
||||
let mut cc_input = Vec::with_capacity(64);
|
||||
cc_input.extend_from_slice(&j);
|
||||
cc_input.extend_from_slice(&z);
|
||||
let client_cc = sha256_bytes(&cc_input);
|
||||
|
||||
let mut msg3 = vec![client_cc.len() as u8];
|
||||
msg3.extend_from_slice(&client_cc);
|
||||
stream.write_all(&msg3).await?;
|
||||
stream.flush().await?;
|
||||
tracing::debug!("EC-SRP5: sent client proof");
|
||||
|
||||
// MSG4: [len][server_cc:32]
|
||||
let mut resp4_header = [0u8; 1];
|
||||
stream.read_exact(&mut resp4_header).await?;
|
||||
let resp4_len = resp4_header[0] as usize;
|
||||
let mut server_cc_received = vec![0u8; resp4_len];
|
||||
stream.read_exact(&mut server_cc_received).await?;
|
||||
|
||||
// Verify server confirmation
|
||||
let mut sc_input = Vec::with_capacity(96);
|
||||
sc_input.extend_from_slice(&j);
|
||||
sc_input.extend_from_slice(&client_cc);
|
||||
sc_input.extend_from_slice(&z);
|
||||
let server_cc_expected = sha256_bytes(&sc_input);
|
||||
|
||||
if server_cc_received == server_cc_expected {
|
||||
tracing::info!("EC-SRP5 authentication successful");
|
||||
Ok(())
|
||||
} else {
|
||||
// Check if server sent an error message
|
||||
if let Ok(msg) = std::str::from_utf8(&server_cc_received) {
|
||||
Err(BtestError::Protocol(format!(
|
||||
"EC-SRP5 authentication failed: {}",
|
||||
msg
|
||||
)))
|
||||
} else {
|
||||
Err(BtestError::AuthFailed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- EC-SRP5 Server Authentication ---
|
||||
|
||||
/// Server-side EC-SRP5 credential store.
|
||||
pub struct EcSrp5Credentials {
|
||||
salt: [u8; 16],
|
||||
x_gamma: [u8; 32],
|
||||
gamma_parity: bool,
|
||||
}
|
||||
|
||||
impl EcSrp5Credentials {
|
||||
/// Derive EC-SRP5 credentials from username/password (done once at startup).
|
||||
pub fn derive(username: &str, password: &str) -> Self {
|
||||
let salt: [u8; 16] = rand::random();
|
||||
let w = WCurve::new();
|
||||
let i = w.gen_password_validator_priv(username, password, &salt);
|
||||
let (x_gamma, parity) = w.gen_public_key(&i);
|
||||
Self {
|
||||
salt,
|
||||
x_gamma,
|
||||
gamma_parity: parity != 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform EC-SRP5 authentication as a server.
|
||||
/// Called after sending `03 00 00 00` to the client.
|
||||
pub async fn server_authenticate<S: AsyncReadExt + AsyncWriteExt + Unpin>(
|
||||
stream: &mut S,
|
||||
username: &str,
|
||||
creds: &EcSrp5Credentials,
|
||||
) -> Result<()> {
|
||||
tracing::info!("Starting EC-SRP5 server authentication");
|
||||
let w = WCurve::new();
|
||||
|
||||
// MSG1: read [len][username\0][pubkey:32][parity:1]
|
||||
let mut len_buf = [0u8; 1];
|
||||
stream.read_exact(&mut len_buf).await?;
|
||||
let msg_len = len_buf[0] as usize;
|
||||
let mut msg1_data = vec![0u8; msg_len];
|
||||
stream.read_exact(&mut msg1_data).await?;
|
||||
|
||||
// Parse username
|
||||
let null_pos = msg1_data.iter().position(|&b| b == 0)
|
||||
.ok_or_else(|| BtestError::Protocol("EC-SRP5: no null terminator in username".into()))?;
|
||||
let client_username = std::str::from_utf8(&msg1_data[..null_pos])
|
||||
.map_err(|_| BtestError::Protocol("EC-SRP5: invalid username encoding".into()))?;
|
||||
|
||||
if client_username != username {
|
||||
tracing::warn!("EC-SRP5: username mismatch (got '{}')", client_username);
|
||||
return Err(BtestError::AuthFailed);
|
||||
}
|
||||
|
||||
let key_start = null_pos + 1;
|
||||
if msg1_data.len() < key_start + 33 {
|
||||
return Err(BtestError::Protocol("EC-SRP5: client message too short".into()));
|
||||
}
|
||||
let mut x_w_a = [0u8; 32];
|
||||
x_w_a.copy_from_slice(&msg1_data[key_start..key_start + 32]);
|
||||
let x_w_a_parity = msg1_data[key_start + 32] != 0;
|
||||
|
||||
tracing::debug!("EC-SRP5: received client pubkey from '{}'", client_username);
|
||||
|
||||
// Generate server ephemeral keypair
|
||||
let s_b: [u8; 32] = rand::random();
|
||||
let s_b_int = BigUint::from_bytes_be(&s_b);
|
||||
let pub_b = w.g.scalar_mul(&s_b_int);
|
||||
|
||||
// Compute password-entangled public key: W_b = s_b*G + redp1(x_gamma, 0)
|
||||
let gamma = w.redp1(&creds.x_gamma, false);
|
||||
let w_b = pub_b.add(&gamma);
|
||||
let (x_w_b, x_w_b_parity) = w.to_montgomery(&w_b);
|
||||
|
||||
// MSG2: [len][server_pubkey:32][parity:1][salt:16]
|
||||
let mut payload2 = Vec::with_capacity(49);
|
||||
payload2.extend_from_slice(&x_w_b);
|
||||
payload2.push(x_w_b_parity);
|
||||
payload2.extend_from_slice(&creds.salt);
|
||||
let mut msg2 = vec![payload2.len() as u8];
|
||||
msg2.extend_from_slice(&payload2);
|
||||
stream.write_all(&msg2).await?;
|
||||
stream.flush().await?;
|
||||
tracing::debug!("EC-SRP5: sent server challenge");
|
||||
|
||||
// Compute shared secret (server side: ECPESVDP-SRP-B)
|
||||
let mut j_input = Vec::with_capacity(64);
|
||||
j_input.extend_from_slice(&x_w_a);
|
||||
j_input.extend_from_slice(&x_w_b);
|
||||
let j = sha256_bytes(&j_input);
|
||||
let j_int = BigUint::from_bytes_be(&j);
|
||||
|
||||
// Server ECPESVDP-SRP-B: Z = s_b * (W_a + j * gamma)
|
||||
// gamma = lift_x(x_gamma, parity=1) — the raw validator public key point
|
||||
// (NOT redp1 — that's used for blinding W_b, not for verification)
|
||||
let w_a = w.lift_x(&BigUint::from_bytes_be(&x_w_a), x_w_a_parity);
|
||||
let gamma = w.lift_x(&BigUint::from_bytes_be(&creds.x_gamma), creds.gamma_parity);
|
||||
let j_gamma = gamma.scalar_mul(&j_int);
|
||||
let sum = w_a.add(&j_gamma);
|
||||
let z_point = sum.scalar_mul(&s_b_int);
|
||||
let (z, _) = w.to_montgomery(&z_point);
|
||||
|
||||
// MSG3: read [len][client_cc:32]
|
||||
let mut len3 = [0u8; 1];
|
||||
stream.read_exact(&mut len3).await?;
|
||||
let mut client_cc = vec![0u8; len3[0] as usize];
|
||||
stream.read_exact(&mut client_cc).await?;
|
||||
|
||||
// Verify client confirmation
|
||||
let mut cc_input = Vec::with_capacity(64);
|
||||
cc_input.extend_from_slice(&j);
|
||||
cc_input.extend_from_slice(&z);
|
||||
let expected_cc = sha256_bytes(&cc_input);
|
||||
|
||||
if client_cc != expected_cc {
|
||||
tracing::warn!("EC-SRP5: client proof mismatch");
|
||||
return Err(BtestError::AuthFailed);
|
||||
}
|
||||
|
||||
// MSG4: [len][server_cc:32]
|
||||
let mut sc_input = Vec::with_capacity(96);
|
||||
sc_input.extend_from_slice(&j);
|
||||
sc_input.extend_from_slice(&client_cc);
|
||||
sc_input.extend_from_slice(&z);
|
||||
let server_cc = sha256_bytes(&sc_input);
|
||||
|
||||
let mut msg4 = vec![server_cc.len() as u8];
|
||||
msg4.extend_from_slice(&server_cc);
|
||||
stream.write_all(&msg4).await?;
|
||||
stream.flush().await?;
|
||||
|
||||
tracing::info!("EC-SRP5 server authentication successful for '{}'", client_username);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
mod hex {
|
||||
pub fn encode(data: &[u8]) -> String {
|
||||
data.iter().map(|b| format!("{:02x}", b)).collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_curve_generator() {
|
||||
let w = WCurve::new();
|
||||
assert!(!w.g.infinity);
|
||||
// Generator from lift_x(9, false) should produce a valid point
|
||||
let (x_mont, _) = w.to_montgomery(&w.g);
|
||||
let x_int = BigUint::from_bytes_be(&x_mont);
|
||||
assert_eq!(x_int, BigUint::from(9u32));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pubkey_generation() {
|
||||
let w = WCurve::new();
|
||||
let priv_key = [1u8; 32];
|
||||
let (pubkey, parity) = w.gen_public_key(&priv_key);
|
||||
assert_ne!(pubkey, [0u8; 32]);
|
||||
assert!(parity <= 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_password_validator() {
|
||||
let w = WCurve::new();
|
||||
let salt = [0x01u8, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
|
||||
0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10];
|
||||
let i = w.gen_password_validator_priv("testuser", "testpass", &salt);
|
||||
assert_ne!(i, [0u8; 32]);
|
||||
// Deterministic: same inputs produce same output
|
||||
let i2 = w.gen_password_validator_priv("testuser", "testpass", &salt);
|
||||
assert_eq!(i, i2);
|
||||
// Different password produces different result
|
||||
let i3 = w.gen_password_validator_priv("testuser", "other", &salt);
|
||||
assert_ne!(i, i3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_redp1() {
|
||||
let w = WCurve::new();
|
||||
let input = [42u8; 32];
|
||||
let pt = w.redp1(&input, false);
|
||||
assert!(!pt.infinity);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scalar_mul_identity() {
|
||||
let w = WCurve::new();
|
||||
let one = BigUint::one();
|
||||
let pt = w.g.scalar_mul(&one);
|
||||
assert_eq!(pt.x, w.g.x);
|
||||
assert_eq!(pt.y, w.g.y);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
pub mod auth;
|
||||
pub mod bandwidth;
|
||||
pub mod client;
|
||||
pub mod cpu;
|
||||
pub mod csv_output;
|
||||
pub mod ecsrp5;
|
||||
pub mod protocol;
|
||||
pub mod server;
|
||||
pub mod syslog_logger;
|
||||
|
||||
117
src/main.rs
117
src/main.rs
@@ -1,8 +1,12 @@
|
||||
mod auth;
|
||||
mod bandwidth;
|
||||
mod client;
|
||||
mod cpu;
|
||||
pub mod csv_output;
|
||||
mod ecsrp5;
|
||||
mod protocol;
|
||||
mod server;
|
||||
pub mod syslog_logger;
|
||||
|
||||
use clap::Parser;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
@@ -48,6 +52,14 @@ struct Cli {
|
||||
#[arg(short = 'P', long = "port", default_value_t = BTEST_PORT)]
|
||||
port: u16,
|
||||
|
||||
/// Listen address for IPv4 (default: 0.0.0.0, use "none" to disable)
|
||||
#[arg(long = "listen", default_value = "0.0.0.0")]
|
||||
listen_addr: String,
|
||||
|
||||
/// Enable IPv6 listener (experimental — TCP works, UDP has issues on macOS)
|
||||
#[arg(long = "listen6", default_missing_value = "::", num_args = 0..=1)]
|
||||
listen6_addr: Option<String>,
|
||||
|
||||
/// Authentication username
|
||||
#[arg(short = 'a', long = "authuser")]
|
||||
auth_user: Option<String>,
|
||||
@@ -56,10 +68,30 @@ struct Cli {
|
||||
#[arg(short = 'p', long = "authpass")]
|
||||
auth_pass: Option<String>,
|
||||
|
||||
/// Use EC-SRP5 authentication (RouterOS >= 6.43 compatible)
|
||||
#[arg(long = "ecsrp5")]
|
||||
ecsrp5: bool,
|
||||
|
||||
/// NAT mode - send probe packet to open firewall
|
||||
#[arg(short = 'n', long = "nat")]
|
||||
nat: bool,
|
||||
|
||||
/// Test duration in seconds (client mode, 0=unlimited)
|
||||
#[arg(short = 'd', long = "duration", default_value_t = 0)]
|
||||
duration: u64,
|
||||
|
||||
/// Output results to CSV file (appends if exists)
|
||||
#[arg(long = "csv")]
|
||||
csv: Option<String>,
|
||||
|
||||
/// Suppress terminal output (use with --csv for machine-readable only)
|
||||
#[arg(long = "quiet", short = 'q')]
|
||||
quiet: bool,
|
||||
|
||||
/// Send logs to remote syslog server (e.g., 192.168.1.1:514)
|
||||
#[arg(long = "syslog")]
|
||||
syslog: Option<String>,
|
||||
|
||||
/// Verbose logging (repeat for more: -v, -vv, -vvv)
|
||||
#[arg(short = 'v', long = "verbose", action = clap::ArgAction::Count)]
|
||||
verbose: u8,
|
||||
@@ -69,6 +101,9 @@ struct Cli {
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
// Start CPU usage sampler
|
||||
cpu::start_sampler();
|
||||
|
||||
// Set up logging based on verbosity
|
||||
let filter = match cli.verbose {
|
||||
0 => "info",
|
||||
@@ -82,10 +117,27 @@ async fn main() -> anyhow::Result<()> {
|
||||
.with_target(false)
|
||||
.init();
|
||||
|
||||
// Initialize syslog if requested
|
||||
if let Some(ref syslog_addr) = cli.syslog {
|
||||
if let Err(e) = syslog_logger::init(syslog_addr) {
|
||||
eprintln!("Warning: failed to initialize syslog to {}: {}", syslog_addr, e);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize CSV output if requested
|
||||
if let Some(ref csv_path) = cli.csv {
|
||||
if let Err(e) = csv_output::init(csv_path) {
|
||||
eprintln!("Warning: failed to initialize CSV output to {}: {}", csv_path, e);
|
||||
}
|
||||
}
|
||||
csv_output::set_quiet(cli.quiet);
|
||||
|
||||
if cli.server {
|
||||
// Server mode
|
||||
let v4 = if cli.listen_addr.eq_ignore_ascii_case("none") { None } else { Some(cli.listen_addr) };
|
||||
let v6 = cli.listen6_addr; // None unless --listen6 is passed
|
||||
tracing::info!("Starting btest server on port {}", cli.port);
|
||||
server::run_server(cli.port, cli.auth_user, cli.auth_pass).await?;
|
||||
server::run_server(cli.port, cli.auth_user, cli.auth_pass, cli.ecsrp5, v4, v6).await?;
|
||||
} else if let Some(host) = cli.client {
|
||||
// Client mode - must specify at least one direction
|
||||
if !cli.transmit && !cli.receive {
|
||||
@@ -116,18 +168,71 @@ async fn main() -> anyhow::Result<()> {
|
||||
_ => (0, 0),
|
||||
};
|
||||
|
||||
client::run_client(
|
||||
let dir_str = match direction {
|
||||
CMD_DIR_RX => "send",
|
||||
CMD_DIR_TX => "receive",
|
||||
CMD_DIR_BOTH => "both",
|
||||
_ => "unknown",
|
||||
};
|
||||
let proto_str = if cli.udp { "UDP" } else { "TCP" };
|
||||
|
||||
// Create shared state that survives timeout cancellation
|
||||
let shared_state = bandwidth::BandwidthState::new();
|
||||
|
||||
// Log test start
|
||||
syslog_logger::test_start(&host, proto_str, dir_str, 0);
|
||||
|
||||
// Run client with optional duration timeout
|
||||
let start = std::time::Instant::now();
|
||||
let client_fut = client::run_client(
|
||||
&host,
|
||||
cli.port,
|
||||
direction,
|
||||
cli.udp,
|
||||
tx_speed,
|
||||
rx_speed,
|
||||
cli.auth_user,
|
||||
cli.auth_pass,
|
||||
cli.auth_user.clone(),
|
||||
cli.auth_pass.clone(),
|
||||
cli.nat,
|
||||
)
|
||||
.await?;
|
||||
shared_state.clone(),
|
||||
);
|
||||
|
||||
if cli.duration > 0 {
|
||||
match tokio::time::timeout(
|
||||
std::time::Duration::from_secs(cli.duration),
|
||||
client_fut,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => { let _ = result?; },
|
||||
Err(_) => {
|
||||
// Timeout — signal stop
|
||||
shared_state.running.store(false, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let _ = client_fut.await?;
|
||||
}
|
||||
|
||||
let elapsed = start.elapsed().as_secs();
|
||||
let (total_tx, total_rx, total_lost, _intervals) = shared_state.summary();
|
||||
|
||||
// Log test end to syslog
|
||||
syslog_logger::test_end(
|
||||
&host, proto_str, dir_str,
|
||||
total_tx, total_rx, total_lost, elapsed as u32,
|
||||
);
|
||||
|
||||
// Write CSV if enabled
|
||||
if csv_output::is_enabled() {
|
||||
let auth_type = if cli.auth_user.is_some() { "auth" } else { "none" };
|
||||
let local_cpu = cpu::get();
|
||||
let remote_cpu = shared_state.remote_cpu.load(std::sync::atomic::Ordering::Relaxed);
|
||||
csv_output::write_result(
|
||||
&host, cli.port, proto_str, dir_str,
|
||||
elapsed, total_tx, total_rx, total_lost, local_cpu, remote_cpu, auth_type,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
eprintln!("Error: Must specify either -s (server) or -c <host> (client)");
|
||||
eprintln!("Run with --help for usage information.");
|
||||
|
||||
@@ -137,23 +137,31 @@ impl Command {
|
||||
pub struct StatusMessage {
|
||||
pub seq: u32,
|
||||
pub bytes_received: u32,
|
||||
pub cpu_load: u8,
|
||||
}
|
||||
|
||||
impl StatusMessage {
|
||||
pub fn serialize(&self) -> [u8; STATUS_MSG_SIZE] {
|
||||
let mut buf = [0u8; STATUS_MSG_SIZE];
|
||||
buf[0] = STATUS_MSG_TYPE;
|
||||
buf[1..5].copy_from_slice(&self.seq.to_be_bytes());
|
||||
buf[5] = 0;
|
||||
buf[6] = 0;
|
||||
buf[7] = 0;
|
||||
// Byte 1: CPU load with high bit set (MikroTik format: 0x80 | percentage)
|
||||
buf[1] = 0x80 | (self.cpu_load & 0x7F);
|
||||
buf[2] = 0;
|
||||
buf[3] = 0;
|
||||
// Bytes 4-7: sequence number (LE)
|
||||
buf[4..8].copy_from_slice(&self.seq.to_le_bytes());
|
||||
// Bytes 8-11: bytes received (LE)
|
||||
buf[8..12].copy_from_slice(&self.bytes_received.to_le_bytes());
|
||||
buf
|
||||
}
|
||||
|
||||
pub fn deserialize(buf: &[u8; STATUS_MSG_SIZE]) -> Self {
|
||||
// MikroTik encodes CPU with high bit set: actual = byte & 0x7F
|
||||
let raw_cpu = buf[1];
|
||||
let cpu = if raw_cpu > 128 { raw_cpu & 0x7F } else { raw_cpu };
|
||||
Self {
|
||||
seq: u32::from_be_bytes([buf[1], buf[2], buf[3], buf[4]]),
|
||||
cpu_load: cpu.min(100),
|
||||
seq: u32::from_le_bytes([buf[4], buf[5], buf[6], buf[7]]),
|
||||
bytes_received: u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]),
|
||||
}
|
||||
}
|
||||
@@ -188,6 +196,7 @@ pub async fn send_command<W: AsyncWriteExt + Unpin>(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn recv_command<R: AsyncReadExt + Unpin>(reader: &mut R) -> Result<Command> {
|
||||
let mut buf = [0u8; 16];
|
||||
reader.read_exact(&mut buf).await?;
|
||||
|
||||
651
src/server.rs
651
src/server.rs
@@ -1,3 +1,4 @@
|
||||
use std::collections::HashMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::Arc;
|
||||
@@ -5,33 +6,119 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream, UdpSocket};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::auth;
|
||||
use crate::bandwidth::{self, BandwidthState};
|
||||
use crate::protocol::*;
|
||||
|
||||
/// Pending TCP multi-connection session: first connection creates this,
|
||||
/// subsequent connections join via the session token.
|
||||
struct TcpSession {
|
||||
peer_ip: std::net::IpAddr,
|
||||
streams: Vec<TcpStream>,
|
||||
expected: u8,
|
||||
}
|
||||
|
||||
type SessionMap = Arc<Mutex<HashMap<u16, TcpSession>>>;
|
||||
|
||||
pub async fn run_server(
|
||||
port: u16,
|
||||
auth_user: Option<String>,
|
||||
auth_pass: Option<String>,
|
||||
use_ecsrp5: bool,
|
||||
listen_v4: Option<String>,
|
||||
listen_v6: Option<String>,
|
||||
) -> Result<()> {
|
||||
let addr = format!("0.0.0.0:{}", port);
|
||||
let listener = TcpListener::bind(&addr).await?;
|
||||
tracing::info!("btest server listening on {}", addr);
|
||||
// Pre-derive EC-SRP5 credentials if enabled
|
||||
let ecsrp5_creds = if use_ecsrp5 {
|
||||
match (auth_user.as_deref(), auth_pass.as_deref()) {
|
||||
(Some(user), Some(pass)) => {
|
||||
tracing::info!("EC-SRP5 authentication enabled for user '{}'", user);
|
||||
Some(Arc::new(crate::ecsrp5::EcSrp5Credentials::derive(user, pass)))
|
||||
}
|
||||
_ => {
|
||||
tracing::warn!("--ecsrp5 requires -a and -p to be set");
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let udp_port_offset = Arc::new(std::sync::atomic::AtomicU16::new(0));
|
||||
let sessions: SessionMap = Arc::new(Mutex::new(HashMap::new()));
|
||||
|
||||
// Bind IPv4 listener
|
||||
let v4_listener = if let Some(ref addr) = listen_v4 {
|
||||
let bind_addr = format!("{}:{}", addr, port);
|
||||
match TcpListener::bind(&bind_addr).await {
|
||||
Ok(l) => {
|
||||
tracing::info!("Listening on {} (IPv4)", bind_addr);
|
||||
Some(l)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to bind {}: {}", bind_addr, e);
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Bind IPv6 listener
|
||||
let v6_listener = if let Some(ref addr) = listen_v6 {
|
||||
let bind_addr = format!("[{}]:{}", addr, port);
|
||||
match TcpListener::bind(&bind_addr).await {
|
||||
Ok(l) => {
|
||||
tracing::info!("Listening on {} (IPv6)", bind_addr);
|
||||
Some(l)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to bind {}: {}", bind_addr, e);
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if v4_listener.is_none() && v6_listener.is_none() {
|
||||
return Err(crate::protocol::BtestError::Protocol(
|
||||
"No listeners bound. Check --listen and --listen6 addresses.".into(),
|
||||
));
|
||||
}
|
||||
|
||||
loop {
|
||||
let (stream, peer) = listener.accept().await?;
|
||||
// Accept from whichever listener has a connection ready
|
||||
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?,
|
||||
(None, None) => unreachable!(),
|
||||
};
|
||||
tracing::info!("New connection from {}", peer);
|
||||
|
||||
let auth_user = auth_user.clone();
|
||||
let auth_pass = auth_pass.clone();
|
||||
let udp_offset = udp_port_offset.clone();
|
||||
let sessions = sessions.clone();
|
||||
let ecsrp5 = ecsrp5_creds.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle_client(stream, peer, auth_user, auth_pass, udp_offset).await {
|
||||
tracing::error!("Client {} error: {}", peer, e);
|
||||
if let Err(e) =
|
||||
handle_client(stream, peer, auth_user, auth_pass, udp_offset, sessions, ecsrp5).await
|
||||
{
|
||||
let err_str = format!("{}", e);
|
||||
tracing::error!("Client {} error: {}", peer, err_str);
|
||||
if err_str.contains("uth") {
|
||||
crate::syslog_logger::auth_failure(&peer.to_string(), "-", "-", &err_str);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -43,14 +130,59 @@ async fn handle_client(
|
||||
auth_user: Option<String>,
|
||||
auth_pass: Option<String>,
|
||||
udp_port_offset: Arc<std::sync::atomic::AtomicU16>,
|
||||
sessions: SessionMap,
|
||||
ecsrp5_creds: Option<Arc<crate::ecsrp5::EcSrp5Credentials>>,
|
||||
) -> Result<()> {
|
||||
stream.set_nodelay(true)?;
|
||||
|
||||
send_hello(&mut stream).await?;
|
||||
|
||||
let cmd = recv_command(&mut stream).await?;
|
||||
// Read 16-byte command (or whatever the client sends)
|
||||
let mut cmd_buf = [0u8; 16];
|
||||
stream.read_exact(&mut cmd_buf).await?;
|
||||
tracing::debug!("Raw command from {}: {:02x?}", peer, cmd_buf);
|
||||
|
||||
// Check if this is a secondary TCP connection joining a session.
|
||||
// Secondary connections send the session token in bytes 0-1 of their "command":
|
||||
// [TOKEN_HI, TOKEN_LO, 0x02, 0x00, ...]
|
||||
// They do NOT do auth — just send them AUTH_OK with the token and they join.
|
||||
{
|
||||
let mut map = sessions.lock().await;
|
||||
let received_token = ((cmd_buf[0] as u16) << 8) | (cmd_buf[1] as u16);
|
||||
if let Some(session) = map.get_mut(&received_token) {
|
||||
if session.peer_ip == peer.ip()
|
||||
&& session.streams.len() < session.expected as usize
|
||||
{
|
||||
tracing::info!(
|
||||
"Client {} is secondary TCP connection (token={:04x})",
|
||||
peer, received_token,
|
||||
);
|
||||
|
||||
// No auth for secondary connections — just send OK with token
|
||||
let ok = [0x01, cmd_buf[0], cmd_buf[1], 0x00];
|
||||
stream.write_all(&ok).await?;
|
||||
stream.flush().await?;
|
||||
|
||||
session.streams.push(stream);
|
||||
tracing::info!(
|
||||
"Secondary connection joined ({}/{})",
|
||||
session.streams.len() + 1,
|
||||
session.expected,
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
drop(map);
|
||||
}
|
||||
|
||||
// Primary connection: parse the command normally
|
||||
let cmd = Command::deserialize(&cmd_buf);
|
||||
if cmd.proto > 1 || cmd.direction == 0 || cmd.direction > 3 {
|
||||
return Err(BtestError::InvalidCommand);
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"Client {} command: proto={} dir={} tx_size={} remote_speed={} local_speed={}",
|
||||
"Client {} command: proto={} dir={} conn_count={} tx_size={} remote_speed={} local_speed={}",
|
||||
peer,
|
||||
if cmd.is_udp() { "UDP" } else { "TCP" },
|
||||
match cmd.direction {
|
||||
@@ -59,28 +191,182 @@ async fn handle_client(
|
||||
CMD_DIR_BOTH => "BOTH",
|
||||
_ => "?",
|
||||
},
|
||||
cmd.tcp_conn_count,
|
||||
cmd.tx_size,
|
||||
cmd.remote_tx_speed,
|
||||
cmd.local_tx_speed,
|
||||
);
|
||||
|
||||
auth::server_authenticate(
|
||||
&mut stream,
|
||||
auth_user.as_deref(),
|
||||
auth_pass.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
// Build auth OK response - include session token for TCP multi-connection
|
||||
let is_tcp_multi = !cmd.is_udp() && cmd.tcp_conn_count > 0;
|
||||
let session_token: u16 = if is_tcp_multi {
|
||||
rand::random::<u16>() | 0x0101 // ensure both bytes non-zero
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let ok_response: [u8; 4] = if is_tcp_multi {
|
||||
// MikroTik expects 01:HI:LO:00 for multi-connection support
|
||||
[0x01, (session_token >> 8) as u8, (session_token & 0xFF) as u8, 0x00]
|
||||
} else {
|
||||
AUTH_OK
|
||||
};
|
||||
|
||||
if cmd.is_udp() {
|
||||
if is_tcp_multi {
|
||||
tracing::info!(
|
||||
"TCP multi-connection: conn_count={}, session_token={:04x}, ok_response={:02x?}",
|
||||
cmd.tcp_conn_count, session_token, ok_response,
|
||||
);
|
||||
}
|
||||
|
||||
// Check if this is a secondary connection joining an existing TCP session
|
||||
if is_tcp_multi {
|
||||
let mut map = sessions.lock().await;
|
||||
for (_token, session) in map.iter_mut() {
|
||||
if session.peer_ip == peer.ip()
|
||||
&& session.streams.len() < session.expected as usize
|
||||
{
|
||||
tracing::info!(
|
||||
"Client {} joining TCP session ({}/{})",
|
||||
peer,
|
||||
session.streams.len() + 1,
|
||||
session.expected,
|
||||
);
|
||||
drop(map);
|
||||
// Secondary connections also do auth with the same session token response
|
||||
auth::server_authenticate(
|
||||
&mut stream,
|
||||
auth_user.as_deref(),
|
||||
auth_pass.as_deref(),
|
||||
&ok_response,
|
||||
)
|
||||
.await?;
|
||||
let mut map = sessions.lock().await;
|
||||
for (_t, s) in map.iter_mut() {
|
||||
if s.peer_ip == peer.ip() && s.streams.len() < s.expected as usize {
|
||||
s.streams.push(stream);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
drop(map);
|
||||
}
|
||||
|
||||
// Primary connection auth
|
||||
if let Some(ref creds) = ecsrp5_creds {
|
||||
// EC-SRP5 authentication
|
||||
let auth_resp: [u8; 4] = [0x03, 0x00, 0x00, 0x00];
|
||||
stream.write_all(&auth_resp).await?;
|
||||
stream.flush().await?;
|
||||
|
||||
crate::ecsrp5::server_authenticate(
|
||||
&mut stream,
|
||||
auth_user.as_deref().unwrap_or("admin"),
|
||||
creds,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Send auth OK (with session token if multi-conn)
|
||||
stream.write_all(&ok_response).await?;
|
||||
stream.flush().await?;
|
||||
} else {
|
||||
// MD5 or no auth
|
||||
auth::server_authenticate(
|
||||
&mut stream,
|
||||
auth_user.as_deref(),
|
||||
auth_pass.as_deref(),
|
||||
&ok_response,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Log auth success and test start
|
||||
let auth_type = if ecsrp5_creds.is_some() { "ecsrp5" } else if auth_user.is_some() { "md5" } else { "none" };
|
||||
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" };
|
||||
crate::syslog_logger::auth_success(&peer.to_string(), auth_user.as_deref().unwrap_or("-"), auth_type);
|
||||
crate::syslog_logger::test_start(&peer.to_string(), proto_str, dir_str, cmd.tcp_conn_count);
|
||||
|
||||
let result = if cmd.is_udp() {
|
||||
run_udp_test_server(&mut stream, peer, &cmd, udp_port_offset).await
|
||||
} else if is_tcp_multi {
|
||||
let conn_count = cmd.tcp_conn_count;
|
||||
|
||||
// Register session for secondary connections to find
|
||||
{
|
||||
let mut map = sessions.lock().await;
|
||||
map.insert(session_token, TcpSession {
|
||||
peer_ip: peer.ip(),
|
||||
streams: Vec::new(),
|
||||
expected: conn_count,
|
||||
});
|
||||
}
|
||||
|
||||
// Wait for secondary connections
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
let count = {
|
||||
let map = sessions.lock().await;
|
||||
map.get(&session_token)
|
||||
.map(|s| s.streams.len())
|
||||
.unwrap_or(0)
|
||||
};
|
||||
if count + 1 >= conn_count as usize {
|
||||
break;
|
||||
}
|
||||
if Instant::now() > deadline {
|
||||
tracing::warn!(
|
||||
"Timeout waiting for TCP connections ({}/{}), proceeding",
|
||||
count + 1,
|
||||
conn_count,
|
||||
);
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
|
||||
let extra_streams = {
|
||||
let mut map = sessions.lock().await;
|
||||
map.remove(&session_token)
|
||||
.map(|s| s.streams)
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
let mut all_streams = vec![stream];
|
||||
all_streams.extend(extra_streams);
|
||||
|
||||
tracing::info!(
|
||||
"TCP multi-connection: starting with {} total streams",
|
||||
all_streams.len(),
|
||||
);
|
||||
|
||||
run_tcp_multiconn_server(all_streams, cmd).await
|
||||
} else {
|
||||
run_tcp_test_server(stream, cmd).await
|
||||
};
|
||||
|
||||
let (total_tx, total_rx, total_lost, intervals) = match &result {
|
||||
Ok(summary) => *summary,
|
||||
Err(_) => (0, 0, 0, 0),
|
||||
};
|
||||
crate::syslog_logger::test_end(
|
||||
&peer.to_string(), proto_str, dir_str,
|
||||
total_tx, total_rx, total_lost, intervals,
|
||||
);
|
||||
if crate::csv_output::is_enabled() {
|
||||
crate::csv_output::write_result(
|
||||
&peer.ip().to_string(), peer.port(), proto_str, dir_str,
|
||||
intervals as u64, total_tx, total_rx, total_lost,
|
||||
crate::cpu::get(), 0, auth_type,
|
||||
);
|
||||
}
|
||||
result.map(|_| ())
|
||||
}
|
||||
|
||||
// --- TCP Test Server ---
|
||||
|
||||
async fn run_tcp_test_server(stream: TcpStream, cmd: Command) -> Result<()> {
|
||||
async fn run_tcp_test_server(stream: TcpStream, cmd: Command) -> Result<(u64, u64, u64, u32)> {
|
||||
let state = BandwidthState::new();
|
||||
let tx_size = cmd.tx_size as usize;
|
||||
let server_should_tx = cmd.server_tx();
|
||||
@@ -89,15 +375,26 @@ async fn run_tcp_test_server(stream: TcpStream, cmd: Command) -> Result<()> {
|
||||
|
||||
let (reader, writer) = stream.into_split();
|
||||
|
||||
// IMPORTANT: Do NOT drop unused halves - dropping sends TCP FIN
|
||||
let mut _writer_keepalive = None;
|
||||
let mut _reader_keepalive = None;
|
||||
|
||||
let state_tx = state.clone();
|
||||
let tx_handle = if server_should_tx {
|
||||
let tx_handle = if server_should_tx && server_should_rx {
|
||||
// BOTH mode: TX data + inject status messages for the RX direction
|
||||
Some(tokio::spawn(async move {
|
||||
tcp_tx_with_status(writer, tx_size, tx_speed, state_tx).await
|
||||
}))
|
||||
} else if server_should_tx {
|
||||
// TX only
|
||||
Some(tokio::spawn(async move {
|
||||
tcp_tx_loop(writer, tx_size, tx_speed, state_tx).await
|
||||
}))
|
||||
} else if server_should_rx {
|
||||
// RX only: use writer for status messages
|
||||
let st = state.clone();
|
||||
Some(tokio::spawn(async move {
|
||||
tcp_status_sender(writer, st).await
|
||||
}))
|
||||
} else {
|
||||
_writer_keepalive = Some(writer);
|
||||
None
|
||||
@@ -113,12 +410,91 @@ async fn run_tcp_test_server(stream: TcpStream, cmd: Command) -> Result<()> {
|
||||
None
|
||||
};
|
||||
|
||||
status_report_loop(&cmd, &state).await;
|
||||
if server_should_tx && !server_should_rx {
|
||||
// TX-only: normal status loop reports TX stats
|
||||
status_report_loop(&cmd, &state).await;
|
||||
} else if server_should_tx && server_should_rx {
|
||||
// BOTH: TX loop injects status + prints RX. Just report TX here.
|
||||
let mut seq: u32 = 0;
|
||||
let mut tick = tokio::time::interval(Duration::from_secs(1));
|
||||
loop {
|
||||
tick.tick().await;
|
||||
if !state.running.load(Ordering::Relaxed) { break; }
|
||||
seq += 1;
|
||||
let tx = state.tx_bytes.swap(0, Ordering::Relaxed);
|
||||
bandwidth::print_status(seq, "TX", tx, Duration::from_secs(1), None);
|
||||
}
|
||||
} else {
|
||||
// RX-only: tcp_status_sender handles everything. Just wait.
|
||||
while state.running.load(Ordering::Relaxed) {
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
|
||||
state.running.store(false, Ordering::SeqCst);
|
||||
if let Some(h) = tx_handle { let _ = h.await; }
|
||||
if let Some(h) = rx_handle { let _ = h.await; }
|
||||
Ok(())
|
||||
Ok(state.summary())
|
||||
}
|
||||
|
||||
/// TCP multi-connection.
|
||||
async fn run_tcp_multiconn_server(streams: Vec<TcpStream>, cmd: Command) -> Result<(u64, u64, u64, u32)> {
|
||||
let state = BandwidthState::new();
|
||||
let tx_size = cmd.tx_size as usize;
|
||||
let server_should_tx = cmd.server_tx();
|
||||
let server_should_rx = cmd.server_rx();
|
||||
let tx_speed = cmd.remote_tx_speed;
|
||||
|
||||
let mut tx_handles = Vec::new();
|
||||
let mut rx_handles = Vec::new();
|
||||
let mut _writer_keepalives: Vec<tokio::net::tcp::OwnedWriteHalf> = Vec::new();
|
||||
let mut _reader_keepalives: Vec<tokio::net::tcp::OwnedReadHalf> = Vec::new();
|
||||
|
||||
for tcp_stream in streams {
|
||||
let (reader, writer) = tcp_stream.into_split();
|
||||
|
||||
if server_should_tx && server_should_rx {
|
||||
let st = state.clone();
|
||||
tx_handles.push(tokio::spawn(async move {
|
||||
tcp_tx_with_status(writer, tx_size, tx_speed, st).await
|
||||
}));
|
||||
} else if server_should_tx {
|
||||
let st = state.clone();
|
||||
tx_handles.push(tokio::spawn(async move {
|
||||
tcp_tx_loop(writer, tx_size, tx_speed, st).await
|
||||
}));
|
||||
} else if server_should_rx {
|
||||
let st = state.clone();
|
||||
tx_handles.push(tokio::spawn(async move {
|
||||
tcp_status_sender(writer, st).await
|
||||
}));
|
||||
} else {
|
||||
_writer_keepalives.push(writer);
|
||||
}
|
||||
|
||||
if server_should_rx {
|
||||
let st = state.clone();
|
||||
rx_handles.push(tokio::spawn(async move {
|
||||
tcp_rx_loop(reader, st).await
|
||||
}));
|
||||
} else {
|
||||
_reader_keepalives.push(reader);
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"TCP multi-conn: {} TX tasks, {} RX tasks",
|
||||
tx_handles.len(),
|
||||
rx_handles.len(),
|
||||
);
|
||||
|
||||
status_report_loop(&cmd, &state).await;
|
||||
|
||||
state.running.store(false, Ordering::SeqCst);
|
||||
for h in tx_handles { let _ = h.await; }
|
||||
for h in rx_handles { let _ = h.await; }
|
||||
tracing::info!("TCP multi-connection test ended");
|
||||
Ok(state.summary())
|
||||
}
|
||||
|
||||
async fn tcp_tx_loop(
|
||||
@@ -126,16 +502,56 @@ async fn tcp_tx_loop(
|
||||
tx_size: usize,
|
||||
tx_speed: u32,
|
||||
state: Arc<BandwidthState>,
|
||||
) {
|
||||
tcp_tx_loop_inner(&mut writer, tx_size, tx_speed, &state, false).await;
|
||||
}
|
||||
|
||||
/// TCP TX loop that also sends status messages when `send_status` is true.
|
||||
/// Used in bidirectional mode where the writer handles both data and status.
|
||||
async fn tcp_tx_with_status(
|
||||
mut writer: tokio::net::tcp::OwnedWriteHalf,
|
||||
tx_size: usize,
|
||||
tx_speed: u32,
|
||||
state: Arc<BandwidthState>,
|
||||
) {
|
||||
tcp_tx_loop_inner(&mut writer, tx_size, tx_speed, &state, true).await;
|
||||
}
|
||||
|
||||
async fn tcp_tx_loop_inner(
|
||||
writer: &mut tokio::net::tcp::OwnedWriteHalf,
|
||||
tx_size: usize,
|
||||
tx_speed: u32,
|
||||
state: &Arc<BandwidthState>,
|
||||
send_status: bool,
|
||||
) {
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
let mut packet = vec![0u8; tx_size];
|
||||
packet[0] = STATUS_MSG_TYPE;
|
||||
let packet = vec![0u8; tx_size];
|
||||
let mut interval = bandwidth::calc_send_interval(tx_speed, tx_size as u16);
|
||||
let mut next_send = Instant::now();
|
||||
let mut next_status = Instant::now() + Duration::from_secs(1);
|
||||
let mut status_seq: u32 = 0;
|
||||
|
||||
while state.running.load(Ordering::Relaxed) {
|
||||
// Inject status message every ~1 second if in bidirectional mode
|
||||
if send_status && Instant::now() >= next_status {
|
||||
status_seq += 1;
|
||||
let rx_bytes = state.rx_bytes.swap(0, Ordering::Relaxed);
|
||||
let status = StatusMessage { cpu_load: crate::cpu::get(),
|
||||
seq: status_seq,
|
||||
bytes_received: rx_bytes as u32,
|
||||
};
|
||||
if writer.write_all(&status.serialize()).await.is_err() {
|
||||
state.running.store(false, Ordering::SeqCst);
|
||||
break;
|
||||
}
|
||||
state.record_interval(0, rx_bytes, 0);
|
||||
bandwidth::print_status(status_seq, "RX", rx_bytes, Duration::from_secs(1), None);
|
||||
next_status = Instant::now() + Duration::from_secs(1);
|
||||
}
|
||||
|
||||
if writer.write_all(&packet).await.is_err() {
|
||||
state.running.store(false, Ordering::SeqCst);
|
||||
break;
|
||||
}
|
||||
state.tx_bytes.fetch_add(tx_size as u64, Ordering::Relaxed);
|
||||
@@ -166,7 +582,10 @@ async fn tcp_rx_loop(mut reader: tokio::net::tcp::OwnedReadHalf, state: Arc<Band
|
||||
let mut buf = vec![0u8; 65536];
|
||||
while state.running.load(Ordering::Relaxed) {
|
||||
match reader.read(&mut buf).await {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(0) | Err(_) => {
|
||||
state.running.store(false, Ordering::SeqCst);
|
||||
break;
|
||||
}
|
||||
Ok(n) => {
|
||||
state.rx_bytes.fetch_add(n as u64, Ordering::Relaxed);
|
||||
}
|
||||
@@ -174,6 +593,45 @@ async fn tcp_rx_loop(mut reader: tokio::net::tcp::OwnedReadHalf, state: Arc<Band
|
||||
}
|
||||
}
|
||||
|
||||
/// Send periodic 12-byte status messages on the TCP connection.
|
||||
/// Used when server is in RX mode — tells the client how many bytes we received.
|
||||
/// Send periodic 12-byte status messages on the TCP connection AND print local stats.
|
||||
/// Used when server is in RX-only mode. Replaces the normal status_report_loop
|
||||
/// because it needs the writer and must own the rx_bytes swap.
|
||||
async fn tcp_status_sender(
|
||||
mut writer: tokio::net::tcp::OwnedWriteHalf,
|
||||
state: Arc<BandwidthState>,
|
||||
) {
|
||||
let mut seq: u32 = 0;
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(1));
|
||||
interval.tick().await;
|
||||
|
||||
while state.running.load(Ordering::Relaxed) {
|
||||
interval.tick().await;
|
||||
if !state.running.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
|
||||
seq += 1;
|
||||
// Swap to get bytes received this interval (atomic reset)
|
||||
let rx_bytes = state.rx_bytes.swap(0, Ordering::Relaxed);
|
||||
|
||||
let status = StatusMessage { cpu_load: crate::cpu::get(),
|
||||
seq,
|
||||
bytes_received: rx_bytes as u32,
|
||||
};
|
||||
|
||||
if writer.write_all(&status.serialize()).await.is_err() {
|
||||
state.running.store(false, Ordering::SeqCst);
|
||||
break;
|
||||
}
|
||||
let _ = writer.flush().await;
|
||||
|
||||
state.record_interval(0, rx_bytes, 0);
|
||||
bandwidth::print_status(seq, "RX", rx_bytes, Duration::from_secs(1), None);
|
||||
}
|
||||
}
|
||||
|
||||
// --- UDP Test Server ---
|
||||
|
||||
async fn run_udp_test_server(
|
||||
@@ -181,7 +639,7 @@ async fn run_udp_test_server(
|
||||
peer: SocketAddr,
|
||||
cmd: &Command,
|
||||
udp_port_offset: Arc<std::sync::atomic::AtomicU16>,
|
||||
) -> Result<()> {
|
||||
) -> Result<(u64, u64, u64, u32)> {
|
||||
let offset = udp_port_offset.fetch_add(1, Ordering::SeqCst);
|
||||
let server_udp_port = BTEST_UDP_PORT_START + offset;
|
||||
let client_udp_port = server_udp_port + BTEST_PORT_CLIENT_OFFSET;
|
||||
@@ -194,10 +652,61 @@ async fn run_udp_test_server(
|
||||
server_udp_port, client_udp_port, peer,
|
||||
);
|
||||
|
||||
let udp = UdpSocket::bind(format!("0.0.0.0:{}", server_udp_port)).await?;
|
||||
let client_udp_addr: SocketAddr =
|
||||
format!("{}:{}", peer.ip(), client_udp_port).parse().unwrap();
|
||||
udp.connect(client_udp_addr).await?;
|
||||
// Bind UDP on the same address family as the peer
|
||||
let bind_addr: SocketAddr = if peer.is_ipv6() {
|
||||
format!("[::]:{}", server_udp_port).parse().unwrap()
|
||||
} else {
|
||||
format!("0.0.0.0:{}", server_udp_port).parse().unwrap()
|
||||
};
|
||||
// Create socket with socket2 FIRST to set buffer sizes before tokio wraps it
|
||||
let domain = if peer.is_ipv6() {
|
||||
socket2::Domain::IPV6
|
||||
} else {
|
||||
socket2::Domain::IPV4
|
||||
};
|
||||
let sock2 = socket2::Socket::new(domain, socket2::Type::DGRAM, Some(socket2::Protocol::UDP))?;
|
||||
sock2.set_nonblocking(true)?;
|
||||
let _ = sock2.set_send_buffer_size(4 * 1024 * 1024);
|
||||
let _ = sock2.set_recv_buffer_size(4 * 1024 * 1024);
|
||||
if peer.is_ipv6() {
|
||||
let _ = sock2.set_only_v6(true);
|
||||
}
|
||||
sock2.bind(&bind_addr.into())?;
|
||||
tracing::debug!(
|
||||
"UDP socket: sndbuf={}, rcvbuf={}",
|
||||
sock2.send_buffer_size().unwrap_or(0),
|
||||
sock2.recv_buffer_size().unwrap_or(0),
|
||||
);
|
||||
let udp = UdpSocket::from_std(sock2.into())?;
|
||||
|
||||
let client_udp_addr = SocketAddr::new(peer.ip(), client_udp_port);
|
||||
|
||||
// On IPv6, send a probe packet to trigger NDP neighbor resolution before blasting.
|
||||
// macOS returns ENOBUFS on send_to() until the neighbor cache is populated.
|
||||
if peer.is_ipv6() {
|
||||
let _ = udp.send_to(&[0u8; 1], client_udp_addr).await;
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
tracing::debug!("IPv6 NDP probe sent to {}", client_udp_addr);
|
||||
}
|
||||
|
||||
// When connection_count > 1, MikroTik sends UDP from MULTIPLE source ports
|
||||
// (base_port, base_port+1, ..., base_port+N-1) all to our single server port.
|
||||
// A connect()'d UDP socket only accepts from the one connected address,
|
||||
// silently dropping packets from the other ports.
|
||||
// Only use unconnected socket for multi-connection mode (MikroTik sends
|
||||
// from multiple source ports). For single-connection, always connect() —
|
||||
// this is critical for IPv6 where send_to() hits ENOBUFS but send() works.
|
||||
// recv_from() works fine on connected sockets for single source.
|
||||
let use_unconnected = cmd.tcp_conn_count > 0;
|
||||
if !use_unconnected {
|
||||
udp.connect(client_udp_addr).await?;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"UDP mode: conn_count={}, socket={}",
|
||||
cmd.tcp_conn_count.max(1),
|
||||
if use_unconnected { "unconnected" } else { "connected" },
|
||||
);
|
||||
|
||||
let state = BandwidthState::new();
|
||||
let tx_size = cmd.tx_size as usize;
|
||||
@@ -209,9 +718,11 @@ async fn run_udp_test_server(
|
||||
|
||||
let state_tx = state.clone();
|
||||
let udp_tx = udp.clone();
|
||||
let tx_target = client_udp_addr;
|
||||
let is_multi = use_unconnected;
|
||||
let tx_handle = if server_should_tx {
|
||||
Some(tokio::spawn(async move {
|
||||
udp_tx_loop(&udp_tx, tx_size, tx_speed, state_tx).await
|
||||
udp_tx_loop(&udp_tx, tx_size, tx_speed, state_tx, is_multi, tx_target).await
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
@@ -233,7 +744,7 @@ async fn run_udp_test_server(
|
||||
state.running.store(false, Ordering::SeqCst);
|
||||
if let Some(h) = tx_handle { let _ = h.await; }
|
||||
if let Some(h) = rx_handle { let _ = h.await; }
|
||||
Ok(())
|
||||
Ok(state.summary())
|
||||
}
|
||||
|
||||
async fn udp_tx_loop(
|
||||
@@ -241,6 +752,8 @@ async fn udp_tx_loop(
|
||||
tx_size: usize,
|
||||
initial_tx_speed: u32,
|
||||
state: Arc<BandwidthState>,
|
||||
multi_conn: bool,
|
||||
target: SocketAddr,
|
||||
) {
|
||||
let mut seq: u32 = 0;
|
||||
let mut packet = vec![0u8; tx_size];
|
||||
@@ -251,20 +764,31 @@ async fn udp_tx_loop(
|
||||
while state.running.load(Ordering::Relaxed) {
|
||||
packet[0..4].copy_from_slice(&seq.to_be_bytes());
|
||||
|
||||
match socket.send(&packet).await {
|
||||
let result = if multi_conn {
|
||||
socket.send_to(&packet, target).await
|
||||
} else {
|
||||
socket.send(&packet).await
|
||||
};
|
||||
match result {
|
||||
Ok(n) => {
|
||||
seq = seq.wrapping_add(1);
|
||||
state.tx_bytes.fetch_add(n as u64, Ordering::Relaxed);
|
||||
consecutive_errors = 0;
|
||||
}
|
||||
Err(_) => {
|
||||
Err(e) => {
|
||||
consecutive_errors += 1;
|
||||
if consecutive_errors > 1000 {
|
||||
if consecutive_errors == 1 {
|
||||
tracing::debug!("UDP TX send error: {} (target={})", e, target);
|
||||
}
|
||||
if consecutive_errors > 50000 {
|
||||
tracing::warn!("UDP TX: too many consecutive send errors, stopping");
|
||||
break;
|
||||
}
|
||||
// Back off on ENOBUFS/EAGAIN
|
||||
tokio::time::sleep(Duration::from_micros(200)).await;
|
||||
// Adaptive backoff: sleep longer as errors accumulate
|
||||
let backoff = Duration::from_micros(
|
||||
(200 + consecutive_errors.min(5000) as u64 * 10).min(10000)
|
||||
);
|
||||
tokio::time::sleep(backoff).await;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -288,9 +812,17 @@ async fn udp_tx_loop(
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// Unlimited: yield every 64 packets to keep system responsive
|
||||
if seq % 64 == 0 {
|
||||
tokio::task::yield_now().await;
|
||||
// "Unlimited" mode: still need minimal pacing to prevent
|
||||
// macOS interface queue overflow (ENOBUFS).
|
||||
// Yield every 16 packets; if errors seen, add real delay.
|
||||
if seq % 16 == 0 {
|
||||
if consecutive_errors > 0 {
|
||||
// Back off enough for the NIC to drain
|
||||
tokio::time::sleep(Duration::from_micros(50)).await;
|
||||
consecutive_errors = 0; // reset after yielding
|
||||
} else {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -302,8 +834,10 @@ async fn udp_rx_loop(socket: &UdpSocket, state: Arc<BandwidthState>) {
|
||||
let mut last_seq: Option<u32> = None;
|
||||
|
||||
while state.running.load(Ordering::Relaxed) {
|
||||
match tokio::time::timeout(Duration::from_secs(5), socket.recv(&mut buf)).await {
|
||||
Ok(Ok(n)) if n >= 4 => {
|
||||
// Use recv_from to accept packets from any source port
|
||||
// (multi-connection MikroTik sends from multiple ports)
|
||||
match tokio::time::timeout(Duration::from_secs(5), socket.recv_from(&mut buf)).await {
|
||||
Ok(Ok((n, _src))) if n >= 4 => {
|
||||
state.rx_bytes.fetch_add(n as u64, Ordering::Relaxed);
|
||||
state.rx_packets.fetch_add(1, Ordering::Relaxed);
|
||||
|
||||
@@ -344,14 +878,15 @@ async fn status_report_loop(cmd: &Command, state: &BandwidthState) {
|
||||
|
||||
seq += 1;
|
||||
|
||||
let tx = if cmd.server_tx() { state.tx_bytes.swap(0, Ordering::Relaxed) } else { 0 };
|
||||
let rx = if cmd.server_rx() { state.rx_bytes.swap(0, Ordering::Relaxed) } else { 0 };
|
||||
let lost = if cmd.server_rx() { state.rx_lost_packets.swap(0, Ordering::Relaxed) } else { 0 };
|
||||
state.record_interval(tx, rx, lost);
|
||||
|
||||
if cmd.server_tx() {
|
||||
let tx = state.tx_bytes.swap(0, Ordering::Relaxed);
|
||||
bandwidth::print_status(seq, "TX", tx, Duration::from_secs(1), None);
|
||||
}
|
||||
|
||||
if cmd.server_rx() {
|
||||
let rx = state.rx_bytes.swap(0, Ordering::Relaxed);
|
||||
let lost = state.rx_lost_packets.swap(0, Ordering::Relaxed);
|
||||
let lost_opt = if cmd.is_udp() { Some(lost) } else { None };
|
||||
bandwidth::print_status(seq, "RX", rx, Duration::from_secs(1), lost_opt);
|
||||
}
|
||||
@@ -391,9 +926,10 @@ async fn udp_status_loop(
|
||||
match tokio::time::timeout(wait_time, reader.read_exact(&mut status_buf)).await {
|
||||
Ok(Ok(_)) => {
|
||||
let client_status = StatusMessage::deserialize(&status_buf);
|
||||
state.remote_cpu.store(client_status.cpu_load, Ordering::Relaxed);
|
||||
tracing::debug!(
|
||||
"RECV status: raw={:02x?} seq={} bytes_received={}",
|
||||
&status_buf, client_status.seq, client_status.bytes_received,
|
||||
"RECV status: raw={:02x?} seq={} bytes_received={} cpu={}%",
|
||||
&status_buf, client_status.seq, client_status.bytes_received, client_status.cpu_load,
|
||||
);
|
||||
|
||||
if client_status.bytes_received > 0 && cmd.server_tx() {
|
||||
@@ -430,9 +966,17 @@ async fn udp_status_loop(
|
||||
let tx_bytes = state.tx_bytes.swap(0, Ordering::Relaxed);
|
||||
let lost = state.rx_lost_packets.swap(0, Ordering::Relaxed);
|
||||
|
||||
let status = StatusMessage {
|
||||
// Report bytes relevant to the active direction.
|
||||
// When TX-only: report tx_bytes so client knows data is flowing.
|
||||
// When RX or BOTH: report rx_bytes (how much we received from client).
|
||||
let report_bytes = if cmd.server_tx() && !cmd.server_rx() {
|
||||
tx_bytes
|
||||
} else {
|
||||
rx_bytes
|
||||
};
|
||||
let status = StatusMessage { cpu_load: crate::cpu::get(),
|
||||
seq,
|
||||
bytes_received: rx_bytes as u32,
|
||||
bytes_received: report_bytes as u32,
|
||||
};
|
||||
let serialized = status.serialize();
|
||||
tracing::debug!(
|
||||
@@ -445,12 +989,17 @@ async fn udp_status_loop(
|
||||
}
|
||||
let _ = writer.flush().await;
|
||||
|
||||
// Print local stats
|
||||
// Print local stats and record totals
|
||||
state.record_interval(tx_bytes, rx_bytes, lost);
|
||||
if cmd.server_tx() {
|
||||
bandwidth::print_status(seq, "TX", tx_bytes, Duration::from_secs(1), None);
|
||||
let local_cpu = crate::cpu::get();
|
||||
let remote_cpu = state.remote_cpu.load(Ordering::Relaxed);
|
||||
bandwidth::print_status_with_cpu(seq, "TX", tx_bytes, Duration::from_secs(1), None, Some(local_cpu), Some(remote_cpu));
|
||||
}
|
||||
if cmd.server_rx() {
|
||||
bandwidth::print_status(seq, "RX", rx_bytes, Duration::from_secs(1), Some(lost));
|
||||
let local_cpu = crate::cpu::get();
|
||||
let remote_cpu = state.remote_cpu.load(Ordering::Relaxed);
|
||||
bandwidth::print_status_with_cpu(seq, "RX", rx_bytes, Duration::from_secs(1), Some(lost), Some(local_cpu), Some(remote_cpu));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
154
src/syslog_logger.rs
Normal file
154
src/syslog_logger.rs
Normal file
@@ -0,0 +1,154 @@
|
||||
//! Syslog integration for btest-rs server mode.
|
||||
//!
|
||||
//! Sends structured log events to a remote syslog server via UDP (RFC 5424).
|
||||
//! Events: auth success/failure, test start/stop, speed results.
|
||||
|
||||
use std::net::UdpSocket;
|
||||
use std::sync::Mutex;
|
||||
|
||||
static SYSLOG: Mutex<Option<SyslogSender>> = Mutex::new(None);
|
||||
|
||||
struct SyslogSender {
|
||||
socket: UdpSocket,
|
||||
target: String,
|
||||
hostname: String,
|
||||
}
|
||||
|
||||
/// Initialize the global syslog sender.
|
||||
/// `target` is the syslog server address, e.g. "192.168.1.1:514".
|
||||
pub fn init(target: &str) -> std::io::Result<()> {
|
||||
let socket = UdpSocket::bind("0.0.0.0:0")?;
|
||||
let hostname = hostname::get()
|
||||
.map(|h| h.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|_| "btest-rs".to_string());
|
||||
|
||||
let sender = SyslogSender {
|
||||
socket,
|
||||
target: target.to_string(),
|
||||
hostname,
|
||||
};
|
||||
|
||||
*SYSLOG.lock().unwrap() = Some(sender);
|
||||
tracing::info!("Syslog enabled, sending to {}", target);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send a syslog message with the given severity and message.
|
||||
/// Severity: 6=info, 4=warning, 3=error
|
||||
fn send(severity: u8, msg: &str) {
|
||||
let guard = SYSLOG.lock().unwrap();
|
||||
if let Some(ref sender) = *guard {
|
||||
// RFC 3164 (BSD syslog): <priority>Mon DD HH:MM:SS hostname program: message
|
||||
// facility=16 (local0) * 8 + severity
|
||||
let priority = 128 + severity;
|
||||
let timestamp = bsd_timestamp();
|
||||
let syslog_msg = format!(
|
||||
"<{}>{} {} btest-rs: {}",
|
||||
priority, timestamp, sender.hostname, msg,
|
||||
);
|
||||
let _ = sender.socket.send_to(syslog_msg.as_bytes(), &sender.target);
|
||||
}
|
||||
}
|
||||
|
||||
fn bsd_timestamp() -> String {
|
||||
// RFC 3164 format: "Mon DD HH:MM:SS" (no year)
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
|
||||
// Simple conversion — good enough for syslog
|
||||
let secs_in_day = 86400u64;
|
||||
let days = now / secs_in_day;
|
||||
let time_of_day = now % secs_in_day;
|
||||
let hours = time_of_day / 3600;
|
||||
let minutes = (time_of_day % 3600) / 60;
|
||||
let seconds = time_of_day % 60;
|
||||
|
||||
// Day of year calculation (approximate months)
|
||||
let months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
|
||||
let days_in_months = [31u64,28,31,30,31,30,31,31,30,31,30,31];
|
||||
|
||||
// Days since epoch to year/month/day
|
||||
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 mut m = 0usize;
|
||||
for i in 0..12 {
|
||||
let mut d = days_in_months[i];
|
||||
if i == 1 && leap { d += 1; }
|
||||
if remaining < d { m = i; break; }
|
||||
remaining -= d;
|
||||
}
|
||||
let day = remaining + 1;
|
||||
|
||||
format!("{} {:2} {:02}:{:02}:{:02}", months[m], day, hours, minutes, seconds)
|
||||
}
|
||||
|
||||
// --- Public logging functions ---
|
||||
|
||||
pub fn auth_success(peer: &str, username: &str, auth_type: &str) {
|
||||
let msg = format!(
|
||||
"AUTH_SUCCESS peer={} user={} type={}",
|
||||
peer, username, auth_type,
|
||||
);
|
||||
tracing::info!("{}", msg);
|
||||
send(6, &msg);
|
||||
}
|
||||
|
||||
pub fn auth_failure(peer: &str, username: &str, auth_type: &str, reason: &str) {
|
||||
let msg = format!(
|
||||
"AUTH_FAILURE peer={} user={} type={} reason={}",
|
||||
peer, username, auth_type, reason,
|
||||
);
|
||||
tracing::warn!("{}", msg);
|
||||
send(4, &msg);
|
||||
}
|
||||
|
||||
pub fn test_start(peer: &str, proto: &str, direction: &str, conn_count: u8) {
|
||||
let msg = format!(
|
||||
"TEST_START peer={} proto={} dir={} connections={}",
|
||||
peer, proto, direction, conn_count.max(1),
|
||||
);
|
||||
tracing::info!("{}", msg);
|
||||
send(6, &msg);
|
||||
}
|
||||
|
||||
pub fn test_end(
|
||||
peer: &str,
|
||||
proto: &str,
|
||||
direction: &str,
|
||||
total_tx: u64,
|
||||
total_rx: u64,
|
||||
total_lost: u64,
|
||||
duration_secs: u32,
|
||||
) {
|
||||
let tx_mbps = if duration_secs > 0 {
|
||||
total_tx as f64 * 8.0 / duration_secs as f64 / 1_000_000.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let rx_mbps = if duration_secs > 0 {
|
||||
total_rx as f64 * 8.0 / duration_secs as f64 / 1_000_000.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let msg = format!(
|
||||
"TEST_END peer={} proto={} dir={} duration={}s tx_avg={:.2}Mbps rx_avg={:.2}Mbps tx_bytes={} rx_bytes={} lost={}",
|
||||
peer, proto, direction, duration_secs, tx_mbps, rx_mbps, total_tx, total_rx, total_lost,
|
||||
);
|
||||
tracing::info!("{}", msg);
|
||||
send(6, &msg);
|
||||
}
|
||||
|
||||
/// Check if syslog is enabled.
|
||||
pub fn is_enabled() -> bool {
|
||||
SYSLOG.lock().unwrap().is_some()
|
||||
}
|
||||
193
tests/ecsrp5_test.rs
Normal file
193
tests/ecsrp5_test.rs
Normal file
@@ -0,0 +1,193 @@
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpStream;
|
||||
|
||||
const SERVER_PORT: u16 = 13000;
|
||||
|
||||
async fn start_ecsrp5_server(port: u16) {
|
||||
tokio::spawn(async move {
|
||||
let _ = btest_rs::server::run_server(
|
||||
port,
|
||||
Some("testuser".into()),
|
||||
Some("testpass".into()),
|
||||
true,
|
||||
Some("127.0.0.1".into()),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
|
||||
async fn start_md5_server(port: u16) {
|
||||
tokio::spawn(async move {
|
||||
let _ = btest_rs::server::run_server(
|
||||
port,
|
||||
Some("testuser".into()),
|
||||
Some("testpass".into()),
|
||||
false,
|
||||
Some("127.0.0.1".into()),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
|
||||
async fn start_noauth_server(port: u16) {
|
||||
tokio::spawn(async move {
|
||||
let _ = btest_rs::server::run_server(port, None, None, false, Some("127.0.0.1".into()), None).await;
|
||||
});
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_ecsrp5_server_sends_03_response() {
|
||||
let port = SERVER_PORT;
|
||||
start_ecsrp5_server(port).await;
|
||||
|
||||
let mut stream = TcpStream::connect(format!("127.0.0.1:{}", port))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Read HELLO
|
||||
let mut buf = [0u8; 4];
|
||||
stream.read_exact(&mut buf).await.unwrap();
|
||||
assert_eq!(buf, [0x01, 0x00, 0x00, 0x00]);
|
||||
|
||||
// Send command (TCP, server TX)
|
||||
let cmd = btest_rs::protocol::Command::new(
|
||||
btest_rs::protocol::CMD_PROTO_TCP,
|
||||
btest_rs::protocol::CMD_DIR_TX,
|
||||
);
|
||||
stream.write_all(&cmd.serialize()).await.unwrap();
|
||||
stream.flush().await.unwrap();
|
||||
|
||||
// Should receive EC-SRP5 auth required
|
||||
stream.read_exact(&mut buf).await.unwrap();
|
||||
assert_eq!(buf, [0x03, 0x00, 0x00, 0x00], "Expected EC-SRP5 auth response");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_ecsrp5_full_client_auth() {
|
||||
let port = SERVER_PORT + 1;
|
||||
start_ecsrp5_server(port).await;
|
||||
|
||||
// Use our client with EC-SRP5
|
||||
let handle = tokio::spawn(async move {
|
||||
btest_rs::client::run_client(
|
||||
"127.0.0.1",
|
||||
port,
|
||||
btest_rs::protocol::CMD_DIR_TX, // server TX = client RX
|
||||
false,
|
||||
0,
|
||||
0,
|
||||
Some("testuser".into()),
|
||||
Some("testpass".into()),
|
||||
false,
|
||||
btest_rs::bandwidth::BandwidthState::new(),
|
||||
)
|
||||
.await
|
||||
});
|
||||
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
handle.abort();
|
||||
// If we got here without panic, EC-SRP5 auth + data transfer worked
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_ecsrp5_wrong_password_fails() {
|
||||
let port = SERVER_PORT + 2;
|
||||
start_ecsrp5_server(port).await;
|
||||
|
||||
let result = btest_rs::client::run_client(
|
||||
"127.0.0.1",
|
||||
port,
|
||||
btest_rs::protocol::CMD_DIR_TX,
|
||||
false,
|
||||
0,
|
||||
0,
|
||||
Some("testuser".into()),
|
||||
Some("wrongpass".into()),
|
||||
false,
|
||||
btest_rs::bandwidth::BandwidthState::new(),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err(), "Wrong password should fail");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_md5_auth_still_works() {
|
||||
let port = SERVER_PORT + 3;
|
||||
start_md5_server(port).await;
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
btest_rs::client::run_client(
|
||||
"127.0.0.1",
|
||||
port,
|
||||
btest_rs::protocol::CMD_DIR_TX,
|
||||
false,
|
||||
0,
|
||||
0,
|
||||
Some("testuser".into()),
|
||||
Some("testpass".into()),
|
||||
false,
|
||||
btest_rs::bandwidth::BandwidthState::new(),
|
||||
)
|
||||
.await
|
||||
});
|
||||
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_noauth_still_works() {
|
||||
let port = SERVER_PORT + 4;
|
||||
start_noauth_server(port).await;
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
btest_rs::client::run_client(
|
||||
"127.0.0.1",
|
||||
port,
|
||||
btest_rs::protocol::CMD_DIR_TX,
|
||||
false,
|
||||
0,
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
btest_rs::bandwidth::BandwidthState::new(),
|
||||
)
|
||||
.await
|
||||
});
|
||||
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_ecsrp5_udp_bidirectional() {
|
||||
let port = SERVER_PORT + 5;
|
||||
start_ecsrp5_server(port).await;
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
btest_rs::client::run_client(
|
||||
"127.0.0.1",
|
||||
port,
|
||||
btest_rs::protocol::CMD_DIR_BOTH,
|
||||
true, // UDP
|
||||
0,
|
||||
0,
|
||||
Some("testuser".into()),
|
||||
Some("testpass".into()),
|
||||
false,
|
||||
btest_rs::bandwidth::BandwidthState::new(),
|
||||
)
|
||||
.await
|
||||
});
|
||||
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
handle.abort();
|
||||
}
|
||||
338
tests/full_integration_test.rs
Normal file
338
tests/full_integration_test.rs
Normal file
@@ -0,0 +1,338 @@
|
||||
//! Comprehensive integration tests covering all modes, protocols, and output formats.
|
||||
//! Each test starts a server, runs a client, verifies data flows, and checks CSV/stats.
|
||||
|
||||
use std::net::UdpSocket as StdUdpSocket;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Duration;
|
||||
|
||||
const BASE_PORT: u16 = 14000;
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
async fn start_server(port: u16, ecsrp5: bool) {
|
||||
let auth_user = Some("testuser".into());
|
||||
let auth_pass = Some("testpass".into());
|
||||
tokio::spawn(async move {
|
||||
let _ = btest_rs::server::run_server(
|
||||
port, auth_user, auth_pass, ecsrp5,
|
||||
Some("127.0.0.1".into()), None,
|
||||
).await;
|
||||
});
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
|
||||
async fn start_server_noauth(port: u16) {
|
||||
tokio::spawn(async move {
|
||||
let _ = btest_rs::server::run_server(
|
||||
port, None, None, false,
|
||||
Some("127.0.0.1".into()), None,
|
||||
).await;
|
||||
});
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
|
||||
async fn start_server_v6(port: u16) {
|
||||
tokio::spawn(async move {
|
||||
let _ = btest_rs::server::run_server(
|
||||
port, None, None, false,
|
||||
None, Some("::1".into()),
|
||||
).await;
|
||||
});
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
|
||||
async fn run_client_test(
|
||||
host: &str, port: u16, transmit: bool, receive: bool, udp: bool,
|
||||
user: Option<&str>, pass: Option<&str>,
|
||||
) -> (u64, u64, u64, u32) {
|
||||
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 user = user.map(String::from);
|
||||
let pass = pass.map(String::from);
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
btest_rs::client::run_client(
|
||||
&host, port, direction, udp,
|
||||
0, 0, user, pass, false, state_clone,
|
||||
).await
|
||||
});
|
||||
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
state.running.store(false, Ordering::SeqCst);
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
handle.abort();
|
||||
|
||||
state.summary()
|
||||
}
|
||||
|
||||
// --- TCP IPv4 Tests ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_tcp4_receive() {
|
||||
let port = BASE_PORT;
|
||||
start_server_noauth(port).await;
|
||||
let (_tx, _rx, _, _intervals) = run_client_test("127.0.0.1", port, false, true, false, None, None).await;
|
||||
assert!(_rx > 0, "TCP4 receive: expected rx > 0, got {}", _rx);
|
||||
assert!(_intervals > 0, "TCP4 receive: expected intervals > 0");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_tcp4_send() {
|
||||
let port = BASE_PORT + 1;
|
||||
start_server_noauth(port).await;
|
||||
let (_tx, _rx, _, _intervals) = run_client_test("127.0.0.1", port, true, false, false, None, None).await;
|
||||
assert!(_tx > 0, "TCP4 send: expected tx > 0, got {}", _tx);
|
||||
assert!(_intervals > 0, "TCP4 send: expected intervals > 0");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_tcp4_both() {
|
||||
let port = BASE_PORT + 2;
|
||||
start_server_noauth(port).await;
|
||||
let (_tx, _rx, _, _intervals) = run_client_test("127.0.0.1", port, true, true, false, None, None).await;
|
||||
assert!(_tx > 0, "TCP4 both: expected tx > 0, got {}", _tx);
|
||||
assert!(_rx > 0, "TCP4 both: expected rx > 0, got {}", _rx);
|
||||
}
|
||||
|
||||
// --- UDP IPv4 Tests ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_udp4_receive() {
|
||||
let port = BASE_PORT + 3;
|
||||
start_server_noauth(port).await;
|
||||
let (_tx, _rx, _, _intervals) = run_client_test("127.0.0.1", port, false, true, true, None, None).await;
|
||||
assert!(_rx > 0, "UDP4 receive: expected rx > 0, got {}", _rx);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_udp4_send() {
|
||||
let port = BASE_PORT + 4;
|
||||
start_server_noauth(port).await;
|
||||
let (_tx, _rx, _, _intervals) = run_client_test("127.0.0.1", port, true, false, true, None, None).await;
|
||||
assert!(_tx > 0, "UDP4 send: expected tx > 0, got {}", _tx);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_udp4_both() {
|
||||
let port = BASE_PORT + 5;
|
||||
start_server_noauth(port).await;
|
||||
let (_tx, _rx, _, _intervals) = run_client_test("127.0.0.1", port, true, true, true, None, None).await;
|
||||
assert!(_tx > 0, "UDP4 both: expected tx > 0, got {}", _tx);
|
||||
assert!(_rx > 0, "UDP4 both: expected rx > 0, got {}", _rx);
|
||||
}
|
||||
|
||||
// --- TCP IPv6 Tests ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_tcp6_receive() {
|
||||
let port = BASE_PORT + 6;
|
||||
start_server_v6(port).await;
|
||||
let (_tx, _rx, _, _intervals) = run_client_test("::1", port, false, true, false, None, None).await;
|
||||
assert!(_rx > 0, "TCP6 receive: expected rx > 0, got {}", _rx);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_tcp6_send() {
|
||||
let port = BASE_PORT + 7;
|
||||
start_server_v6(port).await;
|
||||
let (_tx, _rx, _, _intervals) = run_client_test("::1", port, true, false, false, None, None).await;
|
||||
assert!(_tx > 0, "TCP6 send: expected tx > 0, got {}", _tx);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_tcp6_both() {
|
||||
let port = BASE_PORT + 8;
|
||||
start_server_v6(port).await;
|
||||
let (_tx, _rx, _, _intervals) = run_client_test("::1", port, true, true, false, None, None).await;
|
||||
assert!(_tx > 0, "TCP6 both: expected tx > 0, got {}", _tx);
|
||||
assert!(_rx > 0, "TCP6 both: expected rx > 0, got {}", _rx);
|
||||
}
|
||||
|
||||
// --- UDP IPv6 Tests (loopback, no ENOBUFS issues) ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_udp6_receive() {
|
||||
let port = BASE_PORT + 9;
|
||||
start_server_v6(port).await;
|
||||
let (_tx, _rx, _, _intervals) = run_client_test("::1", port, false, true, true, None, None).await;
|
||||
assert!(_rx > 0, "UDP6 receive: expected rx > 0, got {}", _rx);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_udp6_send() {
|
||||
let port = BASE_PORT + 10;
|
||||
start_server_v6(port).await;
|
||||
let (_tx, _rx, _, _intervals) = run_client_test("::1", port, true, false, true, None, None).await;
|
||||
assert!(_tx > 0, "UDP6 send: expected tx > 0, got {}", _tx);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_udp6_both() {
|
||||
let port = BASE_PORT + 11;
|
||||
start_server_v6(port).await;
|
||||
let (_tx, _rx, _, _intervals) = run_client_test("::1", port, true, true, true, None, None).await;
|
||||
assert!(_tx > 0, "UDP6 both: expected tx > 0, got {}", _tx);
|
||||
assert!(_rx > 0, "UDP6 both: expected rx > 0, got {}", _rx);
|
||||
}
|
||||
|
||||
// --- Authentication Tests ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_md5_auth_works() {
|
||||
let port = BASE_PORT + 12;
|
||||
start_server(port, false).await;
|
||||
let (_tx, _rx, _, _) = run_client_test(
|
||||
"127.0.0.1", port, false, true, false,
|
||||
Some("testuser"), Some("testpass"),
|
||||
).await;
|
||||
assert!(_rx > 0, "MD5 auth: expected data flow");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_ecsrp5_auth_works() {
|
||||
let port = BASE_PORT + 13;
|
||||
start_server(port, true).await;
|
||||
let (_tx, _rx, _, _) = run_client_test(
|
||||
"127.0.0.1", port, false, true, false,
|
||||
Some("testuser"), Some("testpass"),
|
||||
).await;
|
||||
assert!(_rx > 0, "EC-SRP5 auth: expected data flow");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_ecsrp5_wrong_password() {
|
||||
let port = BASE_PORT + 14;
|
||||
start_server(port, true).await;
|
||||
let state = btest_rs::bandwidth::BandwidthState::new();
|
||||
let result = btest_rs::client::run_client(
|
||||
"127.0.0.1", port,
|
||||
btest_rs::protocol::CMD_DIR_TX,
|
||||
false, 0, 0,
|
||||
Some("testuser".into()), Some("wrongpass".into()),
|
||||
false, state,
|
||||
).await;
|
||||
assert!(result.is_err(), "Wrong password should fail");
|
||||
}
|
||||
|
||||
// --- CSV Output Tests ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_csv_created_client() {
|
||||
let port = BASE_PORT + 15;
|
||||
start_server_noauth(port).await;
|
||||
|
||||
let csv_path = format!("/tmp/btest_test_csv_{}.csv", port);
|
||||
let _ = std::fs::remove_file(&csv_path);
|
||||
|
||||
// Initialize CSV
|
||||
btest_rs::csv_output::init(&csv_path).unwrap();
|
||||
|
||||
let (tx, rx, lost, intervals) = run_client_test(
|
||||
"127.0.0.1", port, false, true, false, None, None,
|
||||
).await;
|
||||
|
||||
// Write result like main.rs does
|
||||
btest_rs::csv_output::write_result(
|
||||
"127.0.0.1", port, "TCP", "receive",
|
||||
2, tx, rx, lost, 0, 0, "none",
|
||||
);
|
||||
|
||||
// Verify CSV exists and has data
|
||||
let content = std::fs::read_to_string(&csv_path).unwrap();
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
assert!(lines.len() >= 2, "CSV should have header + at least 1 row, got {} lines", lines.len());
|
||||
assert!(lines[0].starts_with("timestamp,"), "CSV header missing");
|
||||
assert!(lines[1].contains("TCP"), "CSV row should contain protocol");
|
||||
// Check that tx or rx bytes are non-zero (the 7th or 8th CSV field)
|
||||
let fields: Vec<&str> = lines[1].split(',').collect();
|
||||
assert!(fields.len() >= 10, "CSV row should have enough fields");
|
||||
let tx_bytes: u64 = fields[8].parse().unwrap_or(0);
|
||||
let rx_bytes: u64 = fields[9].parse().unwrap_or(0);
|
||||
assert!(tx_bytes > 0 || rx_bytes > 0, "CSV should have non-zero bytes: tx={} rx={}", tx_bytes, rx_bytes);
|
||||
|
||||
let _ = std::fs::remove_file(&csv_path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_csv_created_server() {
|
||||
let port = BASE_PORT + 16;
|
||||
let csv_path = format!("/tmp/btest_test_server_csv_{}.csv", port);
|
||||
let _ = std::fs::remove_file(&csv_path);
|
||||
|
||||
btest_rs::csv_output::init(&csv_path).unwrap();
|
||||
start_server_noauth(port).await;
|
||||
|
||||
let _ = run_client_test("127.0.0.1", port, false, true, false, None, None).await;
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
|
||||
let content = std::fs::read_to_string(&csv_path).unwrap_or_default();
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
assert!(lines.len() >= 2, "Server CSV should have header + rows, got {}", lines.len());
|
||||
|
||||
let _ = std::fs::remove_file(&csv_path);
|
||||
}
|
||||
|
||||
// --- Syslog Tests ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_syslog_emits_events() {
|
||||
// Bind a local UDP socket to receive syslog messages
|
||||
let syslog_sock = StdUdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
let syslog_addr = syslog_sock.local_addr().unwrap();
|
||||
syslog_sock.set_nonblocking(true).unwrap();
|
||||
|
||||
// Initialize syslog to our test socket
|
||||
btest_rs::syslog_logger::init(&syslog_addr.to_string()).unwrap();
|
||||
|
||||
let port = BASE_PORT + 17;
|
||||
start_server_noauth(port).await;
|
||||
|
||||
let _ = run_client_test("127.0.0.1", port, false, true, false, None, None).await;
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
|
||||
// Read all syslog messages
|
||||
let mut messages = Vec::new();
|
||||
let mut buf = [0u8; 4096];
|
||||
loop {
|
||||
match syslog_sock.recv(&mut buf) {
|
||||
Ok(n) => messages.push(String::from_utf8_lossy(&buf[..n]).to_string()),
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
let all = messages.join("\n");
|
||||
assert!(all.contains("AUTH_SUCCESS") || all.contains("TEST_START"),
|
||||
"Syslog should contain auth or test events, got: {}", all);
|
||||
assert!(all.contains("TEST_START"), "Syslog should contain TEST_START");
|
||||
assert!(all.contains("TEST_END"), "Syslog should contain TEST_END");
|
||||
}
|
||||
|
||||
// --- Bandwidth State Tests ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_bandwidth_state_record_interval() {
|
||||
let state = btest_rs::bandwidth::BandwidthState::new();
|
||||
state.record_interval(1000, 2000, 5);
|
||||
state.record_interval(3000, 4000, 10);
|
||||
let (tx, rx, lost, intervals) = state.summary();
|
||||
assert_eq!(tx, 4000);
|
||||
assert_eq!(rx, 6000);
|
||||
assert_eq!(lost, 15);
|
||||
assert_eq!(intervals, 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_bandwidth_state_running_flag() {
|
||||
let state = btest_rs::bandwidth::BandwidthState::new();
|
||||
assert!(state.running.load(Ordering::Relaxed));
|
||||
state.running.store(false, Ordering::SeqCst);
|
||||
assert!(!state.running.load(Ordering::Relaxed));
|
||||
}
|
||||
@@ -8,7 +8,7 @@ async fn start_test_server(port: u16, auth_user: Option<&str>, auth_pass: Option
|
||||
let user = auth_user.map(String::from);
|
||||
let pass = auth_pass.map(String::from);
|
||||
tokio::spawn(async move {
|
||||
let _ = btest_rs::server::run_server(port, user, pass).await;
|
||||
let _ = btest_rs::server::run_server(port, user, pass, false, Some("127.0.0.1".into()), None).await;
|
||||
});
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
@@ -153,6 +153,7 @@ async fn test_loopback_tcp_rx() {
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
btest_rs::bandwidth::BandwidthState::new(),
|
||||
)
|
||||
.await
|
||||
});
|
||||
@@ -177,6 +178,7 @@ async fn test_loopback_tcp_tx() {
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
btest_rs::bandwidth::BandwidthState::new(),
|
||||
)
|
||||
.await
|
||||
});
|
||||
@@ -201,6 +203,7 @@ async fn test_loopback_tcp_both() {
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
btest_rs::bandwidth::BandwidthState::new(),
|
||||
)
|
||||
.await
|
||||
});
|
||||
@@ -225,6 +228,7 @@ async fn test_loopback_tcp_with_auth() {
|
||||
Some("admin".into()),
|
||||
Some("secret".into()),
|
||||
false,
|
||||
btest_rs::bandwidth::BandwidthState::new(),
|
||||
)
|
||||
.await
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user