Files
redapricot/CLAUDE.md
T
2026-08-15 17:31:35 +08:00

164 lines
9.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.