- README: Raspberry Pi install section with auto-detect architecture
- README: pre-built binary download section for all platforms
- Docker docs: dual registry (Gitea + GHCR)
- scripts/push-docker-all.sh: push to both registries in one command
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
New in v0.6.0:
- CPU usage: local/remote shown per interval (cpu: 12%/33%)
- Warning indicator (!) when CPU > 70% on either side
- MikroTik CPU encoding: 0x80 | percentage in status byte 1
- CSV includes local_cpu_pct and remote_cpu_pct columns
- Status message format corrected to match MikroTik wire format:
[type:1][cpu:1][00:2][seq:4 LE][bytes:4 LE]
- Removed btest-opensource submodule (fully reimplemented)
- Deleted research/ecsrp5 branch
- Updated all docs: architecture, user-guide, man page, protocol
- Version bumped to 0.6.0
58 tests, all passing. Zero warnings.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- MikroTik encodes CPU as 0x80 | percentage (high bit flag)
- Deserialize: mask with 0x7F and cap at 100
- Serialize: set high bit (0x80 | cpu) to match MikroTik format
- CSV now includes local_cpu_pct and remote_cpu_pct columns
- Both client and server write CPU to CSV
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
CPU usage feature:
- New cpu.rs module: background sampler thread, cross-platform (macOS + Linux)
- Status message byte 1 now carries CPU load (0-100%), matching MikroTik format
- Status format corrected: [type][cpu][00][00][seq:4 LE][bytes:4 LE]
- Client and server exchange CPU in every status message
- Display format: "cpu: 40%/12%" (local/remote), "!" warning if > 70%
- Both client and server show local + remote CPU per interval
- Syslog TEST_END could include CPU averages (future enhancement)
Removed btest-opensource submodule — we've fully reimplemented the protocol
with EC-SRP5 auth, multi-connection, IPv6, syslog, CSV, and CPU monitoring.
The original project is still credited in LICENSE and README.
58 tests, all passing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Server now writes a CSV row for each completed test with peer IP,
protocol, direction, duration, avg speeds, bytes, and lost packets.
Verified on loopback: TX 35 Gbps, RX 51 Gbps captured in CSV.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The state is now created in main.rs and passed into run_client, so
when --duration timeout cancels the future, the stats are still
accessible via shared_state.summary(). CSV and syslog now show
real speeds and byte counts.
Verified: TCP loopback shows 32 Gbps in CSV output.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
run_client and sub-functions now return (tx_bytes, rx_bytes, lost, intervals).
BandwidthState::record_interval() called in both TCP and UDP client status
loops. CSV and syslog TEST_END now show real speeds and byte counts.
Also raised client UDP TX error threshold from 1000 to 50000 with
adaptive backoff matching the server.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Client mode now emits TEST_START and TEST_END syslog events
- Client UDP TX threshold raised from 1000 to 50000 with adaptive backoff
(matching server behavior) — prevents premature TX death on macOS
- Updated all docs (README, user-guide, architecture, protocol, docker)
- Added results.csv to gitignore
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
TEST_END now includes: duration, avg TX/RX Mbps, total bytes, lost packets.
All test functions track cumulative totals via BandwidthState::record_interval()
and return summary stats.
Example:
TEST_END peer=172.16.81.1:59070 proto=UDP dir=TX duration=6s
tx_avg=275.00Mbps rx_avg=0.00Mbps tx_bytes=206250000 rx_bytes=0 lost=0
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
With flags(no-parse) on the source, syslog-ng doesn't extract
the program name. Use match("btest-rs:" value("MESSAGE")) instead.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Syslog now uses RFC 3164 (BSD) format with proper timestamps
and facility=local0 for easy filtering
- Added deploy/syslog-ng-btest.conf with filters for:
- All btest events (all.log + daily rotation)
- Auth events only (auth.log)
- Test events only (tests.log)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
IPv6 listener now requires explicit --listen6 flag (disabled by default).
TCP over IPv6 works fully. UDP over IPv6 has macOS kernel limitations
(ENOBUFS on send_to). On Linux, IPv6 UDP works fine.
Usage:
btest -s # IPv4 only (default)
btest -s --listen6 # IPv4 + IPv6 on ::
btest -s --listen6 ::1 # IPv4 + IPv6 on specific address
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Reporting tx_bytes in TX-only mode caused MikroTik to show speed on
the wrong side (Tx instead of Rx). MikroTik tracks its own Rx by
counting UDP arrivals — the status bytes_received is for the OTHER
direction (how much we received from the client).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
In TX-only mode (MikroTik receives), we sent rx_bytes=0 in status
because we weren't receiving anything. But MikroTik client needs
to see non-zero bytes in the status to know data is flowing.
Now report tx_bytes when in TX-only mode.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
pcap analysis proved: connected send() achieves 462k pps on IPv6,
while unconnected send_to() hits ENOBUFS at 5k pps then stalls.
Reverted the "always unconnected for IPv6" workaround. Now only
multi-connection mode uses unconnected sockets. Single-connection
always connects, which works for both IPv4 and IPv6 TX and RX.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ENOBUFS hits every send on macOS IPv6 because the interface output queue
is full. The adaptive backoff never recovered because consecutive_errors
never reset. Now reset after sleeping, and yield more frequently (every
16 packets instead of 64).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
macOS returns ENOBUFS on IPv6 send_to() until NDP neighbor resolution
completes. Send a 1-byte probe packet and wait 200ms for NDP to resolve
before starting the data blast.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The into_std/from_std conversion lost the buffer settings. Now create
the raw socket with socket2 first, set SO_SNDBUF/SO_RCVBUF to 4MB,
then wrap with tokio. Also logs actual buffer sizes for debugging.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
macOS IPv6 UDP sockets have tiny default send buffers, causing
immediate ENOBUFS on every send_to(). Set SO_SNDBUF and SO_RCVBUF
to 4MB using socket2, matching what works for high-throughput IPv4.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Same fix as server side — format!("{}:{}", ipv6, port) fails.
Use SocketAddr::new() for IPv6 and bind to [::] instead of 0.0.0.0.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
IPv6 UDP sends hit ENOBUFS much faster than IPv4 (smaller kernel
buffers, NDP overhead). Fixed:
- Adaptive backoff: 200us→10ms as errors accumulate, resets on success
- Higher error threshold: 50k instead of 1k before stopping
- Yield with sleep when errors have been seen recently
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
macOS connected IPv6 UDP sockets don't receive properly.
Use unconnected socket (send_to/recv_from) for IPv6 peers,
same as multi-connection mode.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
format!("{}:{}", ipv6_addr, port) produces invalid socket address.
Use SocketAddr::new() instead. Also bind UDP to [::] for IPv6 peers
and 0.0.0.0 for IPv4 peers.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Server now binds on both IPv4 (0.0.0.0) and IPv6 (::) by default.
Uses tokio::select! to accept from whichever listener has a connection.
New flags:
--listen <addr> IPv4 listen address (default: 0.0.0.0, "none" to disable)
--listen6 <addr> IPv6 listen address (default: ::, "none" to disable)
Examples:
btest -s # listen on both v4 and v6
btest -s --listen6 none # IPv4 only
btest -s --listen none # IPv6 only
btest -s --listen 192.168.1.1 # specific IPv4 address
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
New features:
- --syslog <address:port> sends structured events to remote syslog (RFC 5424 UDP)
Events: AUTH_SUCCESS, AUTH_FAILURE, TEST_START, TEST_END, TEST_RESULT
- EC-SRP5 authentication for both client and server modes
- TCP multi-connection support (session tokens, all 3 directions)
Bug fixes since v0.2.0:
- EC-SRP5 server: fixed gamma parity (was 50% auth failure rate)
- EC-SRP5 server: use lift_x not redp1 for verification
- TCP send direction: server sends 12-byte status messages to client
- TCP both direction: TX loop injects status between data packets
- TCP data: send all zeros (no 0x07 header that MikroTik rejected)
- TCP disconnect detection: running flag set on EOF
- UDP multi-connection: unconnected socket accepts all source ports
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
In BOTH direction, the TX loop now injects 12-byte status messages
every 1 second between data packets, reporting rx_bytes to the client.
Multi-connection mode also updated with same logic for all 3 cases:
- TX only: pure data
- RX only: status sender on writer
- BOTH: TX data + interleaved status messages
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The status sender and status_report_loop were BOTH calling swap(0)
on rx_bytes, racing each other. Now the status sender owns the swap
and prints stats itself. The report loop is skipped in RX-only TCP mode.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Was sending cumulative rx_bytes total which zigzagged because
MikroTik interprets the value as per-interval bandwidth.
Now tracks last_rx and sends (current - last) delta each second.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
pcap of MikroTik-as-server showed it sends periodic 12-byte status
messages back to the client even in RX-only mode. The client needs
these to display speed. Added tcp_status_sender that writes status
messages containing rx_bytes on the TCP write half every 1 second.
Reverted the "always bidirectional" change — TCP direction is
conditional, but RX mode now uses the writer for status instead
of keeping it idle.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>