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>
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>
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>
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>
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>
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>
Secondary connections send [TOKEN_HI, TOKEN_LO, 0x02, 0x00, ...]
as their command — they don't do auth. Server verifies the session
token matches a pending session from the same IP, sends OK with
token, and lets them join. No auth challenge/response needed.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Secondary connections were rejected at recv_command with "Invalid command"
because they don't send a standard 16-byte command. Now we read raw bytes
first, check if there's a pending session from the same IP, and handle
secondary connections before validating the command format.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When tcp_conn_count > 0, the auth OK response includes a session
token in bytes 1-2: [01, HI, LO, 00] instead of [01, 00, 00, 00].
MikroTik checks these bytes to determine multi-connection support.
Primary connection: full handshake, receives session token
Secondary connections: auth with same token, join the session
Server waits up to 10s for all connections to join before starting.
This fixes MikroTik showing "test unsupported" for TCP multi-conn.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Root cause found via pcap analysis: MikroTik with connection-count=N
sends UDP from N different source ports (2257, 2258, 2259, ...) all
to our single server port 2001. A connect()'d UDP socket only accepts
packets from the one connected address, silently dropping ~75% of
traffic with conn_count=4.
Fix: when tcp_conn_count > 0, leave the UDP socket unconnected and
use send_to()/recv_from() instead of send()/recv(). This accepts
packets from all MikroTik source ports.
This bug also exists in the original C btest-opensource.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Dockerfile.static: takes pre-built binary, no compilation
- push-docker.sh: downloads x86_64 from CI release, builds arm64
natively, creates multi-arch manifest and pushes both
- docker pull works on both Intel and Apple Silicon / RPi
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Multi-connection mode is not supported and causes near-zero throughput.
Updated README, user guide, MikroTik CLI examples, and troubleshooting.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
crane can't pull scratch in CI. Docker images are built locally
on Mac where Docker is available, then pushed to Gitea registry.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
crane append doesn't support --set-entrypoint. Use crane mutate
as a separate step to set entrypoint and cmd on the pushed image.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Uses crane (no Docker daemon needed) to build a minimal scratch-based
OCI image from the static musl binary and push it to the Gitea
container registry. Tags both vX.Y.Z and latest.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Uses gcc-mingw-w64 to cross-compile btest.exe from Linux.
Release now includes 4 targets: Linux x86_64/aarch64/armv7 + Windows x86_64.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The act runner has no Node.js in container jobs. Replace
actions/upload-artifact and actions/download-artifact with
direct Gitea API uploads from a single job that builds all
three architectures sequentially.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The act runner already mounts the repo at the workspace path.
Removed manual git clone and working_directory overrides.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The act runner executes actions/checkout inside the job container,
but that action is a Node.js script and rust:1.86-slim has no node.
Use plain git clone instead.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- .gitea/workflows/ci.yml: run tests on every push/PR
- .gitea/workflows/release.yml: build Linux binaries on tag push
- x86_64 (musl static)
- aarch64 / RPi 64-bit (musl static)
- armv7 / RPi 32-bit (musl static)
- Auto-creates Gitea release with all artifacts
- scripts/build-macos-release.sh: build macOS binary locally and
upload to an existing Gitea release
Release flow:
git tag v0.1.0 && git push origin v0.1.0
# CI builds Linux + RPi, creates release
# Then on Mac: ./scripts/build-macos-release.sh --upload v0.1.0
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Rename package to btest-rs (Rust convention for reimplementations)
- MIT license matching the original btest-opensource license
- LICENSE explicitly credits Alex Samorukov's original work
- Comprehensive README with usage, performance numbers, and credits
- CLI --help references the original project
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Dockerfile.cross: builds static x86_64 musl binary from macOS via Docker
- scripts/build-linux.sh: one-command cross-compilation
- scripts/install-service.sh: systemd service with security hardening
- Bump Rust Docker images to 1.86 for edition2024 support
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Full reimplementation of the MikroTik Bandwidth Test protocol:
- Server mode: accepts connections from MikroTik devices on port 2000
- Client mode: connects to MikroTik btest servers
- TCP and UDP protocols with bidirectional support
- MD5 challenge-response authentication
- Dynamic speed adjustment (1.5x algorithm)
- Status exchange matching original C pselect() behavior
- Docker support with multi-stage build
Tested against MikroTik RouterOS achieving:
- 1.05 Gbps server RX (single connection)
- 530 Mbps client TCP download
- 840 Mbps client TCP upload
- 433 Mbps client UDP download
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>