Signed-off-by: Guenther Alka gea@napp-it.org
Concept Co-Authored-By: Claude Fable 5 noreply@anthropic.com
Encrypted TCP stream transport for ZFS replication. Replaces ssh, mbuffer, netcat (nc) and pv in ZFS send/receive pipelines.
Part of napp-it cs web-gui -- used by job-replicate and job-filesync for encrypted ZFS replication and rclone-over-SFTP folder sync between members.
Renamed from "zstream" as of v2.0.0 (avoids clashing with the unrelated OpenZFS tool of the same name) -- functionally identical to zstream v1.3.3, same wire protocol and CLI.
v2.1.0. listen/send (ZFS replication) and tunnel-listen/tunnel-send
(rclone-over-SFTP folder sync) are both implemented and live-deployed to
the official napp-it CS menu path
(data/cs_server/tools/cs-stream/<os>.amd64/) on all reachable cluster
members. --allow-ip is now mandatory on listen and tunnel-listen
(see Security Audit below) -- this is a breaking CLI change from v2.0.0.
v2.1.0 (2026-07-28) -- security audit: mandatory --allow-ip, tunnel
session cap, rate/progress warning.
A full source audit (single-file main.go, not triggered by a live-test
failure) found and fixed three issues:
- No source-IP restriction on
listen/tunnel-listen. Both accepted connections from any source unconditionally.listenonly ever accepts a single connection for the whole process lifetime -- an unauthenticated host reaching the port could connect first and occupy that one slot, blocking the legitimate sender (a pure availability/DoS issue; AES-256-GCM already prevented any actual data read or write without the key, so this was never a confidentiality or integrity gap).tunnel-listenis a persistent multi-session accept loop with no cap at all, so the exposure window was open for as long as the tunnel ran. cs-sync's siblingservecommand has required--allow-ipsince its own v2.1.0 for exactly this reason; cs-stream now matches that convention. Fix:--allow-ip=IPis now mandatory onlistenandtunnel-listen.doListenloops onAccept(), rejecting and closing any non-matching connection and continuing to wait rather than consuming the single accept slot -- the original accept-timeout deadline keeps counting down across every attempt, so a flood of wrong-IP connections cannot extend the window and delay or starve the real sender.doTunnelListenchecks the allow-listed IP per session in its existing accept loop. - No cap on concurrent
tunnel-listensessions. AddedmaxTunnelSessions = 64with an atomic counter -- defense in depth alongside the--allow-ipcheck, bounding resource use even from the allow-listed IP. --rateand--progresswere silently ignored in tunnel mode (unlike--buf, which has an explicit architectural justification in its own doc comment for why it doesn't apply there). Now logs an explicit one-timeWARNINGinstead of silently no-op'ing.
Verified: go build/go vet/staticcheck all clean; cross-compiled
clean for all 8 release platforms; live-tested on all 5 reachable cluster
members -- see Security audit & live test results below.
# Receiver (start first) -- --allow-ip is REQUIRED as of v2.1.0:
cs-stream listen 9000 SESSIONKEY --allow-ip=192.168.1.20 | zfs receive -F tank/backup
# Sender:
zfs send tank@snap | cs-stream send 192.168.1.10 9000 SESSIONKEY
# With options (rate limit, progress, log):
zfs send tank@snap | cs-stream send 192.168.1.10 9000 SESSIONKEY --rate=100m --progress --log=/tmp/cs-stream.log| Option | Default | Description |
|---|---|---|
--buf=SIZE |
128m |
Read-ahead buffer (0 = off). Units: k/m/g |
--rate=SPEED |
off | Throughput limit per second. Units: k/m/g (not supported in tunnel mode) |
--progress |
off | Live progress on stderr (not supported in tunnel mode) |
--log=FILE |
off | Append transfer summary to file |
--bind=IP |
0.0.0.0 |
Bind listener to specific IP |
--allow-ip=IP |
(none) | Required on listen/tunnel-listen: only this source IP may connect |
# Folder sync via rclone over encrypted tunnel.
# --local=ADDR and --allow-ip=IP are both required on tunnel-listen:
# --local is the local address the wrapped child process binds to
# (tunnel-listen) or connects to (tunnel-send); --allow-ip is the only
# source IP tunnel-listen will accept sessions from.
# Receiver (start first):
cs-stream tunnel-listen 9000 KEY --local=127.0.0.1:2222 --allow-ip=192.168.1.10 -- rclone serve sftp /dst --addr 127.0.0.1:2222
# Sender:
cs-stream tunnel-send HOST 9000 KEY --local=127.0.0.1:2222 -- rclone sync /src :sftp: --sftp-host=127.0.0.1 --sftp-port=2222
- AES-256-GCM authenticated encryption (Go stdlib, AES-NI accelerated)
- Session key per transfer (generated by job-replicate.pl / job-filesync.pl)
- Each 64KB chunk independently encrypted with random nonce
- Authentication tag detects any tampering or corruption
--allow-ip(required onlisten/tunnel-listen) restricts which source IP may even attempt a handshake- Accept timeout (120s) prevents listener from waiting forever
- Read timeout (5 min) detects dead sender or network failure
--bind=IPrestricts listener to specific interface
Full source read of main.go (no _test.go files exist in this repo --
noted as a gap for a future session, same finding as
cs-sync's own audit).
go vet/staticcheck clean before and after. The three fixes above were
each verified functionally, then live-tested across the full napp-it CS
cluster platform matrix after deploying v2.1.0 binaries everywhere.
| Test | What it checks |
|---|---|
| A | listen/tunnel-listen refuse to start at all without --allow-ip |
| B | A connection from a non-matching IP is refused and logged; the listener keeps running (does not exit or consume its one accept slot) |
| C | A connection from the correct allow-listed IP succeeds end-to-end, payload decrypts correctly |
| Host | OS | Test A | Test B | Test C |
|---|---|---|---|---|
| my-w11 | Windows | pass | pass | pass |
| .112 | Linux | pass | pass | pass |
| .191 | FreeBSD | pass | pass | pass |
| .196 | macOS | pass | pass | pass |
| .189 | OmniOS/illumos | pass | pass | pass |
All five reachable cluster members confirmed clean, zero errors beyond
the intentional refusal log lines. cs-stream version confirmed v2.1.0
on every host before testing.
Reviewed but not flagged as actionable (documented for a future audit so the same analysis isn't repeated):
- The nonce/key lifetime: random 96-bit nonces, fresh per 64KB chunk, under a static key that never rotates across reconnects. The birthday-bound collision risk for random 96-bit nonces becomes non-negligible around 2^32 messages under one key -- at 64KB/chunk that's on the order of 256 terabytes of lifetime traffic under a single static key to become a real concern, not realistically reachable for this tool's actual use case (one key per transfer, generated fresh by job-replicate.pl/job-filesync.pl for every job run). Noted as a defense-in-depth/forward-secrecy observation, not an actionable vulnerability.
deriveKeyuses a plain SHA-256 hash rather than a proper KDF (HKDF/ PBKDF2) to expand the session key string to 32 bytes. Acceptable given the actual key material is always a freshly-generatedsha256_hex(time+ips+snap)string from the Perl caller (high entropy already), not a user-chosen password; a proper KDF would be a hardening nice-to-have, not a fix for a live weakness.
Pre-built binaries for all platforms in Releases:
| Platform | File |
|---|---|
| macOS x86_64 | cs-stream-darwin-amd64 |
| macOS ARM64 | cs-stream-darwin-arm64 |
| Linux x86_64 | cs-stream-linux-amd64 |
| Linux ARM64 | cs-stream-linux-arm64 |
| Windows x86_64 | cs-stream-windows-amd64.exe |
| FreeBSD x86_64 | cs-stream-freebsd-amd64 |
| Illumos x86_64 | cs-stream-illumos-amd64 |
| OmniOS/Solaris x86_64 | cs-stream-solaris-amd64 |
git clone https://github.com/guenther-alka/cs-stream.git
cd cs-stream
go build -o cs-stream .
# All platforms:
GOOS=linux GOARCH=amd64 go build -o cs-stream-linux-amd64 .
GOOS=windows GOARCH=amd64 go build -o cs-stream-windows-amd64.exe .
GOOS=freebsd GOARCH=amd64 go build -o cs-stream-freebsd-amd64 .
GOOS=solaris GOARCH=amd64 go build -o cs-stream-solaris-amd64 .Requires Go 1.21+. No external dependencies.
Place binary in data/cs_server/tools/cs-stream/<os>.amd64/cs-stream (Linux/BSD/OmniOS/Solaris)
or data\cs_server\tools\cs-stream\mswin.amd64\cs-stream.exe (Windows).
csweb-gui deploys and self-heals this automatically per member; manual placement is only needed to seed the admin host itself with a new version.
See .github/workflows/cs-stream.info for full documentation.
cs-stream is a self-contained, single-file Go binary with no dependency
on a napp-it CS installation at all -- unlike its sibling
cs-sync (which has one
opt-in --service-id exception writing to a fixed /opt path),
cs-stream's main.go has no /opt or napp-it reference whatsoever. It
is a generic encrypted stream transport: listen/send pipe stdin to
stdout like an encrypted netcat, tunnel-listen/tunnel-send wrap an
arbitrary local TCP service (e.g. rclone's SFTP server). Everything is
driven by CLI flags and the session key you supply.
Build and run standalone:
git clone https://github.com/guenther-alka/cs-stream.git
cd cs-stream
go build -o cs-stream .
# receiver:
cs-stream listen 9000 SESSIONKEY --allow-ip=192.168.1.20 > out.bin
# sender:
cs-stream send 192.168.1.10 9000 SESSIONKEY < in.binNo config file, registry entry, or napp-it environment is expected --
only a matching session key on both ends and, as of v2.1.0,
--allow-ip on the receiving side.
Start order matters: always start the receiver (listen /
tunnel-listen) first, then the sender (send / tunnel-send). The
receiver opens the port and waits (up to the accept timeout); a sender
with nothing listening on the other end simply fails to connect.
napp-it cs-sync/stream is open source. You may use, analyze, or modify it free of charge. You use it "as is" and bear sole responsibility for its use. This note does not replace the BSD 2-Clause license terms below -- it summarizes them in plain language.
BSD 2-Clause License -- Copyright (c) 2026 Guenther Alka / napp-it.org
Redistribution in source or binary form requires retaining the copyright notice. See LICENSE for full terms.