Files
redapricot/CLAUDE.md
iceBear67 4df2560331 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.
2026-08-15 18:32:51 +08:00

10 KiB
Raw Permalink Blame History

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:

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

./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 — one player, 1:1
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 ‖ windowboth 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 dials a dedicated worker conn, SYNs 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 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 (1:1 tunnel + flow-control state), net/EncryptedFrames, crypto/Crypto, util/ (VarInt, ProtoReader/Writer, Hex).
  • 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).
  • 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 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: 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

  • 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-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.
  • 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.
  • 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 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.