164 lines
9.8 KiB
Markdown
164 lines
9.8 KiB
Markdown
# 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.
|