13 KiB
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 (case-insensitively) against registered PATTERNs. |
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.
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:
- Decrypts frame 1 with Phase A.
- Rejects (closes) if
|now − Timestamp| > timestampWindowMs(default 30000), or ifRandLenis out of range. - Computes
REKEY = Rand ‖ Timestamp(the 8 timestamp bytes big-endian appended to Rand — theMagicbyte is not included). - 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) |
0x04 |
ControlRequest | S → C | CID: Bytes[16], Pattern: String, PlayerIP: String, PlayerPort: U16 |
0x05 |
Ping | C → S | Nonce: I64 |
0x06 |
Pong | S → C | Nonce: I64 |
- Register / Unregister: the client may (un)register a PATTERN at any time. Patterns are stored lower-cased. Re-registering an existing pattern reassigns it to the newest session (last writer wins).
- ControlRequest: emitted by the hub when a player Handshake matches a
PATTERN this session registered.
CIDis 16 cryptographically-random bytes generated by the hub, unique to that pending player.PlayerIP/PlayerPortare the player's source address (used for HAProxy v2). - Ping/Pong: optional keepalive so idle control sessions survive NAT timeouts. The client pings periodically; the hub echoes the nonce.
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:
- Pick the worker conn with the fewest active streams.
- If that minimum conn is saturated (active streams
> 8) andpoolSize < max_conn, dial a new worker conn and use it instead. - Otherwise use the least-loaded conn (even if it exceeds 8 at
max_conn).
7.2 End-to-end player flow
- Player connects to the hub and sends a Handshake with a matching
ServerAddressandIntent ∉ {17,18}. - 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 sendsControlRequeston the control session. If no SYN arrives withinpendingTimeoutMs(default 10000) the pending entry is dropped and the player socket closed. - The client receives
ControlRequest, looks up the destination forPattern, allocates a worker conn +StreamID, and sendsSYN(StreamID, CID). In parallel it dials the destination and (if configured) writes a HAProxy v2 header (§8) carryingPlayerIP:PlayerPort. - The hub matches
CIDto the pending player, binds(workerConn, StreamID) ↔ playerSocket, forwards the buffered bytes asDATA, and resumes the player socket. Subsequent player bytes becomeDATAframes;DATAframes from the client are written to the player socket. IfCIDis unknown/expired the hub repliesRST. - When the player disconnects the hub sends
FINon the stream; the client closes the destination. When the destination closes, the client sendsFIN; the hub closes the player socket.RSTis 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
{
"listen": "0.0.0.0:25565",
"psk": "change-me",
"timestampWindowMs": 30000,
"pendingTimeoutMs": 10000
}
9.2 Client — 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 }
]
}
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 |
| CID length | 16 bytes |
| max frame payload | 1 MiB |
| saturation threshold | active streams > 8 |
| max worker conns | max_conn ∈ [1,8] |