break: replace muxed workers with 1:1 tunnels

Worker frames are now FrameType + payload; there is no stream id.
Each player gets its own worker conn. maxTunnels (default 256)
caps concurrent tunnels. The old maxConn pool size is ignored so
existing configs do not silently admit only a handful of players.

Resume, per-direction windows, the control session, and the
DATA-only shaper stay. A dropped worker still hangs that one
player and reattaches over a fresh conn.

Add a hub-side per-IP limiter for player intents only (default
8/s, burst 16, 64 concurrent). Unmatched hostnames consume a
token; Intent 17 is never counted. 0 disables each knob.
This commit is contained in:
iceBear67
2026-08-15 18:32:51 +08:00
parent da17140583
commit 4df2560331
27 changed files with 1174 additions and 856 deletions
+20 -17
View File
@@ -57,7 +57,7 @@ they exercise, so run them a few times rather than trusting a single pass.
| First handshake | Becomes |
|---|---|
| `Intent 17`, rekey magic `0x01` | **control session** — pattern registration, `ControlRequest`, Ping/Pong |
| `Intent 17`, rekey magic `0x02` | **worker conn**multiplexed player streams |
| `Intent 17`, rekey magic `0x02` | **worker conn**one player, 1:1 |
| any other intent | **player** — hostname regex-matched, then tunneled |
Session establishment (both kinds): plaintext handshake whose `Server Address` is
@@ -69,8 +69,8 @@ from `rand‖ts` → hub replies `SessionReady` echoing accepted flags and its w
The hub pauses and buffers a matched player socket, mints a random 16-byte **CID**, and
sends `ControlRequest(CID, matchedPattern, ip, port)` down the control session. The client
allocates a worker stream, `SYN`s it with that CID, dials the destination, and the hub
binds the pending player to that stream. The CID's secrecy (it only ever travels encrypted
dials a dedicated worker conn, `SYN`s it with that CID, dials the destination, and the hub
binds the pending player to that conn. The CID's secrecy (it only ever travels encrypted
over the control session) is what authorizes the takeover — there is no other client
identity check. The hub echoes **the matched pattern string, not the player's hostname**,
so the client can look it up directly in its route table; the buffered handshake is
@@ -80,10 +80,10 @@ forwarded verbatim so the backend sees the original hostname.
- `server/src/main/java/io/icybear/redapricot/``HubConnection` (per-socket state machine:
handshake parse → dispatch → rekey → control/worker/player), `Hub` (pattern registry, CID
table, pending players), `ControlSession`, `WorkerConn` (mux demux + per-stream state),
table, pending players), `ControlSession`, `WorkerConn` (1:1 tunnel + flow-control state),
`net/EncryptedFrames`, `crypto/Crypto`, `util/` (VarInt, ProtoReader/Writer, Hex).
- `client/``client.go` (control session, reconnect, dispatch), `worker.go` (pool,
allocation, `WorkerConn`, `Stream` and its two goroutines), `shaper.go` (egress rate cap),
- `client/``client.go` (control session, reconnect, dispatch), `worker.go` (1:1
dial, `WorkerConn`, `Stream` and its two goroutines), `shaper.go` (egress rate cap),
`velocity.go` (Velocity modern-forwarding interception), `proxyproto.go` (HAProxy v2),
`config.go` (config + all protocol constants), `wire/` (VarInt/MC codec, SHA3+ChaCha20,
`FramedConn`).
@@ -95,10 +95,10 @@ forwarded verbatim so the backend sees the original hostname.
per-connection state are touched by one event loop and need no locking. Never introduce a
blocking call there.
- **Client:** one goroutine reads each connection; `WriteFrame` is mutex-serialized. Each
stream has exactly two goroutines — `run` (destination → hub) and `writeLoop` (the *only*
tunnel has exactly two goroutines — `run` (destination → hub) and `writeLoop` (the *only*
writer to the destination, draining a queue fed by the worker readLoop). The readLoop must
never write to a destination, or a stalled backend blocks frame dispatch for every other
stream on that conn.
never write to a destination: WND is granted only after the dest write completes, and a
stalled backend must not stall heartbeat / FIN dispatch on that conn.
## Invariants to preserve when editing
@@ -111,25 +111,28 @@ forwarded verbatim so the backend sees the original hostname.
`‖ 0x02` s2c). Both directions must never share a keystream. Go `x/crypto/chacha20` and
Java JCE `ChaCha20` are byte-identical here, and unit tests on both sides pin the same
SHA3-224 vector — keep that pinning if you touch crypto.
- **Per-stream flow control is mandatory.** The hub rejects a session whose rekey lacks
- **Per-connection flow control is mandatory.** The hub rejects a session whose rekey lacks
`FLAG_STREAM_FC`; the client rejects a hub that does not echo it. Credit is granted back
(`WND`) only as bytes are actually written to the terminal socket, batched at half-window.
- **Only stream ids allocated by the client exist** (a per-conn counter starting at 1);
sid `0` is reserved for connection-scoped `PING`/`PONG`.
- **One worker conn carries one player.** There is no stream id. The first business frame
after `SessionReady` is `SYN` or `RESUME`; a second bind on the same conn is a protocol
violation. `PING`/`PONG` are connection-scoped frames.
- **Only DATA is shaped** by `client/shaper.go`. Delaying `FIN`, `WND`, or `PONG` would trip
the very liveness detection the heartbeat exists for. The shaper is client-local and
invisible on the wire.
- **The pool grows breadth-first** (`StreamsBeforeGrowing = 1`): dial up to `maxConn` before
stacking streams, so one TCP connection is never the shared point of failure for every
player. `SaturationThreshold = 8` only logs, once the pool is already at `maxConn`.
A dial is never performed while holding the pool lock.
- **Each player gets its own worker dial**, up to `maxTunnels` (default 256). A dial is
never performed while holding the live-set lock: session establishment is network I/O,
and one unresponsive hub must not block unrelated players.
- **The hub IP limiter is player-only.** Intent 17 (control + workers) is never
admitted through it — those sockets share the client's one address. Unmatched
player hostnames still consume a token. `0` turns each knob off.
- **Liveness is explicit everywhere:** every session heartbeats (drop after
`3 × pingIntervalMs`), every socket write is bounded, establishment has a deadline. A
silently blackholed path (NAT forgetting a flow, no FIN/RST) must recover without operator
action — `TestBlackholedPathRecovers` guards this.
- **Stream resumption is byte-exact or it is nothing** (`PROTOCOL.md §7.5`,
`client/resume.go`, `WorkerConn.handleResume`). A worker-conn drop hangs the player and
reattaches the stream over a fresh conn. Three offsets are tracked per direction and are
reattaches over a fresh conn. Three offsets are tracked per direction and are
*not* interchangeable: replay from the peer's **accepted** offset, restate the window from
its **delivered** offset, and never size the window from **credited** — the grants in
flight when the conn died are gone for good, and a window derived from them can be