Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -3,3 +3,5 @@
|
||||
btest_original
|
||||
.claude/
|
||||
.env
|
||||
proto-test/venv/
|
||||
**/__pycache__/
|
||||
|
||||
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.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"
|
||||
|
||||
11
Cargo.toml
11
Cargo.toml
@@ -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
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.
|
||||
|
||||
22
README.md
22
README.md
@@ -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
|
||||
|
||||
|
||||
@@ -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
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)
|
||||
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()
|
||||
@@ -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
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,7 @@
|
||||
pub mod auth;
|
||||
pub mod bandwidth;
|
||||
pub mod client;
|
||||
pub mod ecsrp5;
|
||||
pub mod protocol;
|
||||
pub mod server;
|
||||
pub mod syslog_logger;
|
||||
|
||||
29
src/main.rs
29
src/main.rs
@@ -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 {
|
||||
|
||||
@@ -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?;
|
||||
|
||||
414
src/server.rs
414
src/server.rs
@@ -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
117
src/syslog_logger.rs
Normal 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
188
tests/ecsrp5_test.rs
Normal 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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user