26 Commits

Author SHA1 Message Date
Siavash Sameni
a28fc1dc08 v0.5.0: IPv6 off by default, mark as experimental
All checks were successful
CI / test (push) Successful in 1m25s
Build & Release / release (push) Successful in 3m0s
IPv6 listener now requires explicit --listen6 flag (disabled by default).
TCP over IPv6 works fully. UDP over IPv6 has macOS kernel limitations
(ENOBUFS on send_to). On Linux, IPv6 UDP works fine.

Usage:
  btest -s                    # IPv4 only (default)
  btest -s --listen6          # IPv4 + IPv6 on ::
  btest -s --listen6 ::1      # IPv4 + IPv6 on specific address

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 20:54:53 +04:00
Siavash Sameni
29643e7589 Revert: always report rx_bytes in UDP status, not tx_bytes
All checks were successful
CI / test (push) Successful in 1m27s
Reporting tx_bytes in TX-only mode caused MikroTik to show speed on
the wrong side (Tx instead of Rx). MikroTik tracks its own Rx by
counting UDP arrivals — the status bytes_received is for the OTHER
direction (how much we received from the client).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 19:52:14 +04:00
Siavash Sameni
0c14e6cf5b Fix UDP TX-only status: report tx_bytes instead of rx_bytes
All checks were successful
CI / test (push) Successful in 1m26s
Build & Release / release (push) Successful in 3m8s
In TX-only mode (MikroTik receives), we sent rx_bytes=0 in status
because we weren't receiving anything. But MikroTik client needs
to see non-zero bytes in the status to know data is flowing.
Now report tx_bytes when in TX-only mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 19:39:28 +04:00
Siavash Sameni
b8fa6d4580 Fix IPv6 UDP server TX: use connected socket for single-connection
All checks were successful
CI / test (push) Successful in 1m27s
pcap analysis proved: connected send() achieves 462k pps on IPv6,
while unconnected send_to() hits ENOBUFS at 5k pps then stalls.

Reverted the "always unconnected for IPv6" workaround. Now only
multi-connection mode uses unconnected sockets. Single-connection
always connects, which works for both IPv4 and IPv6 TX and RX.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 19:28:42 +04:00
Siavash Sameni
6288fe9f25 Fix IPv6 UDP TX: reset consecutive_errors after yield, pace every 16 pkts
All checks were successful
CI / test (push) Successful in 1m27s
ENOBUFS hits every send on macOS IPv6 because the interface output queue
is full. The adaptive backoff never recovered because consecutive_errors
never reset. Now reset after sleeping, and yield more frequently (every
16 packets instead of 64).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 19:13:46 +04:00
Siavash Sameni
50c0ba528d Fix IPv6 UDP: send NDP probe before data to populate neighbor cache
All checks were successful
CI / test (push) Successful in 1m26s
macOS returns ENOBUFS on IPv6 send_to() until NDP neighbor resolution
completes. Send a 1-byte probe packet and wait 200ms for NDP to resolve
before starting the data blast.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 19:06:41 +04:00
Siavash Sameni
4e3b2939ca Fix IPv6 UDP buffers: create socket with socket2 before tokio
All checks were successful
CI / test (push) Successful in 1m27s
The into_std/from_std conversion lost the buffer settings. Now create
the raw socket with socket2 first, set SO_SNDBUF/SO_RCVBUF to 4MB,
then wrap with tokio. Also logs actual buffer sizes for debugging.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 19:04:27 +04:00
Siavash Sameni
6ba57864a0 Fix IPv6 UDP TX: enlarge socket buffers to 4MB
All checks were successful
CI / test (push) Successful in 1m25s
macOS IPv6 UDP sockets have tiny default send buffers, causing
immediate ENOBUFS on every send_to(). Set SO_SNDBUF and SO_RCVBUF
to 4MB using socket2, matching what works for high-throughput IPv4.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 19:01:50 +04:00
Siavash Sameni
a1dbc6dc5a Fix client IPv6 UDP: use SocketAddr::new() and bind to [::]
All checks were successful
CI / test (push) Successful in 1m26s
Build & Release / release (push) Successful in 3m10s
Same fix as server side — format!("{}:{}", ipv6, port) fails.
Use SocketAddr::new() for IPv6 and bind to [::] instead of 0.0.0.0.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 18:54:45 +04:00
Siavash Sameni
7be6a0d541 Fix IPv6 UDP TX: adaptive backoff on ENOBUFS
All checks were successful
CI / test (push) Successful in 1m28s
Build & Release / release (push) Successful in 3m10s
IPv6 UDP sends hit ENOBUFS much faster than IPv4 (smaller kernel
buffers, NDP overhead). Fixed:
- Adaptive backoff: 200us→10ms as errors accumulate, resets on success
- Higher error threshold: 50k instead of 1k before stopping
- Yield with sleep when errors have been seen recently

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 18:45:04 +04:00
Siavash Sameni
ba0a8f1b7c Add UDP TX error logging for IPv6 debugging
All checks were successful
CI / test (push) Successful in 1m27s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 18:41:57 +04:00
Siavash Sameni
176cdae239 Fix IPv6 UDP: use unconnected socket for IPv6 peers
All checks were successful
CI / test (push) Successful in 1m28s
macOS connected IPv6 UDP sockets don't receive properly.
Use unconnected socket (send_to/recv_from) for IPv6 peers,
same as multi-connection mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 18:37:43 +04:00
Siavash Sameni
0385d2e745 Fix IPv6 UDP: use SocketAddr::new() and bind correct address family
All checks were successful
CI / test (push) Successful in 1m23s
format!("{}:{}", ipv6_addr, port) produces invalid socket address.
Use SocketAddr::new() instead. Also bind UDP to [::] for IPv6 peers
and 0.0.0.0 for IPv4 peers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 18:31:22 +04:00
Siavash Sameni
7bbb7c9d9b Add dual-stack IPv4+IPv6 listening
All checks were successful
CI / test (push) Successful in 1m24s
Server now binds on both IPv4 (0.0.0.0) and IPv6 (::) by default.
Uses tokio::select! to accept from whichever listener has a connection.

New flags:
  --listen <addr>   IPv4 listen address (default: 0.0.0.0, "none" to disable)
  --listen6 <addr>  IPv6 listen address (default: ::, "none" to disable)

Examples:
  btest -s                          # listen on both v4 and v6
  btest -s --listen6 none           # IPv4 only
  btest -s --listen none            # IPv6 only
  btest -s --listen 192.168.1.1     # specific IPv4 address

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 18:28:48 +04:00
Siavash Sameni
2dec6cc007 v0.5.0: Add syslog support, fix TCP send/both, EC-SRP5 server auth
All checks were successful
CI / test (push) Successful in 1m22s
New features:
- --syslog <address:port> sends structured events to remote syslog (RFC 5424 UDP)
  Events: AUTH_SUCCESS, AUTH_FAILURE, TEST_START, TEST_END, TEST_RESULT
- EC-SRP5 authentication for both client and server modes
- TCP multi-connection support (session tokens, all 3 directions)

Bug fixes since v0.2.0:
- EC-SRP5 server: fixed gamma parity (was 50% auth failure rate)
- EC-SRP5 server: use lift_x not redp1 for verification
- TCP send direction: server sends 12-byte status messages to client
- TCP both direction: TX loop injects status between data packets
- TCP data: send all zeros (no 0x07 header that MikroTik rejected)
- TCP disconnect detection: running flag set on EOF
- UDP multi-connection: unconnected socket accepts all source ports

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 18:22:31 +04:00
Siavash Sameni
f9289cca55 Add TCP status for bidirectional mode
All checks were successful
CI / test (push) Successful in 1m22s
In BOTH direction, the TX loop now injects 12-byte status messages
every 1 second between data packets, reporting rx_bytes to the client.
Multi-connection mode also updated with same logic for all 3 cases:
- TX only: pure data
- RX only: status sender on writer
- BOTH: TX data + interleaved status messages

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 18:10:46 +04:00
Siavash Sameni
8b127d833f Fix TCP status: use swap(0) and skip status_report_loop in RX mode
All checks were successful
CI / test (push) Successful in 1m24s
The status sender and status_report_loop were BOTH calling swap(0)
on rx_bytes, racing each other. Now the status sender owns the swap
and prints stats itself. The report loop is skipped in RX-only TCP mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 18:03:28 +04:00
Siavash Sameni
cdad23ffa0 Fix TCP status: report delta bytes per interval, not cumulative
All checks were successful
CI / test (push) Successful in 1m20s
Was sending cumulative rx_bytes total which zigzagged because
MikroTik interprets the value as per-interval bandwidth.
Now tracks last_rx and sends (current - last) delta each second.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 17:58:58 +04:00
Siavash Sameni
51bc4ddf16 Fix TCP send: server sends 12-byte status messages when receiving
All checks were successful
CI / test (push) Successful in 1m19s
pcap of MikroTik-as-server showed it sends periodic 12-byte status
messages back to the client even in RX-only mode. The client needs
these to display speed. Added tcp_status_sender that writes status
messages containing rx_bytes on the TCP write half every 1 second.

Reverted the "always bidirectional" change — TCP direction is
conditional, but RX mode now uses the writer for status instead
of keeping it idle.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 17:56:36 +04:00
Siavash Sameni
fa4fd63fb3 Fix TCP: always send bidirectional data regardless of direction
All checks were successful
CI / test (push) Successful in 1m21s
MITM capture of MikroTik-to-MikroTik showed both sides always send
zero-filled TCP streams, regardless of the direction setting. Direction
only controls what gets measured. Our server wasn't starting a TX thread
when direction=RX, so MikroTik saw no data and reported 0 speed.

Now TCP always starts both TX and RX on every connection.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 17:46:16 +04:00
Siavash Sameni
d8f3b9c189 Fix TCP data: send all zeros, not 0x07 header
All checks were successful
CI / test (push) Successful in 1m20s
MITM capture showed MikroTik sends all-zero TCP data streams.
Our server was setting packet[0]=0x07 (STATUS_MSG_TYPE), which
MikroTik rejected. TCP mode has no status headers — just raw
zero-filled data streams in both directions.

Fixed in both server and client TCP TX loops.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 17:39:12 +04:00
Siavash Sameni
9552cbef1a Fix disconnect detection: TX/RX loops set running=false on EOF
All checks were successful
CI / test (push) Successful in 1m20s
When a TCP connection closes (EOF or write error), the loop now sets
the shared running flag to false, which stops the status report loop
and all other tasks. Adds "test ended" log messages.

The TCP multi-conn "MikroTik shows 0 on send" is a separate issue
requiring TCP-level status exchange (MikroTik sends 12-byte status
messages on TCP connections, not just a data stream).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 17:30:16 +04:00
Siavash Sameni
6c82228dd1 Fix EC-SRP5 server: use stored gamma parity, not hardcoded true
All checks were successful
CI / test (push) Successful in 1m21s
The gamma point's y-parity depends on the random salt. Using hardcoded
parity=true caused ~50% of auth attempts to fail (whenever the actual
parity was 0). Now stored from key derivation and used correctly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 17:22:06 +04:00
Siavash Sameni
a87dd7510f Fix TCP multi-connection: TX/RX on ALL streams, not just primary
All checks were successful
CI / test (push) Successful in 1m19s
pcap analysis showed MikroTik sends/receives data across all 20 TCP
connections, but we only used the primary. Now all streams get their
own TX and RX tasks, distributing bandwidth across all connections.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 17:17:00 +04:00
Siavash Sameni
b28c553e10 Fix EC-SRP5 server: use lift_x not redp1 for verification
All checks were successful
CI / test (push) Successful in 1m20s
Server-side shared secret used redp1(x_gamma) which is the hash-to-curve
blinding function, but verification needs lift_x(x_gamma) — the raw
validator public key point. Also fixed prime_mod_sqrt for p ≡ 5 (mod 8)
using Atkin's algorithm instead of Tonelli-Shanks.

Removed unused password parameter from server_authenticate.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 17:10:31 +04:00
Siavash Sameni
58274da859 Add EC-SRP5 authentication (RouterOS >= 6.43)
All checks were successful
CI / test (push) Successful in 1m18s
Client: auto-detects 03 response and performs EC-SRP5 handshake
Server: --ecsrp5 flag enables Curve25519 Weierstrass EC-SRP5 auth
  btest -s -a admin -p password --ecsrp5

Protocol: [len][payload] framing (no 0x06 handler, unlike Winbox)
Crypto: Curve25519 in Weierstrass form, SHA256, SRP key exchange

Based on MarginResearch/mikrotik_authentication (Apache 2.0).
Verified against MikroTik RouterOS 7.x via MITM protocol analysis.

34 tests (10 unit, 6 EC-SRP5 integration, 8 base integration, 10 doc-tests).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 16:56:38 +04:00
17 changed files with 1966 additions and 77 deletions

2
.gitignore vendored
View File

@@ -3,3 +3,5 @@
btest_original
.claude/
.env
proto-test/venv/
**/__pycache__/

122
Cargo.lock generated
View File

@@ -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.5.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"

View File

@@ -1,9 +1,9 @@
[package]
name = "btest-rs"
version = "0.1.0"
version = "0.5.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

13
LICENSE
View File

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

View File

@@ -132,9 +132,24 @@ The MikroTik btest protocol uses:
See the [original protocol documentation](btest-opensource/README.md) for wire-format details.
## Authentication
Both MD5 (legacy) and EC-SRP5 (RouterOS >= 6.43) authentication are supported:
```bash
# Server with MD5 auth (legacy clients)
btest -s -a admin -p password
# Server with EC-SRP5 auth (modern RouterOS clients)
btest -s -a admin -p password --ecsrp5
# Client auto-detects auth type
btest -c 192.168.88.1 -r -a admin -p password
```
## Known Limitations
- **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.
- **IPv6 support is experimental** (`--listen6`). TCP over IPv6 works fully. UDP over IPv6 has issues on macOS due to kernel ENOBUFS limitations with `send_to()`. On Linux, IPv6 UDP works fine. IPv6 is disabled by default.
- **Multi-connection UDP** is supported. MikroTik's multi-connection mode sends from multiple source ports which are all accepted by the server.
## Testing
@@ -148,8 +163,9 @@ 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

View File

@@ -50,12 +50,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_proof:32]
SRV->>TCP: [len][server_proof: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
@@ -179,6 +187,7 @@ btest-rs/
│ ├── 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

238
docs/ecsrp5-research.md Normal file
View 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)

View 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()

View File

@@ -37,29 +37,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!(
@@ -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();
@@ -198,9 +213,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 {

659
src/ecsrp5.rs Normal file
View 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) % &order;
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);
}
}

View File

@@ -1,5 +1,7 @@
pub mod auth;
pub mod bandwidth;
pub mod client;
pub mod ecsrp5;
pub mod protocol;
pub mod server;
pub mod syslog_logger;

View File

@@ -1,8 +1,10 @@
mod auth;
mod bandwidth;
mod client;
mod ecsrp5;
mod protocol;
mod server;
pub mod syslog_logger;
use clap::Parser;
use tracing_subscriber::EnvFilter;
@@ -48,6 +50,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 +66,18 @@ 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,
/// 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,
@@ -82,10 +100,19 @@ 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);
}
}
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 {

View File

@@ -188,6 +188,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?;

View File

@@ -26,28 +26,99 @@ 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, sessions).await
handle_client(stream, peer, auth_user, auth_pass, udp_offset, sessions, ecsrp5).await
{
tracing::error!("Client {} error: {}", peer, e);
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);
}
}
});
}
@@ -60,6 +131,7 @@ async fn handle_client(
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)?;
@@ -182,15 +254,41 @@ async fn handle_client(
}
// Primary connection auth
auth::server_authenticate(
&mut stream,
auth_user.as_deref(),
auth_pass.as_deref(),
&ok_response,
)
.await?;
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?;
if cmd.is_udp() {
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;
@@ -235,18 +333,21 @@ async fn handle_client(
.unwrap_or_default()
};
let mut all_streams = vec![stream];
all_streams.extend(extra_streams);
tracing::info!(
"TCP multi-connection: starting with {} total streams",
1 + extra_streams.len(),
all_streams.len(),
);
// Run test - primary stream handles data, extras provide parallel TCP bandwidth
// For now just use the primary; extras keep the connection alive
let _extra_keepalive = extra_streams;
run_tcp_test_server(stream, cmd).await
run_tcp_multiconn_server(all_streams, cmd).await
} else {
run_tcp_test_server(stream, cmd).await
}
};
crate::syslog_logger::test_end(&peer.to_string(), proto_str, dir_str);
result
}
// --- TCP Test Server ---
@@ -260,15 +361,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
@@ -284,7 +396,26 @@ 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; }
@@ -292,21 +423,120 @@ async fn run_tcp_test_server(stream: TcpStream, cmd: Command) -> Result<()> {
Ok(())
}
/// TCP multi-connection.
async fn run_tcp_multiconn_server(streams: Vec<TcpStream>, cmd: Command) -> Result<()> {
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(())
}
async fn tcp_tx_loop(
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, 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 {
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;
}
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);
@@ -337,7 +567,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);
}
@@ -345,6 +578,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 {
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;
// Also print locally
bandwidth::print_status(seq, "RX", rx_bytes, Duration::from_secs(1), None);
}
}
// --- UDP Test Server ---
async fn run_udp_test_server(
@@ -365,25 +637,60 @@ 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();
// 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.
// So: only connect() for single-connection mode (enables send() without addr).
// For multi-connection, we leave the socket unconnected and use send_to()/recv_from().
let multi_conn = cmd.tcp_conn_count > 0;
if !multi_conn {
// 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 multi_conn { "unconnected (multi-port RX)" } else { "connected" },
if use_unconnected { "unconnected" } else { "connected" },
);
let state = BandwidthState::new();
@@ -397,7 +704,7 @@ 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 = multi_conn;
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, is_multi, tx_target).await
@@ -453,13 +760,20 @@ async fn udp_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, target);
}
if consecutive_errors > 50000 {
tracing::warn!("UDP TX: too many consecutive send errors, stopping");
break;
}
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;
}
}
@@ -483,9 +797,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;
}
}
}
}
@@ -627,9 +949,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);
// 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 {
seq,
bytes_received: rx_bytes as u32,
bytes_received: report_bytes as u32,
};
let serialized = status.serialize();
tracing::debug!(

117
src/syslog_logger.rs Normal file
View File

@@ -0,0 +1,117 @@
//! 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 5424 facility=1 (user), severity as given
let priority = 8 + severity; // facility=1 (user-level) * 8 + severity
let timestamp = chrono_lite_now();
let syslog_msg = format!(
"<{}>1 {} {} btest-rs - - - {}",
priority, timestamp, sender.hostname, msg,
);
let _ = sender.socket.send_to(syslog_msg.as_bytes(), &sender.target);
}
}
fn chrono_lite_now() -> String {
// Simple ISO 8601 timestamp without chrono dependency
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
let secs = now.as_secs();
// Good enough for syslog — not perfect but functional
format!("{}", secs)
}
// --- 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) {
let msg = format!(
"TEST_END peer={} proto={} dir={}",
peer, proto, direction,
);
tracing::info!("{}", msg);
send(6, &msg);
}
pub fn test_result(
peer: &str,
direction: &str,
avg_mbps: f64,
duration_secs: u32,
) {
let msg = format!(
"TEST_RESULT peer={} dir={} avg_mbps={:.2} duration={}s",
peer, direction, avg_mbps, duration_secs,
);
tracing::info!("{}", msg);
send(6, &msg);
}
/// Check if syslog is enabled.
pub fn is_enabled() -> bool {
SYSLOG.lock().unwrap().is_some()
}

188
tests/ecsrp5_test.rs Normal file
View File

@@ -0,0 +1,188 @@
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,
)
.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,
)
.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,
)
.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,
)
.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,
)
.await
});
tokio::time::sleep(Duration::from_secs(3)).await;
handle.abort();
}

View File

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