# redapricot (红杏) wire protocol redapricot is a central-hub P2P tunnel that speaks (an extension of) the Minecraft Java Edition protocol. This document is the normative wire spec that the Java **server** (the *hub*) and the Go **client** both implement. It is self-contained: everything needed to write an interoperable implementation is here. There are three roles: | Role | Language | Description | |------------|----------|-------------| | **Server** | Java | The central hub. Accepts every inbound TCP connection (players *and* clients) on one port. | | **Client** | Go | Registers routing patterns with the hub and forwards player traffic to real destinations. | | **Player** | any | An ordinary Minecraft client connecting through the hub. | ``` Player ──MC──▶ Hub(server) ══WorkerConn(mux)══▶ Client ──MC──▶ Destination ▲ registers patterns / receives control requests │ └────────────── Control Session ────────────────────┘ ``` Security is intentionally lightweight: the goal is connectivity, not confidentiality against a determined attacker. The single shared secret is the **PSK** (pre-shared key), a UTF-8 passphrase configured on the hub and every client. --- ## 1. Primitive data types These follow the Minecraft protocol exactly. | Type | Encoding | |-----------|----------| | `VarInt` | LEB128, 7 data bits per byte, high bit = continuation, little-endian groups, two's-complement, max 5 bytes. | | `String` | `VarInt` byte-length of the UTF-8 encoding, followed by the UTF-8 bytes. | | `U16` | unsigned 16-bit, **big-endian**. | | `I64` | signed 64-bit, **big-endian**. | | `Bytes[N]`| exactly N raw bytes, no length prefix. | | `u8` | single unsigned byte. | ## 2. Minecraft packet framing (plaintext) Every connection begins as an ordinary Minecraft connection. An uncompressed Minecraft packet is: ``` [Length: VarInt][PacketID: VarInt][Data...] Length = len(PacketID)+len(Data) ``` redapricot **never** enables Minecraft compression on the hub link. Player traffic that is compressed end-to-end (negotiated between the player and the real destination) is irrelevant — the hub forwards raw bytes and never inspects anything past the Handshake. ### 2.1 Handshake The first packet on every connection is the Handshake (packet id `0x00`, Handshaking state): ``` ProtocolVersion : VarInt ServerAddress : String (≤ 255) ServerPort : U16 Intent : VarInt ``` The hub reads exactly one Handshake packet and dispatches on `Intent`: | Intent | Meaning | |---------------|---------| | `17` | redapricot session establishment (control session *or* worker conn). | | `18` | Reserved for redapricot management/status. Never matched against patterns. The reference hub replies with a status line and closes. | | anything else | **Player** connection. `ServerAddress` is matched against the registered **regex** PATTERNs (§5.1). | For `Intent == 17` the hub additionally requires `ServerAddress == lowercase_hex(SHA3-224(PSK))` — a 56-character hex string. This is the first (cheap) proof that the peer knows the PSK. A mismatch closes the connection. For player connections the hub normalizes `ServerAddress` before matching: lower-cased, and any trailing `.` or Forge/FML `\0`-suffix (`host\0FML\0`) stripped to the bare hostname. The resulting hostname is then tested against the registered regex patterns (§5.1). ## 3. Encryption Immediately **after** the `Intent == 17` Handshake, the connection switches to an encrypted, self-delimiting frame stream. redapricot uses **ChaCha20** (RFC 8439, 32-bit block counter, 96-bit nonce) as a raw stream cipher applied to frame payloads (no Poly1305 tag — padding/space overhead is minimized, matching the design goal). Each direction is an independent ChaCha20 keystream. Keys are derived from a "phase key" `PK` (raw bytes) as: ``` keyC2S = SHA3-256(PK ‖ 0x01) # client → server keyS2C = SHA3-256(PK ‖ 0x02) # server → client nonce = 0x00 × 12 # both directions counter starts at 0 # both directions ``` Using distinct keys per direction avoids a two-time-pad while keeping the nonce trivially fixed. Each side keeps two ChaCha20 instances (one encrypt, one decrypt) and feeds bytes through them incrementally; the keystream position is maintained across writes. There are two phases: * **Phase A** — `PK = PSK` (the configured passphrase, UTF-8 bytes). * **Phase B** — `PK = REKEY` (see §4), used for the remainder of the connection. ### 3.1 Encrypted frames Once encryption is on, the connection speaks length-prefixed **frames**: ``` [Length: VarInt] # PLAINTEXT (not encrypted) [Payload: Bytes[Length]] # ciphertext (ChaCha20) ``` Only the payload is encrypted; the `Length` prefix is sent in the clear. The cipher is a continuous per-direction keystream: each frame's payload advances the keystream by exactly `Length` bytes (the length prefix consumes no keystream). This keeps framing trivial — a reader reads a plaintext VarInt, then decrypts exactly that many following bytes as one unit — and lets the cipher phase switch (§4) happen cleanly on a frame boundary without ever decrypting a later frame's bytes with the wrong key. Max payload length is `1 MiB`; larger closes the connection. ## 4. Session establishment (Intent 17) The first frame is sent by the peer that opened the connection (client → server) and is encrypted with **Phase A**. Its payload is the **Rekey** message: ``` Magic : u8 # 0x01 = control session, 0x02 = worker conn RandLen : VarInt # 8 ≤ RandLen ≤ 64 Rand : Bytes[RandLen] # cryptographically random Timestamp : I64 # client's epoch milliseconds ``` The hub: 1. Decrypts frame 1 with Phase A. 2. Rejects (closes) if `|now − Timestamp| > timestampWindowMs` (default 30000), or if `RandLen` is out of range. 3. Computes `REKEY = Rand ‖ Timestamp` (the 8 timestamp bytes big-endian appended to Rand — the `Magic` byte is **not** included). 4. Switches **both** its ciphers to Phase B keys derived from `REKEY`. The client, after sending frame 1 with Phase A, likewise switches both its ciphers to Phase B. In practice **only frame 1 uses Phase A**; every later frame (both directions) is Phase B, counters reset to 0. The hub then sends one Phase-B frame to confirm success: ``` SessionReady : payload = [ 0x00 ] ``` A hub that rejects the session simply closes the TCP connection (optionally after a Phase-B `Error` frame, §6). After `SessionReady`: * `Magic == 0x01` → the connection is a **Control Session** (§5). * `Magic == 0x02` → the connection is a **Worker Conn** (§7). ## 5. Control session messages After `SessionReady`, a control session exchanges **control messages**, one per encrypted frame. Frame payload: ``` Type : u8 ... : type-specific fields ``` | Type | Name | Direction | Fields | |--------|----------------|-----------|--------| | `0x00` | SessionReady | S → C | *(none)* — the confirmation frame from §4 | | `0x01` | Register | C → S | `Pattern: String` | | `0x02` | Unregister | C → S | `Pattern: String` | | `0x03` | RegisterAck | S → C | `Pattern: String`, `Status: u8` (0 = ok, 1 = invalid pattern) | | `0x04` | ControlRequest | S → C | `CID: Bytes[16]`, `Pattern: String`, `PlayerIP: String`, `PlayerPort: U16`, `Username: String` | | `0x05` | Ping | C → S | `Nonce: I64` | | `0x06` | Pong | S → C | `Nonce: I64` | * **Register / Unregister**: the client may (un)register a PATTERN at any time. A PATTERN is a **regular expression** (§5.1) and is stored **verbatim** — the exact string is the registry key (never normalized, so regex metacharacters are preserved). Re-registering the identical pattern string reassigns it to the newest session (last writer wins); `Unregister` only removes it if the requesting session still owns it. * **RegisterAck**: acknowledges a `Register`. `Status` is `0` on success, or `1` if the pattern is not a valid regular expression (in which case nothing is registered). The `Pattern` echoes the string that was registered. * **ControlRequest**: emitted by the hub when a player Handshake matches a PATTERN this session registered. `Pattern` is the **registered pattern string that matched** (echoed verbatim), *not* the player's hostname — so the client can look the pattern up in its own route table. `CID` is 16 cryptographically-random bytes generated by the hub, unique to that pending player. `PlayerIP`/`PlayerPort` are the player's source address (used for HAProxy v2). `Username` is the player's name, read best-effort from the Login Start packet — present when the client pipelined it with the Handshake (the usual case), otherwise an empty string. It is informational (logging) only. * **Ping/Pong**: optional keepalive so idle control sessions survive NAT timeouts. The client pings periodically; the hub echoes the nonce. ### 5.1 Pattern matching A registered PATTERN is a **regular expression** (the reference hub uses `java.util.regex`). Matching is: * **Case-insensitive** — patterns are compiled with a case-insensitive flag, and the player hostname is lower-cased during normalization (§2.1). * **Whole-string (anchored)** — the pattern must match the *entire* normalized hostname, as if wrapped in `^…$`. `mc\.example\.com` matches `mc.example.com` but not `mc.example.com.evil` or `sub.mc.example.com`. * **First match wins** — the hostname is tested against every registered pattern; the first that matches routes the player. If several patterns overlap, which one wins is unspecified. Because the pattern is a regex, a literal dot must be escaped (`mc\.example\.com`); an unescaped `.` is the regex "any character" wildcard. A pattern that fails to compile is rejected at `Register` time with `RegisterAck` status `1`. ## 6. Error frame (any redapricot connection) At any time either side may send, then close: ``` Type : u8 = 0x7F Msg : String ``` Purely informational; the receiver logs it. ## 7. Worker conn & multiplexing A **Worker Conn** (`Magic == 0x02`) carries player↔destination traffic for many players over one TCP connection using a minimal stream multiplexer. The unit of work is a **stream**. Stream ids are assigned by the **client** (the only side that opens streams), unique per worker conn, starting at 1 and increasing. Each encrypted frame on a worker conn carries one **mux frame**: ``` FrameType : u8 StreamID : VarInt Data : Bytes[...] # remainder of the frame payload ``` | FrameType | Name | Direction | Data | |-----------|------|-----------|------| | `0x00` | SYN | C → S | `CID: Bytes[16]` — open a stream to take over the pending player identified by CID. | | `0x01` | DATA | both | raw tunneled bytes for the stream. | | `0x02` | FIN | both | *(empty)* — graceful close of the stream (both directions). This is the "disconnect" the hub sends when the player leaves. | | `0x03` | RST | both | *(optional 1 byte reason)* — abnormal close (e.g. CID unknown/expired, destination dial failed). | There is no explicit SYN-ACK: success is implied by the hub forwarding the buffered Handshake as the stream's first `DATA`; failure is an `RST`. ### 7.1 Stream allocation (client side) The client keeps a pool of `1 ≤ N ≤ max_conn` worker conns (`max_conn` configurable, `1..8`). To place a new stream: 1. Pick the worker conn with the **fewest active streams**. 2. If that minimum conn is **saturated** (active streams `> 8`) **and** `poolSize < max_conn`, dial a new worker conn and use it instead. 3. Otherwise use the least-loaded conn (even if it exceeds 8 at `max_conn`). ### 7.2 End-to-end player flow 1. Player connects to the hub and sends a Handshake with a matching `ServerAddress` and `Intent ∉ {17,18}`. 2. Hub normalizes the address, finds the registering control session, generates `CID`, **pauses** the player socket, buffers everything read so far (the raw Handshake plus any pipelined bytes), and sends `ControlRequest` on the control session. If no SYN arrives within `pendingTimeoutMs` (default 10000) the pending entry is dropped and the player socket closed. 3. The client receives `ControlRequest`, looks up the destination for `Pattern`, allocates a worker conn + `StreamID`, and sends `SYN(StreamID, CID)`. In parallel it dials the destination and (if configured) writes a HAProxy v2 header (§8) carrying `PlayerIP:PlayerPort`. 4. The hub matches `CID` to the pending player, binds `(workerConn, StreamID) ↔ playerSocket`, forwards the buffered bytes as `DATA`, and resumes the player socket. Subsequent player bytes become `DATA` frames; `DATA` frames from the client are written to the player socket. If `CID` is unknown/expired the hub replies `RST`. 5. When the player disconnects the hub sends `FIN` on the stream; the client closes the destination. When the destination closes, the client sends `FIN`; the hub closes the player socket. `RST` is treated the same way (hard close). Data on a worker conn is subject to that TCP connection's back-pressure. Each stream has a bounded outbound queue on the receiving side; overflow resets the stream (`RST`). (This is a deliberate simplification — no per-stream credit windows — acceptable for the interactive, low-throughput Minecraft handshake + gameplay traffic pattern.) ## 8. HAProxy protocol v2 (optional) When a mapping has `proxyProtocol: true`, the client prepends a PROXY v2 header to the destination connection *before* any tunneled bytes, so the real server sees the player's true source address. ``` Signature : 0D 0A 0D 0A 00 0D 0A 51 55 49 54 0A (12 bytes) VerCmd : 0x21 (v2, PROXY command) FamProto : 0x11 (TCP/IPv4) | 0x21 (TCP/IPv6) Len : U16 (length of the address block) Addresses : IPv4 → srcAddr[4] dstAddr[4] srcPort[2] dstPort[2] (12 bytes) IPv6 → srcAddr[16] dstAddr[16] srcPort[2] dstPort[2] (36 bytes) ``` `src` is the player; `dst` is the destination the client dialed. Ports are big-endian. ## 9. Configuration ### 9.1 Hub (server) — JSON ```json { "listen": "0.0.0.0:25565", "psk": "change-me", "timestampWindowMs": 30000, "pendingTimeoutMs": 10000 } ``` ### 9.2 Client — JSON ```json { "server": "127.0.0.1:25565", "psk": "change-me", "maxConn": 4, "pingIntervalMs": 20000, "mappings": [ { "pattern": "mc\\.example\\.com", "destination": "127.0.0.1:25566", "proxyProtocol": true } ] } ``` Each `pattern` is a regular expression (§5.1) matched against the whole normalized player hostname, case-insensitively. Escape literal dots (`mc\.example\.com`, which is `mc\\.example\\.com` in JSON); an unescaped `.` matches any character. Use ordinary regex to route wildcards, e.g. `.*\.example\.com` for every subdomain or `(alpha|beta)\.mc\.net` for a fixed set. ## 10. Constants summary | Name | Value | |------|-------| | redapricot Handshake intent | `17` | | reserved management intent | `18` | | Handshake address for Intent 17 | `hex(SHA3-224(PSK))` | | cipher | ChaCha20 (RFC 8439), 12-byte zero nonce, per-direction key, payload-only | | frame length prefix | plaintext VarInt | | key derivation | `SHA3-256(PK ‖ 0x01)` c→s, `SHA3-256(PK ‖ 0x02)` s→c | | rekey material | `Rand ‖ Timestamp(I64 BE)` | | Magic: control / worker | `0x01` / `0x02` | | RegisterAck status: ok / invalid pattern | `0x00` / `0x01` | | pattern matching | case-insensitive, whole-string regex; first match wins | | CID length | 16 bytes | | max frame payload | 1 MiB | | saturation threshold | active streams `> 8` | | max worker conns | `max_conn ∈ [1,8]` | ```