initial commit
This commit is contained in:
+334
@@ -0,0 +1,334 @@
|
||||
# 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:
|
||||
|
||||
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) |
|
||||
| `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. `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).
|
||||
* **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:
|
||||
|
||||
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 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 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]` |
|
||||
```
|
||||
Reference in New Issue
Block a user