Releases: javimosch/remotecmd-cli
Release list
v2.4.1
Summary
Adds a relay secret exempt list (RELAY_SECRET_EXEMPT env var) that allows specific target names to connect without the relay secret. Also adds static (musl/Alpine-compatible) binaries to the release.
Changes
RELAY_SECRET_EXEMPT env var
- Comma-separated target names allowed to connect without the Bearer token
- When set, unauthenticated WebSocket connections are allowed to upgrade, but rejected on
registerunless the target name is in the exempt list - When not set (empty), behavior is unchanged: all unauthenticated connections are rejected at the WebSocket upgrade level
- Enables gradual migration: legacy nodes can keep connecting while new nodes adopt the secret
Static binaries (Alpine/musl compatible)
remotecmd-cli-linux-amd64-static(CGO_ENABLED=0)remotecmd-cli-linux-arm64-static(CGO_ENABLED=0)- Needed for Alpine Linux containers (supergato, etc.) that use musl instead of glibc
Sidecar updates (remotecmd-sidecar repo)
- Both node and go sidecars now accept
relaySecretin the pair request payload - Pass
--secrettoset-relaywhen provided - Falls back to
RCMD_RELAY_SECRETenv var
Deployment status
- dk1 relay: running with
RELAY_SECRET+RELAY_SECRET_EXEMPT=supergato,74ac167fc6df,PRINTER-BOT-V1,coolify,dk31 - 6/7 targets alive: rbm21, dk2, vps1, pve2, rbm20, supergato (all with secret)
- rfs_dev_ecobox (74ac167fc6df): offline (daemon killed, no direct SSH) — in exempt list, will connect when daemon restarts
- dk31, PRINTER-BOT-V1, coolify: connected via exempt list (legacy nodes)
Test plan
- 8 unit tests for relay secret (reject/accept/wrong/no-secret/exempt-allow/exempt-reject/helpers/splitCSV)
- Full test suite passes (21s)
- CI green
- Deployed to dk1 with secret + exempt list
- 6/7 targets verified alive through enforced relay
- supergato re-paired via sidecar with relay secret
Generated with Devin
v2.4.0
Summary
First security layer for remotecmd-cli: relay shared secret + TLS env vars. Both are opt-in and backward compatible — existing deployments continue to work without any config changes.
Changes
Relay shared secret (RELAY_SECRET)
- If
RELAY_SECRETenv var is set on the relay, all WebSocket connections must includeAuthorization: Bearer <secret>header - If not set, relay accepts all connections (backward compatible)
- Clients/daemons send the header via
dialRelay()— reads fromconfig.json(relay.secret) orRELAY_SECRETenv var - Configure with
remotecmd-cli set-relay --url <u> --name <n> --secret <s>
TLS env vars
- Relay:
RCMD_TLS_CERTandRCMD_TLS_KEYenv vars as fallback for--tls-cert/--tls-keyflags - Client:
RCMD_TLS_SKIP_VERIFY=1to skip cert verification (self-signed certs) wss://URLs already worked viawsURL()— no client changes needed
Centralized relay dialing
- All 10 relay connection sites now use
dialRelay()which adds auth headers automatically relayAuthHeaders()returns nil when no secret configured (no overhead)
Migration path
- Deploy v2.4.0 to relay and all clients/daemons (self-update works)
- When ready, set
RELAY_SECRETon the relay and restart it - Add
--secret <value>toset-relayon each client/daemon (or setRELAY_SECRETenv var) - Until all peers are updated, the relay runs without the secret — no disruption
Test plan
- Unit tests: 5 new tests for relay secret (reject/accept/wrong/no-secret/helpers)
- Full test suite passes (27s)
- CI passes (tests + coverage gate)
- Deployed to dk1 relay — all 10 targets reconnected without config changes
- Exec verified on rbm21, dk2, vps1 through new relay
Generated with Devin
v2.3.2
Summary
The update command now uses gh api (authenticated, 5000 req/hr) when available, falling back to unauthenticated HTTP (60 req/hr) if gh is not installed.
Why
Updating a fleet of 6+ machines would exhaust the unauthenticated GitHub API rate limit (60 req/hr) after a few updates. With gh authenticated, the limit is 5000/hr — enough for any fleet size.
How it works
latestReleaseInfo() now tries two paths:
gh api repos/javimosch/remotecmd-cli/releases/latest(ifghis in PATH)- Unauthenticated HTTP to
api.github.com(fallback)
This also fixes the passive nudge (maybeNudge) which uses the same function.
Test plan
- Unit tests pass
- CI passes (tests + coverage gate)
- Local:
update --checkworks while unauthenticated API is rate-limited (gh fallback) - rbm21: self-updated via gh-authenticated path
- dk1: updated via SSH (no gh installed, used HTTP fallback)
Generated with Devin
v2.3.1
Summary
Fixes the stale pair code spam bug: a daemon with an unpaired pair_code file was retrying every 15 seconds forever. In production, this ran for 2 months (~5,760 retries/day).
Changes
Tiered backoff based on pair_code file age:
- 0-5 min: 15s intervals (normal pairing — common case)
- 5-60 min: 1 min intervals (slow colleague / relay restart)
- 1-24 hours: 5 min intervals (provisioning, "I'll do it tomorrow")
- After 24h: stop, delete pair code, log "expired"
Uses the pair_code file's mtime as start, so a daemon restart doesn't reset the 24h expiry clock.
Total retries in 24h: ~290 (vs 5,760 with flat 15s).
Test plan
- Unit tests pass
- CI passes (tests + coverage gate)
- Deployed to dk1, rbm21, local — all on v2.3.1
- No pair code spam in relay log
Generated with Devin
v2.3.0 — Auto-tuned Parallel Streams + Gzip Writer Pooling
Summary
- Auto-tuned parallel streams based on file size (CERN/GridFTP research)
- Pooled gzip.Writer objects to reduce GC pressure on compressible data
- Larger relay write queue (128 frames) for better parallel stream pipelining
Auto-tuned parallel streams
File size → stream count mapping based on GridFTP/CERN research (2-10 streams optimal):
- 5-20 MiB → 2 streams
- 20-100 MiB → 3 streams
- 100+ MiB → 4 streams
Override with RCMD_PARALLEL_STREAMS=N (0 = disabled).
Gzip writer pooling
gzip.Writer is ~40KB per allocation. Previously created a new one per chunk. Now pooled with sync.Pool + Reset() for reuse. Helps most with compressible data.
Benchmark (50MB random → rbm21, 77ms RTT)
| Method | Time | vs scp |
|---|---|---|
| scp | 75.6s | 1.0x |
| rcmd auto-3 streams | 69.1s | 1.10x |
10MB zeros (compressible): 465ms (22 MB/s)
All transfers verified with sha256sum.
Test plan
- Unit tests pass
- 10MB random — integrity verified
- 10MB zeros — integrity verified
- 50MB random — integrity verified
- Auto-parallel selects correct stream count by file size
Generated with Devin
v2.2.0 — Parallel TCP Streams
Summary
- Parallel TCP streams for file transfers — auto-enabled for files > 5 MiB
- 1.74x speedup on 50MB files over high-RTT relay links (77ms RTT)
- 1.21x faster than scp for 50MB files
What changed
- Client opens N WebSocket connections (
sendFileParallel), each sending interleaved chunks - Daemon uses seek-based writes to place each chunk at the correct offset
- Relay uses atomic header+binary forwarding to prevent interleaving
- Auto-parallel: files > 5 MiB get 2 streams by default
- Override with
RCMD_PARALLEL_STREAMS=N(0 = disabled)
Benchmark (50MB random → rbm21, 77ms RTT relay)
| Method | Time | Throughput | vs scp |
|---|---|---|---|
| scp | 87.0s | 600 KB/s | 1.0x |
| rcmd 1 stream | 124.9s | 421 KB/s | 0.70x |
| rcmd 2 streams | 86.6s | 608 KB/s | 1.00x |
| rcmd 3 streams | 72.0s | 734 KB/s | 1.21x |
All transfers verified with sha256sum.
Test plan
- Unit tests pass
- 10MB transfer with 2 streams — integrity verified
- 50MB transfer with 3 streams — integrity verified
- Small file (1MB) uses single stream
- Auto-parallel for files > 5MB
Generated with Devin
v2.1.0
What's new
Two optimizations that together bring 10MB transfer to rbm21 from 14s to 9.5s — 1.45x faster than scp.
Async relay write queue
When a chunked file transfer begins, the relay starts a background goroutine to drain a buffered channel of frames to the target. This decouples the relay's read loop from the target's write speed: the relay can read chunk N+1 from the client while chunk N is still being written to the target.
Optimal chunk size: 2 MiB
Benchmarked on the real relay link (local → dk1 → rbm21, 77ms RTT). 2 MiB chunks keep the async pipeline full: 5 chunks for 10 MiB vs 2 chunks with 8 MiB, giving better overlap of read and write on the relay.
Benchmark (10MB random, 5 runs)
| Method | Time | Throughput | vs scp |
|---|---|---|---|
| scp | 13.8s | 761 KB/s | 1.0x |
| rcmd v2.0.0 (8MB, sync) | 11.0s | 953 KB/s | 1.26x |
| rcmd v2.1.0 (2MB, async) | 9.5s | 1104 KB/s | 1.45x |
Chunk size sweep (10MB random, before async queue)
| Chunk size | Time |
|---|---|
| 1 MB | 11.9s |
| 2 MB | 9.6s |
| 4 MB | 12.2s |
| 8 MB | 14.2s |
| 16 MB | 11.7s |
Generated with Devin
v2.0.0
What's new
Optimization pass on top of v1.9.0 adaptive compression.
Sample-based compression skip
Compresses a 4KB sample first; if it doesn't compress by 5%, skips the full chunk entirely. Eliminates the CPU waste on incompressible data that v1.9.0 introduced.
| Data type | v1.9.0 | v2.0.0 | Improvement |
|---|---|---|---|
| 10MB random | 14.5s + wasted CPU | 14.5s, no wasted CPU | CPU saved |
| 10MB zeros | 0.3s | 0.7s | same (compressed) |
| 10MB text | 13s | 13s | same (compressed) |
TCP_NODELAY
Disables Nagle's algorithm on all WebSocket connections (client, relay, daemon). Reduces latency for the small JSON headers that precede each binary chunk.
Buffer pooling
sync.Pool reuses bytes.Buffer across compression calls to reduce GC pressure on large transfers.
Shared dialer
Centralized wsDialer() helper with 1 MiB read/write buffers, TCP_NODELAY, and 1 MiB TCP socket buffers.
Chunk size
Tested 16 MiB chunks — caused throughput regression due to head-of-line blocking on high-RTT links. Kept at 8 MiB.
Generated with Devin
v1.9.0
What's new
Adaptive gzip compression for cp
Each 8 MiB chunk is gzip-compressed (BestSpeed level) before sending. If the compressed version is not at least 10% smaller, the raw bytes are sent instead — zero overhead for incompressible data.
| Data type | v1.8.0 | v1.9.0 | Speedup | Wire savings |
|---|---|---|---|---|
| 10MB zeros | 16s | 0.3s | 53x | 99.9% |
| 10MB text (base64) | 16.5s | 13s | 1.3x | 24% |
| 10MB source tar | 11.4MB | 6.7MB | — | 41% |
| 10MB random binary | 21s | 21s | same | 0% (skipped) |
How it works
- Client compresses each chunk with
gzip.BestSpeed - If
compressed_len < original_len * 0.9, sends compressed + setsCompressed: truein header - Relay forwards the
Compressedflag as-is (no re-serialization) - Daemon decompresses with
gzip.NewReaderbefore writing to disk
Backwards compatibility
Old daemons that don't understand Compressed will ignore the flag and write compressed bytes to disk — but old clients never set it, so this only affects new-to-new transfers.
Issues
This further improves on #7 (cp transfer performance).
Generated with Devin
v1.8.0
What's new
Binary WebSocket frames for cp (3-6x faster)
File chunks are now sent as raw binary WebSocket frames instead of base64-encoded JSON. Eliminates 33% base64 overhead and JSON re-serialization on the relay.
| File size | v1.7.0 | v1.8.0 | Speedup |
|---|---|---|---|
| 1 MB | 16.5s | 2.6s | 6.3x |
| 10 MB | 67s | 16s | 4.2x |
| 50 MB | 257s | 79s | 3.3x |
stdin forwarding for exec (issue #6)
Piped stdin is now forwarded to the remote command:
cat file.bin | remotecmd-cli exec --target node --cmd 'cat > /remote/file.bin'Binary-safe (base64-encoded in transit).
Streaming from/to disk
- Client reads files in 8 MiB chunks instead of loading the entire file into memory
- Daemon writes chunks directly to disk instead of buffering in RAM
- 50 MB transfer: client RSS 17 MB (was 50+ MB)
Larger socket buffers
1 MiB read/write buffers on client, relay, and daemon (default ~212 KB). Helps on high-RTT links.
Backwards compatibility
Legacy base64 chunk protocol preserved — old clients/daemons still work with new relays.
Issues fixed
Generated with Devin