impl connection recovery
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## What this is
|
||||
|
||||
redapricot is a central-hub P2P tunnel that speaks an *extension* of the Minecraft Java
|
||||
Edition protocol. A **hub** (Java/Vert.x, one public TCP port) forwards players to a
|
||||
**client** (Go) sitting next to a real Minecraft server behind NAT. Everything — players
|
||||
and clients alike — arrives on the same port; the hub tells them apart by the handshake
|
||||
`Intent` field (`17` = redapricot, `18` reserved, anything else = player).
|
||||
|
||||
Two implementations of one wire protocol live in this repo, so **most changes are
|
||||
cross-language**. `PROTOCOL.md` is the normative spec; `docs/architecture.md` explains the
|
||||
design rationale.
|
||||
|
||||
## Build
|
||||
|
||||
`scripts/build.sh` builds both sides (hub via `installDist`, client into `bin/`). It
|
||||
auto-discovers a JDK/Gradle under `~/.sdkman/candidates/`.
|
||||
|
||||
**Gradle needs an explicit `JAVA_HOME` in this environment** — it is not inherited:
|
||||
|
||||
```bash
|
||||
JAVA_HOME=$HOME/.sdkman/candidates/java/current gradle -p server installDist
|
||||
JAVA_HOME=$HOME/.sdkman/candidates/java/current gradle -p server test
|
||||
JAVA_HOME=$HOME/.sdkman/candidates/java/current gradle -p server shadowJar # fat jar
|
||||
go build ./... # Go side
|
||||
```
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
./scripts/e2e.sh # build both, Go unit tests, then e2e
|
||||
go test ./client/... -count=1 # Go unit tests (codec, crypto, pool, shaper, velocity)
|
||||
go test ./e2e/... -count=1 -v -timeout 300s
|
||||
go test ./e2e/ -run TestRoundTrip -v # one e2e test
|
||||
go test ./client/ -run TestShaperEnforcesRate -v
|
||||
```
|
||||
|
||||
The e2e suite spawns the **real Java hub as a subprocess** from
|
||||
`server/build/install/redapricot-server` (via `-cp lib/* io.icybear.redapricot.Main`) and
|
||||
runs the Go client in-process against a mock destination. It fails fast if the hub has not
|
||||
been installed, so **rebuild the hub after touching Java** or e2e silently tests stale
|
||||
bytecode. `resolveJava` skips the suite when no JDK is found (`JAVA_HOME`, SDKMAN, `PATH`).
|
||||
|
||||
Timing-sensitive suites (`e2e/bandwidth_test.go`, `client/shaper_test.go`,
|
||||
`e2e/slowstream_test.go`, `e2e/resilience_test.go`, `e2e/resume_test.go`) assert on rates
|
||||
and recovery deadlines; give them slack rather than tightening thresholds. The resume tests
|
||||
are load-sensitive — a busy machine (a gradle daemon in the background) shifts the races
|
||||
they exercise, so run them a few times rather than trusting a single pass.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Connection kinds (all on one port)
|
||||
|
||||
| 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 |
|
||||
| any other intent | **player** — hostname regex-matched, then tunneled |
|
||||
|
||||
Session establishment (both kinds): plaintext handshake whose `Server Address` is
|
||||
`hex(SHA3-224(PSK))` → one frame encrypted with PSK-derived keys carrying
|
||||
`magic ‖ rand ‖ timestamp ‖ flags ‖ window` → **both sides switch ciphers** to keys derived
|
||||
from `rand‖ts` → hub replies `SessionReady` echoing accepted flags and its window.
|
||||
|
||||
### Player handoff
|
||||
|
||||
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
|
||||
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
|
||||
forwarded verbatim so the backend sees the original hostname.
|
||||
|
||||
### Code map
|
||||
|
||||
- `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),
|
||||
`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),
|
||||
`velocity.go` (Velocity modern-forwarding interception), `proxyproto.go` (HAProxy v2),
|
||||
`config.go` (config + all protocol constants), `wire/` (VarInt/MC codec, SHA3+ChaCha20,
|
||||
`FramedConn`).
|
||||
- `cmd/redapricot-client/` — binary entrypoint; `e2e/` — integration harness.
|
||||
|
||||
### Threading
|
||||
|
||||
- **Hub:** a single Vert.x verticle instance, so the pattern registry, CID table, and all
|
||||
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*
|
||||
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.
|
||||
|
||||
## Invariants to preserve when editing
|
||||
|
||||
- **Constants are mirrored** in `client/config.go` and `server/.../Protocol.java`. Changing
|
||||
one without the other is a silent protocol break; update `PROTOCOL.md` too.
|
||||
- **The frame length prefix is plaintext, the payload is encrypted.** This is deliberate: a
|
||||
reader always knows how many ciphertext bytes belong to the current frame, which makes the
|
||||
rekey cipher switch unambiguous. Do not encrypt the length.
|
||||
- **Per-direction keys, fixed zero nonce** (`SHA3-256(phaseKey ‖ 0x01)` c2s,
|
||||
`‖ 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
|
||||
`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`.
|
||||
- **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.
|
||||
- **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
|
||||
*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
|
||||
permanently zero, which deadlocks. `TestResumePreservesByteStream` guards this.
|
||||
- **The retained region needs no cap of its own.** Flow control already bounds outstanding
|
||||
bytes to one window, so the retransmit buffer *is* the outstanding region. Anything that
|
||||
lets a sender exceed its window silently makes it unbounded.
|
||||
- **Hub-side socket handlers must route through `st.worker`, never through a captured
|
||||
conn.** A lambda installed in `handleSyn` closes over `this`; after a reattach it would
|
||||
write into the dead conn's transport, where sends are dropped silently and the player goes
|
||||
mute with nothing logged.
|
||||
- **A closed control session orphans its routes rather than deleting them**
|
||||
(`PROTOCOL.md §5.2`, `Hub.removeSession`). Players arriving during the client's
|
||||
reconnect are held and replayed once it re-registers, instead of being told there
|
||||
is no such server. Two traps: `Hub.match` must surface the orphaned state rather
|
||||
than hand back a dead `ControlSession` (`EncryptedFrames.send` drops silently on a
|
||||
closed transport, so the player would hang with no request ever sent), and a held
|
||||
player's deadline is the registration grace, not `pendingTimeoutMs` — whichever is
|
||||
shorter fires first and closes the socket.
|
||||
- **The off switches must reach the hot path, not just the wire.** `streamResume: false`
|
||||
allocates no retained region and `statsIntervalMs: 0` allocates no counters, so both cost
|
||||
one predictable branch. `TestResumeDisabledAllocatesNothing` guards the first.
|
||||
|
||||
## Conventions
|
||||
|
||||
- Comments here explain *why*, often citing the failure they prevent, and reference
|
||||
`PROTOCOL.md §N`. Match that when adding code on either side.
|
||||
- Wire-visible behaviour changes need: both implementations, `PROTOCOL.md`, an e2e test, and
|
||||
usually a note in `docs/architecture.md` and the README config tables.
|
||||
- Java uses Lombok (freefair plugin) and Log4j2; JUL is routed through Log4j2 both via
|
||||
`applicationDefaultJvmArgs` and programmatically in `Main` for the `java -jar` path.
|
||||
Reference in New Issue
Block a user